Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions Readme.rdoc
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,52 @@ The primary class you will use to track events is Mixpanel::Tracker. An instance
Mixpanel::Tracker is enough to send events directly to \Mixpanel, and get you integrated
right away.

== Service Account Authentication

Service accounts provide secure server-to-server authentication and are recommended over
API keys for import operations and feature flags.

=== Import with Service Account

require 'mixpanel-ruby'

# Create service account credentials
credentials = Mixpanel::ServiceAccountCredentials.new(
'your-service-account-username',
'your-service-account-secret',
'your-project-id'
)

tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN)

# Import historical events using service account
tracker.import(credentials, 'User1', 'Past Event', {
'time' => 1369353600,
'Source' => 'Import'
})

=== Feature Flags with Service Account

require 'mixpanel-ruby'

credentials = Mixpanel::ServiceAccountCredentials.new(
'your-service-account-username',
'your-service-account-secret',
'your-project-id'
)

# Pass credentials directly to the tracker
tracker = Mixpanel::Tracker.new(
YOUR_MIXPANEL_TOKEN,
nil,
credentials: credentials,
local_flags_config: {},
remote_flags_config: {}
)

# Use feature flags
is_enabled = tracker.remote_flags.is_enabled?('my-flag', { 'distinct_id' => 'User1' })

== Additional Information

For more information please visit:
Expand Down
1 change: 1 addition & 0 deletions lib/mixpanel-ruby.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
require 'mixpanel-ruby/consumer.rb'
require 'mixpanel-ruby/tracker.rb'
require 'mixpanel-ruby/version.rb'
require 'mixpanel-ruby/credentials.rb'
require 'mixpanel-ruby/flags/utils.rb'
require 'mixpanel-ruby/flags/types.rb'
require 'mixpanel-ruby/flags/flags_provider.rb'
Expand Down
24 changes: 21 additions & 3 deletions lib/mixpanel-ruby/consumer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -85,13 +85,18 @@ def send!(type, message)

decoded_message = JSON.load(message)
api_key = decoded_message["api_key"]
credentials = decoded_message["credentials"]
data = Base64.encode64(decoded_message["data"].to_json).gsub("\n", '')

form_data = {"data" => data, "verbose" => 1}
form_data.merge!("api_key" => api_key) if api_key

# Only add api_key to form data if using legacy API key (not service account credentials)
if api_key && !credentials
form_data.merge!("api_key" => api_key)
end

begin
response_code, response_body = request(endpoint, form_data)
response_code, response_body = request(endpoint, form_data, credentials, type)
rescue => e
raise ConnectionError.new("Could not connect to Mixpanel, with error \"#{e.message}\".")
end
Expand Down Expand Up @@ -123,11 +128,24 @@ def send(type, message)
#
# as the result of the response. Response code should be nil if
# the request never receives a response for some reason.
def request(endpoint, form_data)
def request(endpoint, form_data, credentials = nil, type = nil)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
uri = URI(endpoint)

# Add project_id as query parameter for import endpoint with service account credentials
if credentials && type == :import
query_params = URI.decode_www_form(uri.query || '').to_h
query_params['project_id'] = credentials['project_id']
uri.query = URI.encode_www_form(query_params)
end

request = Net::HTTP::Post.new(uri.request_uri)
request.set_form_data(form_data)

# Use Basic Auth with service account credentials for import endpoint
if credentials && type == :import
request.basic_auth(credentials['username'], credentials['secret'])
end

client = Net::HTTP.new(uri.host, uri.port)
client.use_ssl = true
client.open_timeout = 10
Expand Down
40 changes: 40 additions & 0 deletions lib/mixpanel-ruby/credentials.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
module Mixpanel
# Service account credentials for server-to-server authentication
# This is the recommended authentication method over API keys
class ServiceAccountCredentials
attr_reader :username, :secret, :project_id

# Create service account credentials
# @param username [String] Service account username
# @param secret [String] Service account secret
# @param project_id [String, Integer] Mixpanel project ID (accepts string or integer)
def initialize(username, secret, project_id)
raise ArgumentError, 'username is required' if username.nil? || username.empty?
raise ArgumentError, 'secret is required' if secret.nil? || secret.empty?
raise ArgumentError, 'project_id is required' if project_id.nil?

# Convert project_id to string if it's an integer (Mixpanel dashboard shows numeric IDs)
project_id = project_id.to_s if project_id.is_a?(Integer)
raise ArgumentError, 'project_id is required' if project_id.empty?

@username = username
@secret = secret
@project_id = project_id
end

# JSON serialization support - called automatically by JSON.generate/to_json
# Note: secret IS included because it's needed by the Consumer for HTTP Basic Auth
def as_json(options = nil)
{
'username' => @username,
'secret' => @secret,
'project_id' => @project_id
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
end
Comment thread
greptile-apps[bot] marked this conversation as resolved.

# Explicit to_json method for direct .to_json calls
def to_json(*args)
as_json.to_json(*args)
end
end
end
19 changes: 13 additions & 6 deletions lib/mixpanel-ruby/events.rb
Original file line number Diff line number Diff line change
Expand Up @@ -89,17 +89,17 @@ def track(distinct_id, event, properties={}, ip=nil)
#
# tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN)
#
# # Track that user "12345"'s credit card was declined
# # Using deprecated API key (still supported)
# tracker.import("API_KEY", "12345", "Credit Card Declined")
#
# # Properties describe the circumstances of the event,
# # or aspects of the source or user associated with the event
# tracker.import("API_KEY", "12345", "Welcome Email Sent", {
# # Using service account credentials (recommended)
# credentials = Mixpanel::ServiceAccountCredentials.new(username, secret, project_id)
# tracker.import(credentials, "12345", "Welcome Email Sent", {
# 'Email Template' => 'Pretty Pink Welcome',
# 'User Sign-up Cohort' => 'July 2013',
# 'time' => 1369353600,
# })
def import(api_key, distinct_id, event, properties={}, ip=nil)
def import(api_key_or_credentials, distinct_id, event, properties={}, ip=nil)
properties = {
'distinct_id' => distinct_id,
'token' => @token,
Expand All @@ -116,9 +116,16 @@ def import(api_key, distinct_id, event, properties={}, ip=nil)

message = {
'data' => data,
'api_key' => api_key,
}

# Support both service account credentials and legacy API key
if api_key_or_credentials.is_a?(ServiceAccountCredentials)
message['credentials'] = api_key_or_credentials
else
warn '[DEPRECATION] Using API key for import is deprecated. Please use ServiceAccountCredentials instead. See https://developer.mixpanel.com/reference/service-accounts for more information.'
Comment thread
lajohn4747 marked this conversation as resolved.
message['api_key'] = api_key_or_credentials
end

ret = true
begin
@sink.call(:import, message.to_json)
Expand Down
17 changes: 15 additions & 2 deletions lib/mixpanel-ruby/flags/flags_provider.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ module Flags
# Base class for feature flags providers
# Provides common HTTP handling and exposure event tracking
class FlagsProvider
# @param provider_config [Hash] Configuration with :token, :api_host, :request_timeout_in_seconds
# @param provider_config [Hash] Configuration with :token, :api_host, :request_timeout_in_seconds, :credentials (optional)
# @param endpoint [String] API endpoint path (e.g., '/flags' or '/flags/definitions')
# @param tracker_callback [Proc] Function used to track events (bound tracker.track method)
# @param evaluation_mode [String] The feature flag evaluation mode. This is either 'local' or 'remote'
Expand All @@ -23,6 +23,7 @@ def initialize(provider_config, endpoint, tracker_callback, evaluation_mode, err
@tracker_callback = tracker_callback
@evaluation_mode = evaluation_mode
@error_handler = error_handler
@credentials = provider_config[:credentials]
end

# Make HTTP request to flags API endpoint
Expand All @@ -31,12 +32,19 @@ def initialize(provider_config, endpoint, tracker_callback, evaluation_mode, err
# @raise [Mixpanel::ConnectionError] on network errors
# @raise [Mixpanel::ServerError] on HTTP errors
def call_flags_endpoint(additional_params = nil)
# Always use token in query params
common_params = Utils.prepare_common_query_params(
@provider_config[:token],
Mixpanel::VERSION
)

params = common_params.merge(additional_params || {})

# Add project_id as query parameter when using service account credentials
if @credentials
params['project_id'] = @credentials.project_id
end

query_string = URI.encode_www_form(params)

uri = URI::HTTPS.build(
Expand All @@ -53,7 +61,12 @@ def call_flags_endpoint(additional_params = nil)

request = Net::HTTP::Get.new(uri.request_uri)

request.basic_auth(@provider_config[:token], '')
# Use service account credentials for basic auth if provided, otherwise use token
if @credentials
request.basic_auth(@credentials.username, @credentials.secret)
else
request.basic_auth(@provider_config[:token], '')
end
Comment thread
lajohn4747 marked this conversation as resolved.

request['Content-Type'] = 'application/json'
request['traceparent'] = Utils.generate_traceparent
Expand Down
6 changes: 4 additions & 2 deletions lib/mixpanel-ruby/flags/local_flags_provider.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ class LocalFlagsProvider < FlagsProvider
# @param config [Hash] Local flags configuration
# @param tracker_callback [Proc] Callback to track events
# @param error_handler [Mixpanel::ErrorHandler] Error handler
def initialize(token, config, tracker_callback, error_handler)
# @param credentials [ServiceAccountCredentials, nil] Optional service account credentials
def initialize(token, config, tracker_callback, error_handler, credentials = nil)
# compact: an explicit nil from the caller (e.g.
# polling_interval_in_seconds: nil) must not override a sane default.
# Both the previous sleep(nil) and the current
Expand All @@ -35,7 +36,8 @@ def initialize(token, config, tracker_callback, error_handler)
provider_config = {
token: token,
api_host: @config[:api_host],
request_timeout_in_seconds: @config[:request_timeout_in_seconds]
request_timeout_in_seconds: @config[:request_timeout_in_seconds],
credentials: credentials
}

super(provider_config, '/flags/definitions', tracker_callback, 'local', error_handler)
Expand Down
6 changes: 4 additions & 2 deletions lib/mixpanel-ruby/flags/remote_flags_provider.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,17 @@ class RemoteFlagsProvider < FlagsProvider

# @param token [String] Mixpanel project token
# @param config [Hash] Remote flags configuration
# @param credentials [ServiceAccountCredentials, nil] Optional service account credentials
# @param tracker_callback [Proc] Callback to track events
# @param error_handler [Mixpanel::ErrorHandler] Error handler
def initialize(token, config, tracker_callback, error_handler)
def initialize(token, config, credentials, tracker_callback, error_handler)
merged_config = DEFAULT_CONFIG.merge(config || {})

provider_config = {
token: token,
api_host: merged_config[:api_host],
request_timeout_in_seconds: merged_config[:request_timeout_in_seconds]
request_timeout_in_seconds: merged_config[:request_timeout_in_seconds],
credentials: credentials
}

super(provider_config, '/flags', tracker_callback, 'remote', error_handler)
Expand Down
21 changes: 14 additions & 7 deletions lib/mixpanel-ruby/tracker.rb
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,12 @@ class Tracker < Events
# If a block is provided, it is passed a type (one of :event or :profile_update)
# and a string message. This same format is accepted by Mixpanel::Consumer#send!
# and Mixpanel::BufferedConsumer#send!
def initialize(token, error_handler=nil, local_flags_config: nil, remote_flags_config: nil, &block)
#
# Optional parameters:
# - credentials: ServiceAccountCredentials for feature flags authentication
# - local_flags_config: Configuration hash for local feature flags
# - remote_flags_config: Configuration hash for remote feature flags
def initialize(token, error_handler=nil, credentials: nil, local_flags_config: nil, remote_flags_config: nil, &block)
super(token, error_handler, &block)
@token = token
@people = People.new(token, error_handler, &block)
Expand All @@ -74,7 +79,8 @@ def initialize(token, error_handler=nil, local_flags_config: nil, remote_flags_c
token,
local_flags_config,
method(:track), # Pass bound method as callback
error_handler || ErrorHandler.new
error_handler || ErrorHandler.new,
credentials
)
end

Expand All @@ -83,6 +89,7 @@ def initialize(token, error_handler=nil, local_flags_config: nil, remote_flags_c
@remote_flags = Flags::RemoteFlagsProvider.new(
token,
remote_flags_config,
credentials,
method(:track), # Pass bound method as callback
error_handler || ErrorHandler.new
)
Expand Down Expand Up @@ -122,19 +129,19 @@ def track(distinct_id, event, properties={}, ip=nil)
#
# tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN)
#
# # Import event that user "12345"'s credit card was declined
# # Using deprecated API key (still supported)
# tracker.import("API_KEY", "12345", "Credit Card Declined", {
# 'time' => 1310111365
# })
#
# # Properties describe the circumstances of the event,
# # or aspects of the source or user associated with the event
# tracker.import("API_KEY", "12345", "Welcome Email Sent", {
# # Using service account credentials (recommended)
# credentials = Mixpanel::ServiceAccountCredentials.new(username, secret, project_id)
# tracker.import(credentials, "12345", "Welcome Email Sent", {
# 'Email Template' => 'Pretty Pink Welcome',
# 'User Sign-up Cohort' => 'July 2013',
# 'time' => 1310111365
# })
def import(api_key, distinct_id, event, properties={}, ip=nil)
def import(api_key_or_credentials, distinct_id, event, properties={}, ip=nil)
# This is here strictly to allow rdoc to include the relevant
# documentation
super
Expand Down
24 changes: 24 additions & 0 deletions spec/mixpanel-ruby/consumer_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,30 @@
with(:body => {'data' => 'IlRFU1QgRVZFTlQgTUVTU0FHRSI=', 'api_key' => 'API_KEY', 'verbose' => '1' })
end

it 'should send a request to api.mixpanel.com/import with service account credentials' do
stub_request(:any, 'https://api.mixpanel.com/import?project_id=test-project-123').to_return({:body => '{"status": 1, "error": null}'})
credentials = {
'username' => 'test-user',
'secret' => 'test-secret',
'project_id' => 'test-project-123'
}
subject.send!(:import, {'data' => 'TEST EVENT MESSAGE', 'credentials' => credentials}.to_json)

# Should use Basic Auth header with username:secret
# Should add project_id as query parameter
# Should NOT include credentials in POST body
expect(WebMock).to have_requested(:post, 'https://api.mixpanel.com/import?project_id=test-project-123').
with(
:body => {
'data' => 'IlRFU1QgRVZFTlQgTUVTU0FHRSI=',
'verbose' => '1'
},
:headers => {
'Authorization' => 'Basic ' + Base64.strict_encode64('test-user:test-secret')
}
)
end

it 'should encode long messages without newlines' do
stub_request(:any, 'https://api.mixpanel.com/track').to_return({:body => '{"status": 1, "error": null}'})
subject.send!(:event, {'data' => 'BASE64-ENCODED VERSION OF BIN. THIS METHOD COMPLIES WITH RFC 2045. LINE FEEDS ARE ADDED TO EVERY 60 ENCODED CHARACTORS. IN RUBY 1.8 WE NEED TO JUST CALL ENCODE64 AND REMOVE THE LINE FEEDS, IN RUBY 1.9 WE CALL STRIC_ENCODED64 METHOD INSTEAD'}.to_json)
Expand Down
Loading
Loading