From e70855aaa77fdc1ed39f78e95bb04776b13a9dd7 Mon Sep 17 00:00:00 2001 From: John La <10409759+lajohn4747@users.noreply.github.com> Date: Thu, 25 Jun 2026 22:12:33 +0000 Subject: [PATCH 01/28] Add service account support --- Readme.rdoc | 44 +++++++++++++++++++ lib/mixpanel-ruby.rb | 1 + lib/mixpanel-ruby/consumer.rb | 11 ++++- lib/mixpanel-ruby/events.rb | 18 +++++--- lib/mixpanel-ruby/flags/flags_provider.rb | 26 ++++++++--- .../flags/local_flags_provider.rb | 5 ++- .../flags/remote_flags_provider.rb | 5 ++- lib/mixpanel-ruby/tracker.rb | 10 ++--- spec/mixpanel-ruby/events_spec.rb | 25 +++++++++++ 9 files changed, 125 insertions(+), 20 deletions(-) diff --git a/Readme.rdoc b/Readme.rdoc index f2b39121..9ec46f74 100644 --- a/Readme.rdoc +++ b/Readme.rdoc @@ -29,6 +29,50 @@ 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' + ) + + tracker = Mixpanel::Tracker.new( + YOUR_MIXPANEL_TOKEN, + nil, + local_flags_config: { credentials: credentials }, + remote_flags_config: { credentials: credentials } + ) + + # Use feature flags + is_enabled = tracker.remote_flags.is_enabled?('my-flag', { 'distinct_id' => 'User1' }) + == Additional Information For more information please visit: diff --git a/lib/mixpanel-ruby.rb b/lib/mixpanel-ruby.rb index 79df69ca..4f27b514 100644 --- a/lib/mixpanel-ruby.rb +++ b/lib/mixpanel-ruby.rb @@ -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' diff --git a/lib/mixpanel-ruby/consumer.rb b/lib/mixpanel-ruby/consumer.rb index 3078f662..f76271af 100644 --- a/lib/mixpanel-ruby/consumer.rb +++ b/lib/mixpanel-ruby/consumer.rb @@ -85,10 +85,19 @@ 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 + + # Use service account credentials if provided, otherwise fall back to API key + if credentials + form_data.merge!("username" => credentials["username"]) + form_data.merge!("secret" => credentials["secret"]) + form_data.merge!("project_id" => credentials["project_id"]) + elsif api_key + form_data.merge!("api_key" => api_key) + end begin response_code, response_body = request(endpoint, form_data) diff --git a/lib/mixpanel-ruby/events.rb b/lib/mixpanel-ruby/events.rb index be653cda..6432e0d1 100644 --- a/lib/mixpanel-ruby/events.rb +++ b/lib/mixpanel-ruby/events.rb @@ -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, @@ -116,9 +116,15 @@ 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 + message['api_key'] = api_key_or_credentials + end + ret = true begin @sink.call(:import, message.to_json) diff --git a/lib/mixpanel-ruby/flags/flags_provider.rb b/lib/mixpanel-ruby/flags/flags_provider.rb index 116f0115..61ada12d 100644 --- a/lib/mixpanel-ruby/flags/flags_provider.rb +++ b/lib/mixpanel-ruby/flags/flags_provider.rb @@ -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' @@ -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 @@ -31,10 +32,18 @@ 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) - common_params = Utils.prepare_common_query_params( - @provider_config[:token], - Mixpanel::VERSION - ) + # Use project_id for service accounts, token otherwise + if @credentials + common_params = Utils.prepare_common_query_params( + @credentials.project_id, + Mixpanel::VERSION + ) + else + common_params = Utils.prepare_common_query_params( + @provider_config[:token], + Mixpanel::VERSION + ) + end params = common_params.merge(additional_params || {}) query_string = URI.encode_www_form(params) @@ -53,7 +62,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 or token for basic auth + if @credentials + request.basic_auth(@credentials.username, @credentials.secret) + else + request.basic_auth(@provider_config[:token], '') + end request['Content-Type'] = 'application/json' request['traceparent'] = Utils.generate_traceparent diff --git a/lib/mixpanel-ruby/flags/local_flags_provider.rb b/lib/mixpanel-ruby/flags/local_flags_provider.rb index 9bf1bb07..b2d7b889 100644 --- a/lib/mixpanel-ruby/flags/local_flags_provider.rb +++ b/lib/mixpanel-ruby/flags/local_flags_provider.rb @@ -15,7 +15,7 @@ class LocalFlagsProvider < FlagsProvider }.freeze # @param token [String] Mixpanel project token - # @param config [Hash] Local flags configuration + # @param config [Hash] Local flags configuration (may include :credentials) # @param tracker_callback [Proc] Callback to track events # @param error_handler [Mixpanel::ErrorHandler] Error handler def initialize(token, config, tracker_callback, error_handler) @@ -27,6 +27,9 @@ def initialize(token, config, tracker_callback, error_handler) request_timeout_in_seconds: @config[:request_timeout_in_seconds] } + # Pass credentials if provided in config + provider_config[:credentials] = @config[:credentials] if @config[:credentials] + super(provider_config, '/flags/definitions', tracker_callback, 'local', error_handler) @flag_definitions = {} diff --git a/lib/mixpanel-ruby/flags/remote_flags_provider.rb b/lib/mixpanel-ruby/flags/remote_flags_provider.rb index eaa64713..f2cf56ff 100644 --- a/lib/mixpanel-ruby/flags/remote_flags_provider.rb +++ b/lib/mixpanel-ruby/flags/remote_flags_provider.rb @@ -11,7 +11,7 @@ class RemoteFlagsProvider < FlagsProvider }.freeze # @param token [String] Mixpanel project token - # @param config [Hash] Remote flags configuration + # @param config [Hash] Remote flags configuration (may include :credentials) # @param tracker_callback [Proc] Callback to track events # @param error_handler [Mixpanel::ErrorHandler] Error handler def initialize(token, config, tracker_callback, error_handler) @@ -23,6 +23,9 @@ def initialize(token, config, tracker_callback, error_handler) request_timeout_in_seconds: merged_config[:request_timeout_in_seconds] } + # Pass credentials if provided in config + provider_config[:credentials] = merged_config[:credentials] if merged_config[:credentials] + super(provider_config, '/flags', tracker_callback, 'remote', error_handler) end diff --git a/lib/mixpanel-ruby/tracker.rb b/lib/mixpanel-ruby/tracker.rb index 667c672e..4ef7c2e1 100644 --- a/lib/mixpanel-ruby/tracker.rb +++ b/lib/mixpanel-ruby/tracker.rb @@ -122,19 +122,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 diff --git a/spec/mixpanel-ruby/events_spec.rb b/spec/mixpanel-ruby/events_spec.rb index 57f6adec..adbf2cb5 100644 --- a/spec/mixpanel-ruby/events_spec.rb +++ b/spec/mixpanel-ruby/events_spec.rb @@ -73,4 +73,29 @@ } } ]]) end + + it 'should send a well formed import/ message with service account credentials' do + credentials = Mixpanel::ServiceAccountCredentials.new('test-user', 'test-secret', 'test-project-123') + @events.import(credentials, 'TEST ID', 'Test Event', { + 'Circumstances' => 'During a test' + }) + + expect(@log.length).to eq(1) + expect(@log[0][0]).to eq(:import) + + message = @log[0][1] + expect(message['credentials']).to eq(credentials) + expect(message['api_key']).to be_nil + expect(message['data']).to eq({ + 'event' => 'Test Event', + 'properties' => { + 'Circumstances' => 'During a test', + 'distinct_id' => 'TEST ID', + 'mp_lib' => 'ruby', + '$lib_version' => Mixpanel::VERSION, + 'token' => 'TEST TOKEN', + 'time' => @time_now.to_i * 1000 + } + }) + end end From 6ac0e4c3e2cd9558124e9e9d310e7f0066c02374 Mon Sep 17 00:00:00 2001 From: John La <10409759+lajohn4747@users.noreply.github.com> Date: Thu, 25 Jun 2026 22:22:49 +0000 Subject: [PATCH 02/28] Fix comments --- lib/mixpanel-ruby/credentials.rb | 30 ++++++++++ lib/mixpanel-ruby/flags/flags_provider.rb | 19 ++---- spec/mixpanel-ruby/credentials_spec.rb | 72 +++++++++++++++++++++++ spec/mixpanel-ruby/events_spec.rb | 6 +- 4 files changed, 113 insertions(+), 14 deletions(-) create mode 100644 lib/mixpanel-ruby/credentials.rb create mode 100644 spec/mixpanel-ruby/credentials_spec.rb diff --git a/lib/mixpanel-ruby/credentials.rb b/lib/mixpanel-ruby/credentials.rb new file mode 100644 index 00000000..305ea1e9 --- /dev/null +++ b/lib/mixpanel-ruby/credentials.rb @@ -0,0 +1,30 @@ +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] Mixpanel project ID + 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? || project_id.empty? + + @username = username + @secret = secret + @project_id = project_id + end + + # JSON serialization support - called automatically by JSON.generate/to_json + def as_json(options = nil) + { + 'username' => @username, + 'secret' => @secret, + 'project_id' => @project_id + } + end + end +end diff --git a/lib/mixpanel-ruby/flags/flags_provider.rb b/lib/mixpanel-ruby/flags/flags_provider.rb index 61ada12d..2c37318c 100644 --- a/lib/mixpanel-ruby/flags/flags_provider.rb +++ b/lib/mixpanel-ruby/flags/flags_provider.rb @@ -32,18 +32,11 @@ 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) - # Use project_id for service accounts, token otherwise - if @credentials - common_params = Utils.prepare_common_query_params( - @credentials.project_id, - Mixpanel::VERSION - ) - else - common_params = Utils.prepare_common_query_params( - @provider_config[:token], - Mixpanel::VERSION - ) - end + # Always use token in query params + common_params = Utils.prepare_common_query_params( + @provider_config[:token], + Mixpanel::VERSION + ) params = common_params.merge(additional_params || {}) query_string = URI.encode_www_form(params) @@ -62,7 +55,7 @@ def call_flags_endpoint(additional_params = nil) request = Net::HTTP::Get.new(uri.request_uri) - # Use service account credentials or token for basic auth + # Use service account credentials for basic auth if provided, otherwise use token if @credentials request.basic_auth(@credentials.username, @credentials.secret) else diff --git a/spec/mixpanel-ruby/credentials_spec.rb b/spec/mixpanel-ruby/credentials_spec.rb new file mode 100644 index 00000000..abaa7722 --- /dev/null +++ b/spec/mixpanel-ruby/credentials_spec.rb @@ -0,0 +1,72 @@ +require 'mixpanel-ruby' + +describe Mixpanel::ServiceAccountCredentials do + describe '#initialize' do + it 'creates credentials with valid parameters' do + credentials = Mixpanel::ServiceAccountCredentials.new('user', 'secret', 'project123') + expect(credentials.username).to eq('user') + expect(credentials.secret).to eq('secret') + expect(credentials.project_id).to eq('project123') + end + + it 'raises ArgumentError when username is nil' do + expect { + Mixpanel::ServiceAccountCredentials.new(nil, 'secret', 'project123') + }.to raise_error(ArgumentError, 'username is required') + end + + it 'raises ArgumentError when username is empty' do + expect { + Mixpanel::ServiceAccountCredentials.new('', 'secret', 'project123') + }.to raise_error(ArgumentError, 'username is required') + end + + it 'raises ArgumentError when secret is nil' do + expect { + Mixpanel::ServiceAccountCredentials.new('user', nil, 'project123') + }.to raise_error(ArgumentError, 'secret is required') + end + + it 'raises ArgumentError when secret is empty' do + expect { + Mixpanel::ServiceAccountCredentials.new('user', '', 'project123') + }.to raise_error(ArgumentError, 'secret is required') + end + + it 'raises ArgumentError when project_id is nil' do + expect { + Mixpanel::ServiceAccountCredentials.new('user', 'secret', nil) + }.to raise_error(ArgumentError, 'project_id is required') + end + + it 'raises ArgumentError when project_id is empty' do + expect { + Mixpanel::ServiceAccountCredentials.new('user', 'secret', '') + }.to raise_error(ArgumentError, 'project_id is required') + end + end + + describe 'JSON serialization' do + it 'serializes to JSON correctly' do + credentials = Mixpanel::ServiceAccountCredentials.new('user', 'secret', 'project123') + json_str = credentials.to_json + parsed = JSON.parse(json_str) + expect(parsed).to eq({ + 'username' => 'user', + 'secret' => 'secret', + 'project_id' => 'project123' + }) + end + + it 'survives JSON round-trip' do + credentials = Mixpanel::ServiceAccountCredentials.new('user', 'secret', 'project123') + message = {'credentials' => credentials}.to_json + decoded = JSON.load(message) + expect(decoded['credentials']).to eq({ + 'username' => 'user', + 'secret' => 'secret', + 'project_id' => 'project123' + }) + end + end +end diff --git a/spec/mixpanel-ruby/events_spec.rb b/spec/mixpanel-ruby/events_spec.rb index adbf2cb5..38ed416d 100644 --- a/spec/mixpanel-ruby/events_spec.rb +++ b/spec/mixpanel-ruby/events_spec.rb @@ -84,7 +84,11 @@ expect(@log[0][0]).to eq(:import) message = @log[0][1] - expect(message['credentials']).to eq(credentials) + expect(message['credentials']).to eq({ + 'username' => 'test-user', + 'secret' => 'test-secret', + 'project_id' => 'test-project-123' + }) expect(message['api_key']).to be_nil expect(message['data']).to eq({ 'event' => 'Test Event', From 72693674f66422e1e292d0243082e7118bd1dd30 Mon Sep 17 00:00:00 2001 From: John La <10409759+lajohn4747@users.noreply.github.com> Date: Fri, 26 Jun 2026 17:36:53 +0000 Subject: [PATCH 03/28] Add explicit to_json method to ServiceAccountCredentials The as_json method alone is not sufficient for direct .to_json calls. Ruby's JSON library requires an explicit to_json method to properly serialize custom objects. --- lib/mixpanel-ruby/credentials.rb | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/mixpanel-ruby/credentials.rb b/lib/mixpanel-ruby/credentials.rb index 305ea1e9..023f87a2 100644 --- a/lib/mixpanel-ruby/credentials.rb +++ b/lib/mixpanel-ruby/credentials.rb @@ -26,5 +26,10 @@ def as_json(options = nil) 'project_id' => @project_id } end + + # Explicit to_json method for direct .to_json calls + def to_json(*args) + as_json.to_json(*args) + end end end From f3dc6da04925bbcf971240bec21eb6b1d567a486 Mon Sep 17 00:00:00 2001 From: John La <10409759+lajohn4747@users.noreply.github.com> Date: Fri, 26 Jun 2026 17:46:14 +0000 Subject: [PATCH 04/28] Simplify credentials API for feature flags Allow passing credentials directly to Tracker.new instead of requiring them to be nested in the config hash. This provides a cleaner API: Before: tracker = Tracker.new(token, nil, local_flags_config: { credentials: creds }, remote_flags_config: { credentials: creds }) After: tracker = Tracker.new(token, nil, credentials: creds, local_flags_config: {}, remote_flags_config: {}) Credentials in config still take precedence if both are provided, allowing different credentials per provider if needed. --- Readme.rdoc | 10 +++++++++ lib/mixpanel-ruby/tracker.rb | 19 ++++++++++++++--- spec/mixpanel-ruby/tracker_spec.rb | 33 ++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/Readme.rdoc b/Readme.rdoc index 9ec46f74..ef07522f 100644 --- a/Readme.rdoc +++ b/Readme.rdoc @@ -63,6 +63,16 @@ API keys for import operations and feature flags. '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: {} + ) + + # Or pass credentials in the config if you need different credentials per provider tracker = Mixpanel::Tracker.new( YOUR_MIXPANEL_TOKEN, nil, diff --git a/lib/mixpanel-ruby/tracker.rb b/lib/mixpanel-ruby/tracker.rb index 4ef7c2e1..f650ecf6 100644 --- a/lib/mixpanel-ruby/tracker.rb +++ b/lib/mixpanel-ruby/tracker.rb @@ -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) @@ -70,9 +75,13 @@ def initialize(token, error_handler=nil, local_flags_config: nil, remote_flags_c # Initialize local flags if config provided if local_flags_config + # Inject credentials into config if provided + config = local_flags_config.dup + config[:credentials] ||= credentials if credentials + @local_flags = Flags::LocalFlagsProvider.new( token, - local_flags_config, + config, method(:track), # Pass bound method as callback error_handler || ErrorHandler.new ) @@ -80,9 +89,13 @@ def initialize(token, error_handler=nil, local_flags_config: nil, remote_flags_c # Initialize remote flags if config provided if remote_flags_config + # Inject credentials into config if provided + config = remote_flags_config.dup + config[:credentials] ||= credentials if credentials + @remote_flags = Flags::RemoteFlagsProvider.new( token, - remote_flags_config, + config, method(:track), # Pass bound method as callback error_handler || ErrorHandler.new ) diff --git a/spec/mixpanel-ruby/tracker_spec.rb b/spec/mixpanel-ruby/tracker_spec.rb index 1fe54eb3..f2e9054f 100644 --- a/spec/mixpanel-ruby/tracker_spec.rb +++ b/spec/mixpanel-ruby/tracker_spec.rb @@ -131,4 +131,37 @@ expect(expect).to eq(found) end end + + describe 'service account credentials' do + it 'should pass credentials to flags providers when passed directly' do + credentials = Mixpanel::ServiceAccountCredentials.new('user', 'secret', 'project123') + + tracker = Mixpanel::Tracker.new( + 'TEST TOKEN', + nil, + credentials: credentials, + local_flags_config: {}, + remote_flags_config: {} + ) + + # Verify credentials were passed to providers by checking internal state + expect(tracker.local_flags.instance_variable_get(:@config)[:credentials]).to eq(credentials) + expect(tracker.remote_flags.instance_variable_get(:@config)[:credentials]).to eq(credentials) + end + + it 'should prefer credentials in config over direct credentials parameter' do + direct_credentials = Mixpanel::ServiceAccountCredentials.new('user1', 'secret1', 'project1') + config_credentials = Mixpanel::ServiceAccountCredentials.new('user2', 'secret2', 'project2') + + tracker = Mixpanel::Tracker.new( + 'TEST TOKEN', + nil, + credentials: direct_credentials, + local_flags_config: { credentials: config_credentials } + ) + + # Config credentials should take precedence + expect(tracker.local_flags.instance_variable_get(:@config)[:credentials]).to eq(config_credentials) + end + end end From f7f42692125e7e849dc9ed2051df8e9018ac5ce0 Mon Sep 17 00:00:00 2001 From: John La <10409759+lajohn4747@users.noreply.github.com> Date: Fri, 26 Jun 2026 19:47:24 +0000 Subject: [PATCH 05/28] Remove credentials from config hash, pass as separate parameter Credentials don't need to be part of the config hash - they're a fundamental authentication parameter. This simplifies the API: - Credentials are passed directly as a parameter to the flags providers - No more confusing config[:credentials] nesting - Config hash is only for provider-specific settings (api_host, timeouts, etc.) - Cleaner separation of concerns This removes 30+ lines of unnecessary complexity. --- Readme.rdoc | 8 -------- .../flags/local_flags_provider.rb | 11 +++++------ .../flags/remote_flags_provider.rb | 11 +++++------ lib/mixpanel-ruby/tracker.rb | 14 ++++---------- spec/mixpanel-ruby/tracker_spec.rb | 19 ++----------------- 5 files changed, 16 insertions(+), 47 deletions(-) diff --git a/Readme.rdoc b/Readme.rdoc index ef07522f..d4705779 100644 --- a/Readme.rdoc +++ b/Readme.rdoc @@ -72,14 +72,6 @@ API keys for import operations and feature flags. remote_flags_config: {} ) - # Or pass credentials in the config if you need different credentials per provider - tracker = Mixpanel::Tracker.new( - YOUR_MIXPANEL_TOKEN, - nil, - local_flags_config: { credentials: credentials }, - remote_flags_config: { credentials: credentials } - ) - # Use feature flags is_enabled = tracker.remote_flags.is_enabled?('my-flag', { 'distinct_id' => 'User1' }) diff --git a/lib/mixpanel-ruby/flags/local_flags_provider.rb b/lib/mixpanel-ruby/flags/local_flags_provider.rb index b2d7b889..f10ee4de 100644 --- a/lib/mixpanel-ruby/flags/local_flags_provider.rb +++ b/lib/mixpanel-ruby/flags/local_flags_provider.rb @@ -15,21 +15,20 @@ class LocalFlagsProvider < FlagsProvider }.freeze # @param token [String] Mixpanel project token - # @param config [Hash] Local flags configuration (may include :credentials) + # @param config [Hash] Local 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) @config = DEFAULT_CONFIG.merge(config || {}) 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 } - # Pass credentials if provided in config - provider_config[:credentials] = @config[:credentials] if @config[:credentials] - super(provider_config, '/flags/definitions', tracker_callback, 'local', error_handler) @flag_definitions = {} diff --git a/lib/mixpanel-ruby/flags/remote_flags_provider.rb b/lib/mixpanel-ruby/flags/remote_flags_provider.rb index f2cf56ff..7ced48f5 100644 --- a/lib/mixpanel-ruby/flags/remote_flags_provider.rb +++ b/lib/mixpanel-ruby/flags/remote_flags_provider.rb @@ -11,21 +11,20 @@ class RemoteFlagsProvider < FlagsProvider }.freeze # @param token [String] Mixpanel project token - # @param config [Hash] Remote flags configuration (may include :credentials) + # @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 } - # Pass credentials if provided in config - provider_config[:credentials] = merged_config[:credentials] if merged_config[:credentials] - super(provider_config, '/flags', tracker_callback, 'remote', error_handler) end diff --git a/lib/mixpanel-ruby/tracker.rb b/lib/mixpanel-ruby/tracker.rb index f650ecf6..4d01b7da 100644 --- a/lib/mixpanel-ruby/tracker.rb +++ b/lib/mixpanel-ruby/tracker.rb @@ -75,13 +75,10 @@ def initialize(token, error_handler=nil, credentials: nil, local_flags_config: n # Initialize local flags if config provided if local_flags_config - # Inject credentials into config if provided - config = local_flags_config.dup - config[:credentials] ||= credentials if credentials - @local_flags = Flags::LocalFlagsProvider.new( token, - config, + local_flags_config, + credentials, method(:track), # Pass bound method as callback error_handler || ErrorHandler.new ) @@ -89,13 +86,10 @@ def initialize(token, error_handler=nil, credentials: nil, local_flags_config: n # Initialize remote flags if config provided if remote_flags_config - # Inject credentials into config if provided - config = remote_flags_config.dup - config[:credentials] ||= credentials if credentials - @remote_flags = Flags::RemoteFlagsProvider.new( token, - config, + remote_flags_config, + credentials, method(:track), # Pass bound method as callback error_handler || ErrorHandler.new ) diff --git a/spec/mixpanel-ruby/tracker_spec.rb b/spec/mixpanel-ruby/tracker_spec.rb index f2e9054f..1aa3a8a6 100644 --- a/spec/mixpanel-ruby/tracker_spec.rb +++ b/spec/mixpanel-ruby/tracker_spec.rb @@ -145,23 +145,8 @@ ) # Verify credentials were passed to providers by checking internal state - expect(tracker.local_flags.instance_variable_get(:@config)[:credentials]).to eq(credentials) - expect(tracker.remote_flags.instance_variable_get(:@config)[:credentials]).to eq(credentials) - end - - it 'should prefer credentials in config over direct credentials parameter' do - direct_credentials = Mixpanel::ServiceAccountCredentials.new('user1', 'secret1', 'project1') - config_credentials = Mixpanel::ServiceAccountCredentials.new('user2', 'secret2', 'project2') - - tracker = Mixpanel::Tracker.new( - 'TEST TOKEN', - nil, - credentials: direct_credentials, - local_flags_config: { credentials: config_credentials } - ) - - # Config credentials should take precedence - expect(tracker.local_flags.instance_variable_get(:@config)[:credentials]).to eq(config_credentials) + expect(tracker.local_flags.instance_variable_get(:@credentials)).to eq(credentials) + expect(tracker.remote_flags.instance_variable_get(:@credentials)).to eq(credentials) end end end From 03a009607ab3af9737687f3e58f6dcb1e97e9e3b Mon Sep 17 00:00:00 2001 From: John La <10409759+lajohn4747@users.noreply.github.com> Date: Fri, 26 Jun 2026 21:26:35 +0000 Subject: [PATCH 06/28] Fix flag provider tests for new credentials parameter Update test setup to pass nil for credentials parameter in the new 5-argument signature. --- spec/mixpanel-ruby/flags/local_flags_spec.rb | 1 + spec/mixpanel-ruby/flags/remote_flags_spec.rb | 1 + 2 files changed, 2 insertions(+) diff --git a/spec/mixpanel-ruby/flags/local_flags_spec.rb b/spec/mixpanel-ruby/flags/local_flags_spec.rb index 69695215..a1870610 100644 --- a/spec/mixpanel-ruby/flags/local_flags_spec.rb +++ b/spec/mixpanel-ruby/flags/local_flags_spec.rb @@ -15,6 +15,7 @@ Mixpanel::Flags::LocalFlagsProvider.new( test_token, config, + nil, # credentials mock_tracker, mock_error_handler ) diff --git a/spec/mixpanel-ruby/flags/remote_flags_spec.rb b/spec/mixpanel-ruby/flags/remote_flags_spec.rb index 8e7308b7..ad490350 100644 --- a/spec/mixpanel-ruby/flags/remote_flags_spec.rb +++ b/spec/mixpanel-ruby/flags/remote_flags_spec.rb @@ -15,6 +15,7 @@ Mixpanel::Flags::RemoteFlagsProvider.new( test_token, config, + nil, # credentials mock_tracker, mock_error_handler ) From 2d12649e37546ff20cbe23e1c10f9af99aae99b3 Mon Sep 17 00:00:00 2001 From: John La <10409759+lajohn4747@users.noreply.github.com> Date: Fri, 26 Jun 2026 21:52:58 +0000 Subject: [PATCH 07/28] Fix remaining provider instantiation in polling test --- spec/mixpanel-ruby/flags/local_flags_spec.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/spec/mixpanel-ruby/flags/local_flags_spec.rb b/spec/mixpanel-ruby/flags/local_flags_spec.rb index a1870610..3e5ed085 100644 --- a/spec/mixpanel-ruby/flags/local_flags_spec.rb +++ b/spec/mixpanel-ruby/flags/local_flags_spec.rb @@ -741,6 +741,7 @@ def user_context_with_properties(properties) polling_provider = Mixpanel::Flags::LocalFlagsProvider.new( test_token, { enable_polling: true, polling_interval_in_seconds: 0.1 }, + nil, # credentials mock_tracker, mock_error_handler ) From b520a0bdde3f9e73c6b76abfa37e8d95f2e1c24a Mon Sep 17 00:00:00 2001 From: John La <10409759+lajohn4747@users.noreply.github.com> Date: Sun, 28 Jun 2026 13:59:40 +0000 Subject: [PATCH 08/28] Add tests for service account credentials authentication in flag providers --- spec/mixpanel-ruby/flags/local_flags_spec.rb | 31 +++++++++++++++++ spec/mixpanel-ruby/flags/remote_flags_spec.rb | 34 +++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/spec/mixpanel-ruby/flags/local_flags_spec.rb b/spec/mixpanel-ruby/flags/local_flags_spec.rb index 3e5ed085..cfb97fbd 100644 --- a/spec/mixpanel-ruby/flags/local_flags_spec.rb +++ b/spec/mixpanel-ruby/flags/local_flags_spec.rb @@ -758,4 +758,35 @@ def user_context_with_properties(properties) end end end + + describe 'service account credentials' do + it 'uses service account credentials for authentication' do + credentials = Mixpanel::ServiceAccountCredentials.new('test-user', 'test-secret', 'test-project') + flag = create_test_flag + + stub_request(:get, endpoint_url_regex) + .with( + basic_auth: ['test-user', 'test-secret'] + ) + .to_return( + status: 200, + body: { code: 200, flags: [flag] }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + credentials_provider = Mixpanel::Flags::LocalFlagsProvider.new( + test_token, + config, + credentials, + mock_tracker, + mock_error_handler + ) + + credentials_provider.start_polling_for_definitions! + result = credentials_provider.get_variant_value('test_flag', 'fallback', test_context, report_exposure: false) + + expect(result).not_to eq('fallback') + credentials_provider.stop_polling_for_definitions! + end + end end diff --git a/spec/mixpanel-ruby/flags/remote_flags_spec.rb b/spec/mixpanel-ruby/flags/remote_flags_spec.rb index ad490350..f7f959c0 100644 --- a/spec/mixpanel-ruby/flags/remote_flags_spec.rb +++ b/spec/mixpanel-ruby/flags/remote_flags_spec.rb @@ -439,4 +439,38 @@ def stub_flags_request_error(error) provider.send(:track_exposure_event, 'test_flag', variant, test_context) end end + + describe 'service account credentials' do + it 'uses service account credentials for authentication' do + credentials = Mixpanel::ServiceAccountCredentials.new('test-user', 'test-secret', 'test-project') + + response = create_success_response({ + 'test_flag' => { + 'variant_key' => 'treatment', + 'variant_value' => 'treatment' + } + }) + + stub_request(:get, endpoint_url_regex) + .with( + basic_auth: ['test-user', 'test-secret'] + ) + .to_return( + status: 200, + body: response.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + credentials_provider = Mixpanel::Flags::RemoteFlagsProvider.new( + test_token, + config, + credentials, + mock_tracker, + mock_error_handler + ) + + result = credentials_provider.get_variant_value('test_flag', 'fallback', test_context, report_exposure: false) + expect(result).to eq('treatment') + end + end end From dca931f74a20d0fd100e6ae9bfd0025b61964eb6 Mon Sep 17 00:00:00 2001 From: John La <10409759+lajohn4747@users.noreply.github.com> Date: Mon, 29 Jun 2026 05:25:18 +0000 Subject: [PATCH 09/28] Add test for service account credentials in Consumer import endpoint --- spec/mixpanel-ruby/consumer_spec.rb | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/spec/mixpanel-ruby/consumer_spec.rb b/spec/mixpanel-ruby/consumer_spec.rb index 941f2561..3fd29db9 100644 --- a/spec/mixpanel-ruby/consumer_spec.rb +++ b/spec/mixpanel-ruby/consumer_spec.rb @@ -36,6 +36,24 @@ 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').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) + expect(WebMock).to have_requested(:post, 'https://api.mixpanel.com/import'). + with(:body => { + 'data' => 'IlRFU1QgRVZFTlQgTUVTU0FHRSI=', + 'username' => 'test-user', + 'secret' => 'test-secret', + 'project_id' => 'test-project-123', + 'verbose' => '1' + }) + 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) From 880a2817b64669671a440529007e57378bfb0783 Mon Sep 17 00:00:00 2001 From: John La <10409759+lajohn4747@users.noreply.github.com> Date: Tue, 30 Jun 2026 21:54:12 +0000 Subject: [PATCH 10/28] Fix passing credentials to go import --- lib/mixpanel-ruby/consumer.rb | 25 +++++++++++++++-------- lib/mixpanel-ruby/events.rb | 1 + lib/mixpanel-ruby/flags/flags_provider.rb | 6 ++++++ spec/mixpanel-ruby/consumer_spec.rb | 22 ++++++++++++-------- spec/mixpanel-ruby/events_spec.rb | 9 +++++--- 5 files changed, 44 insertions(+), 19 deletions(-) diff --git a/lib/mixpanel-ruby/consumer.rb b/lib/mixpanel-ruby/consumer.rb index f76271af..abcfd8b0 100644 --- a/lib/mixpanel-ruby/consumer.rb +++ b/lib/mixpanel-ruby/consumer.rb @@ -90,17 +90,13 @@ def send!(type, message) form_data = {"data" => data, "verbose" => 1} - # Use service account credentials if provided, otherwise fall back to API key - if credentials - form_data.merge!("username" => credentials["username"]) - form_data.merge!("secret" => credentials["secret"]) - form_data.merge!("project_id" => credentials["project_id"]) - elsif 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 @@ -132,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) 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 diff --git a/lib/mixpanel-ruby/events.rb b/lib/mixpanel-ruby/events.rb index 6432e0d1..8c0107ce 100644 --- a/lib/mixpanel-ruby/events.rb +++ b/lib/mixpanel-ruby/events.rb @@ -122,6 +122,7 @@ def import(api_key_or_credentials, distinct_id, event, properties={}, ip=nil) 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.' message['api_key'] = api_key_or_credentials end diff --git a/lib/mixpanel-ruby/flags/flags_provider.rb b/lib/mixpanel-ruby/flags/flags_provider.rb index 2c37318c..eb8a7159 100644 --- a/lib/mixpanel-ruby/flags/flags_provider.rb +++ b/lib/mixpanel-ruby/flags/flags_provider.rb @@ -39,6 +39,12 @@ def call_flags_endpoint(additional_params = nil) ) 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( diff --git a/spec/mixpanel-ruby/consumer_spec.rb b/spec/mixpanel-ruby/consumer_spec.rb index 3fd29db9..64c5b9fe 100644 --- a/spec/mixpanel-ruby/consumer_spec.rb +++ b/spec/mixpanel-ruby/consumer_spec.rb @@ -44,14 +44,20 @@ 'project_id' => 'test-project-123' } subject.send!(:import, {'data' => 'TEST EVENT MESSAGE', 'credentials' => credentials}.to_json) - expect(WebMock).to have_requested(:post, 'https://api.mixpanel.com/import'). - with(:body => { - 'data' => 'IlRFU1QgRVZFTlQgTUVTU0FHRSI=', - 'username' => 'test-user', - 'secret' => 'test-secret', - 'project_id' => 'test-project-123', - 'verbose' => '1' - }) + + # 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 diff --git a/spec/mixpanel-ruby/events_spec.rb b/spec/mixpanel-ruby/events_spec.rb index 38ed416d..29e4a995 100644 --- a/spec/mixpanel-ruby/events_spec.rb +++ b/spec/mixpanel-ruby/events_spec.rb @@ -3,6 +3,7 @@ require 'mixpanel-ruby/events.rb' require 'mixpanel-ruby/version.rb' +require 'mixpanel-ruby/credentials.rb' describe Mixpanel::Events do before(:each) do @@ -33,9 +34,11 @@ end it 'should send a well formed import/ message' do - @events.import('API_KEY', 'TEST ID', 'Test Event', { - 'Circumstances' => 'During a test' - }) + expect { + @events.import('API_KEY', 'TEST ID', 'Test Event', { + 'Circumstances' => 'During a test' + }) + }.to output(/DEPRECATION.*API key for import is deprecated/).to_stderr expect(@log).to eq([[:import, { 'api_key' => 'API_KEY', 'data' => { From d7a06bf6d9c520cc354971dc6bde11b698062207 Mon Sep 17 00:00:00 2001 From: John La <10409759+lajohn4747@users.noreply.github.com> Date: Tue, 30 Jun 2026 22:19:41 +0000 Subject: [PATCH 11/28] Fix WebMock stub for service account import test The stub needs to include the project_id query parameter to match the actual request being made. --- spec/mixpanel-ruby/consumer_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/mixpanel-ruby/consumer_spec.rb b/spec/mixpanel-ruby/consumer_spec.rb index 64c5b9fe..0b6f4b7e 100644 --- a/spec/mixpanel-ruby/consumer_spec.rb +++ b/spec/mixpanel-ruby/consumer_spec.rb @@ -37,7 +37,7 @@ end it 'should send a request to api.mixpanel.com/import with service account credentials' do - stub_request(:any, 'https://api.mixpanel.com/import').to_return({:body => '{"status": 1, "error": null}'}) + 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', From 8183119933e9d104cdbdce29ab08a751d23eb3a5 Mon Sep 17 00:00:00 2001 From: John La <10409759+lajohn4747@users.noreply.github.com> Date: Tue, 30 Jun 2026 22:29:16 +0000 Subject: [PATCH 12/28] security: exclude secret from ServiceAccountCredentials JSON serialization The secret should not be included in the serialized message JSON to prevent exposure in logs, queues, or custom consumer implementations. The secret is only needed at the HTTP layer for Basic Auth, not in the message payload. --- lib/mixpanel-ruby/credentials.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/mixpanel-ruby/credentials.rb b/lib/mixpanel-ruby/credentials.rb index 023f87a2..5b8f427d 100644 --- a/lib/mixpanel-ruby/credentials.rb +++ b/lib/mixpanel-ruby/credentials.rb @@ -19,10 +19,11 @@ def initialize(username, secret, project_id) end # JSON serialization support - called automatically by JSON.generate/to_json + # Note: secret is intentionally excluded from serialization to prevent + # exposure in logs, queues, or custom consumer implementations def as_json(options = nil) { 'username' => @username, - 'secret' => @secret, 'project_id' => @project_id } end From 9c5be70ab28ead021963a350c940fe6dc8a42a81 Mon Sep 17 00:00:00 2001 From: John La <10409759+lajohn4747@users.noreply.github.com> Date: Tue, 30 Jun 2026 22:34:29 +0000 Subject: [PATCH 13/28] test: update tests to expect secret not in JSON serialization Tests now verify that the secret is excluded from JSON serialization for security, while still being accessible via the object's accessor. --- spec/mixpanel-ruby/credentials_spec.rb | 6 ++++-- spec/mixpanel-ruby/events_spec.rb | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/spec/mixpanel-ruby/credentials_spec.rb b/spec/mixpanel-ruby/credentials_spec.rb index abaa7722..24ee415a 100644 --- a/spec/mixpanel-ruby/credentials_spec.rb +++ b/spec/mixpanel-ruby/credentials_spec.rb @@ -51,20 +51,22 @@ credentials = Mixpanel::ServiceAccountCredentials.new('user', 'secret', 'project123') json_str = credentials.to_json parsed = JSON.parse(json_str) + # Secret should NOT be serialized for security expect(parsed).to eq({ 'username' => 'user', - 'secret' => 'secret', 'project_id' => 'project123' }) + # But secret is still accessible on the object + expect(credentials.secret).to eq('secret') end it 'survives JSON round-trip' do credentials = Mixpanel::ServiceAccountCredentials.new('user', 'secret', 'project123') message = {'credentials' => credentials}.to_json decoded = JSON.load(message) + # Secret should NOT be in serialized JSON for security expect(decoded['credentials']).to eq({ 'username' => 'user', - 'secret' => 'secret', 'project_id' => 'project123' }) end diff --git a/spec/mixpanel-ruby/events_spec.rb b/spec/mixpanel-ruby/events_spec.rb index 29e4a995..e014e562 100644 --- a/spec/mixpanel-ruby/events_spec.rb +++ b/spec/mixpanel-ruby/events_spec.rb @@ -87,9 +87,9 @@ expect(@log[0][0]).to eq(:import) message = @log[0][1] + # Secret should NOT be in serialized JSON for security expect(message['credentials']).to eq({ 'username' => 'test-user', - 'secret' => 'test-secret', 'project_id' => 'test-project-123' }) expect(message['api_key']).to be_nil From 3336b0e94fba6ea2d6dc516e4099d75111e8c4c5 Mon Sep 17 00:00:00 2001 From: John La <10409759+lajohn4747@users.noreply.github.com> Date: Wed, 1 Jul 2026 00:21:25 +0000 Subject: [PATCH 14/28] fix: accept integer project_id and convert to string Mixpanel project IDs are displayed as integers in the dashboard, so users naturally pass them as integers. This fix converts integer project_ids to strings and avoids NoMethodError on Integer#empty?. --- lib/mixpanel-ruby/credentials.rb | 12 ++++++++---- spec/mixpanel-ruby/credentials_spec.rb | 13 ++++++++++--- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/lib/mixpanel-ruby/credentials.rb b/lib/mixpanel-ruby/credentials.rb index 5b8f427d..8c076508 100644 --- a/lib/mixpanel-ruby/credentials.rb +++ b/lib/mixpanel-ruby/credentials.rb @@ -7,11 +7,15 @@ class ServiceAccountCredentials # Create service account credentials # @param username [String] Service account username # @param secret [String] Service account secret - # @param project_id [String] Mixpanel project ID + # @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? || project_id.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 @@ -19,11 +23,11 @@ def initialize(username, secret, project_id) end # JSON serialization support - called automatically by JSON.generate/to_json - # Note: secret is intentionally excluded from serialization to prevent - # exposure in logs, queues, or custom consumer implementations + # 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 } end diff --git a/spec/mixpanel-ruby/credentials_spec.rb b/spec/mixpanel-ruby/credentials_spec.rb index 24ee415a..38b427ae 100644 --- a/spec/mixpanel-ruby/credentials_spec.rb +++ b/spec/mixpanel-ruby/credentials_spec.rb @@ -44,6 +44,11 @@ Mixpanel::ServiceAccountCredentials.new('user', 'secret', '') }.to raise_error(ArgumentError, 'project_id is required') end + + it 'accepts integer project_id and converts to string' do + credentials = Mixpanel::ServiceAccountCredentials.new('user', 'secret', 12345) + expect(credentials.project_id).to eq('12345') + end end describe 'JSON serialization' do @@ -51,12 +56,13 @@ credentials = Mixpanel::ServiceAccountCredentials.new('user', 'secret', 'project123') json_str = credentials.to_json parsed = JSON.parse(json_str) - # Secret should NOT be serialized for security + # Secret IS included so Consumer can use it for HTTP Basic Auth expect(parsed).to eq({ 'username' => 'user', + 'secret' => 'secret', 'project_id' => 'project123' }) - # But secret is still accessible on the object + # Secret is also accessible on the object expect(credentials.secret).to eq('secret') end @@ -64,9 +70,10 @@ credentials = Mixpanel::ServiceAccountCredentials.new('user', 'secret', 'project123') message = {'credentials' => credentials}.to_json decoded = JSON.load(message) - # Secret should NOT be in serialized JSON for security + # Secret IS included so Consumer can use it for HTTP Basic Auth expect(decoded['credentials']).to eq({ 'username' => 'user', + 'secret' => 'secret', 'project_id' => 'project123' }) end From 0e1377f91ae3572a0b647303f7b15a530099b521 Mon Sep 17 00:00:00 2001 From: John La <10409759+lajohn4747@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:18:31 +0000 Subject: [PATCH 15/28] fix test --- spec/mixpanel-ruby/events_spec.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/spec/mixpanel-ruby/events_spec.rb b/spec/mixpanel-ruby/events_spec.rb index e014e562..72d2cdc7 100644 --- a/spec/mixpanel-ruby/events_spec.rb +++ b/spec/mixpanel-ruby/events_spec.rb @@ -87,9 +87,10 @@ expect(@log[0][0]).to eq(:import) message = @log[0][1] - # Secret should NOT be in serialized JSON for security + # Secret IS included in serialization so Consumer can use it for HTTP Basic Auth expect(message['credentials']).to eq({ 'username' => 'test-user', + 'secret' => 'test-secret', 'project_id' => 'test-project-123' }) expect(message['api_key']).to be_nil From 9f9ef929fa575c0377f7cee46079775eeca46e59 Mon Sep 17 00:00:00 2001 From: John La <10409759+lajohn4747@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:15:33 +0000 Subject: [PATCH 16/28] Fix tests after rebase --- lib/mixpanel-ruby/flags/local_flags_provider.rb | 4 ++-- lib/mixpanel-ruby/tracker.rb | 4 ++-- spec/mixpanel-ruby/flags/local_flags_spec.rb | 13 +++++++------ 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/lib/mixpanel-ruby/flags/local_flags_provider.rb b/lib/mixpanel-ruby/flags/local_flags_provider.rb index cc14074a..5ea98faa 100644 --- a/lib/mixpanel-ruby/flags/local_flags_provider.rb +++ b/lib/mixpanel-ruby/flags/local_flags_provider.rb @@ -16,10 +16,10 @@ class LocalFlagsProvider < FlagsProvider # @param token [String] Mixpanel project token # @param config [Hash] Local 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) + # @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 diff --git a/lib/mixpanel-ruby/tracker.rb b/lib/mixpanel-ruby/tracker.rb index 4d01b7da..84e80e08 100644 --- a/lib/mixpanel-ruby/tracker.rb +++ b/lib/mixpanel-ruby/tracker.rb @@ -78,9 +78,9 @@ def initialize(token, error_handler=nil, credentials: nil, local_flags_config: n @local_flags = Flags::LocalFlagsProvider.new( token, local_flags_config, - credentials, method(:track), # Pass bound method as callback - error_handler || ErrorHandler.new + error_handler || ErrorHandler.new, + credentials ) end diff --git a/spec/mixpanel-ruby/flags/local_flags_spec.rb b/spec/mixpanel-ruby/flags/local_flags_spec.rb index 0182c746..fad90d02 100644 --- a/spec/mixpanel-ruby/flags/local_flags_spec.rb +++ b/spec/mixpanel-ruby/flags/local_flags_spec.rb @@ -2,6 +2,7 @@ require 'timeout' require 'mixpanel-ruby/flags/local_flags_provider' require 'mixpanel-ruby/flags/types' +require 'mixpanel-ruby/credentials' require 'webmock/rspec' describe Mixpanel::Flags::LocalFlagsProvider do @@ -16,9 +17,9 @@ Mixpanel::Flags::LocalFlagsProvider.new( test_token, config, - nil, # credentials mock_tracker, - mock_error_handler + mock_error_handler, + nil # credentials ) end @@ -770,9 +771,9 @@ def user_context_with_properties(properties) polling_provider = Mixpanel::Flags::LocalFlagsProvider.new( test_token, { enable_polling: true, polling_interval_in_seconds: 0.1 }, - nil, # credentials mock_tracker, - mock_error_handler + mock_error_handler, + nil # credentials ) begin @@ -950,9 +951,9 @@ def user_context_with_properties(properties) credentials_provider = Mixpanel::Flags::LocalFlagsProvider.new( test_token, config, - credentials, mock_tracker, - mock_error_handler + mock_error_handler, + credentials ) credentials_provider.start_polling_for_definitions! From e263b02458e76b5e730974dd8dec22f949d9d3c2 Mon Sep 17 00:00:00 2001 From: John La <10409759+lajohn4747@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:23:22 +0000 Subject: [PATCH 17/28] Address comments --- lib/mixpanel-ruby/consumer.rb | 20 ++++++++++++++++++- lib/mixpanel-ruby/credentials.rb | 6 +++++- .../flags/remote_flags_provider.rb | 4 ++-- lib/mixpanel-ruby/tracker.rb | 4 ++-- spec/mixpanel-ruby/flags/remote_flags_spec.rb | 7 ++++--- 5 files changed, 32 insertions(+), 9 deletions(-) diff --git a/lib/mixpanel-ruby/consumer.rb b/lib/mixpanel-ruby/consumer.rb index abcfd8b0..b1371d81 100644 --- a/lib/mixpanel-ruby/consumer.rb +++ b/lib/mixpanel-ruby/consumer.rb @@ -50,6 +50,13 @@ def self.config_http(&block) # mixpanel.send!(*JSON.load(message_json)) # end # + # IMPORTANT SECURITY NOTE: When using service account credentials, + # the message payload contains the service account secret in plaintext. + # Do not log or persist raw message JSON in production environments. + # The secret is stripped from the HTTP request body but remains in the + # serialized message that custom sinks, BufferedConsumer, or async + # executors receive. + # # Mixpanel::Consumer is the default consumer. It sends each message, # as the message is recieved, directly to Mixpanel. class Consumer @@ -96,7 +103,15 @@ def send!(type, message) end begin - response_code, response_body = request(endpoint, form_data, credentials, type) + # Only widen the request() call for service-account import path to preserve + # backward-compatibility with 2-arg custom Consumer#request overrides. + # Credentials are only used for imports, so only pass them for that type. + response_code, response_body = + if credentials && type == :import + request(endpoint, form_data, credentials, type) + else + request(endpoint, form_data) + end rescue => e raise ConnectionError.new("Could not connect to Mixpanel, with error \"#{e.message}\".") end @@ -128,6 +143,9 @@ 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. + # + # For service account authentication, pass credentials (hash with + # 'username', 'secret', 'project_id') and type (:import). def request(endpoint, form_data, credentials = nil, type = nil) uri = URI(endpoint) diff --git a/lib/mixpanel-ruby/credentials.rb b/lib/mixpanel-ruby/credentials.rb index 8c076508..836dee0c 100644 --- a/lib/mixpanel-ruby/credentials.rb +++ b/lib/mixpanel-ruby/credentials.rb @@ -23,7 +23,11 @@ def initialize(username, secret, 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 + # + # SECURITY NOTE: The secret IS included in the serialized output because it's + # needed by the Consumer for HTTP Basic Auth. This means the secret will be + # present in message payloads passed to custom sinks, BufferedConsumer, or + # async executors. Do not log or persist these messages in plaintext. def as_json(options = nil) { 'username' => @username, diff --git a/lib/mixpanel-ruby/flags/remote_flags_provider.rb b/lib/mixpanel-ruby/flags/remote_flags_provider.rb index 7ced48f5..661208e3 100644 --- a/lib/mixpanel-ruby/flags/remote_flags_provider.rb +++ b/lib/mixpanel-ruby/flags/remote_flags_provider.rb @@ -12,10 +12,10 @@ 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, credentials, tracker_callback, error_handler) + # @param credentials [ServiceAccountCredentials, nil] Optional service account credentials + def initialize(token, config, tracker_callback, error_handler, credentials = nil) merged_config = DEFAULT_CONFIG.merge(config || {}) provider_config = { diff --git a/lib/mixpanel-ruby/tracker.rb b/lib/mixpanel-ruby/tracker.rb index 84e80e08..27f118ce 100644 --- a/lib/mixpanel-ruby/tracker.rb +++ b/lib/mixpanel-ruby/tracker.rb @@ -89,9 +89,9 @@ def initialize(token, error_handler=nil, credentials: nil, local_flags_config: n @remote_flags = Flags::RemoteFlagsProvider.new( token, remote_flags_config, - credentials, method(:track), # Pass bound method as callback - error_handler || ErrorHandler.new + error_handler || ErrorHandler.new, + credentials ) end end diff --git a/spec/mixpanel-ruby/flags/remote_flags_spec.rb b/spec/mixpanel-ruby/flags/remote_flags_spec.rb index f7f959c0..6fcee63a 100644 --- a/spec/mixpanel-ruby/flags/remote_flags_spec.rb +++ b/spec/mixpanel-ruby/flags/remote_flags_spec.rb @@ -1,6 +1,7 @@ require 'json' require 'mixpanel-ruby/flags/remote_flags_provider' require 'mixpanel-ruby/flags/types' +require 'mixpanel-ruby/credentials' require 'webmock/rspec' describe Mixpanel::Flags::RemoteFlagsProvider do @@ -15,9 +16,9 @@ Mixpanel::Flags::RemoteFlagsProvider.new( test_token, config, - nil, # credentials mock_tracker, mock_error_handler + # credentials defaults to nil ) end @@ -464,9 +465,9 @@ def stub_flags_request_error(error) credentials_provider = Mixpanel::Flags::RemoteFlagsProvider.new( test_token, config, - credentials, mock_tracker, - mock_error_handler + mock_error_handler, + credentials ) result = credentials_provider.get_variant_value('test_flag', 'fallback', test_context, report_exposure: false) From 5e8836b6dc9888889cd516a6200d36594477c383 Mon Sep 17 00:00:00 2001 From: John La <10409759+lajohn4747@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:28:59 +0000 Subject: [PATCH 18/28] move notice earlier --- lib/mixpanel-ruby/consumer.rb | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/lib/mixpanel-ruby/consumer.rb b/lib/mixpanel-ruby/consumer.rb index b1371d81..83089a7d 100644 --- a/lib/mixpanel-ruby/consumer.rb +++ b/lib/mixpanel-ruby/consumer.rb @@ -40,6 +40,12 @@ def self.config_http(&block) # @kestrel.set(ANALYTICS_QUEUE, [type, message].to_json) # end # + # IMPORTANT SECURITY NOTE: When using service account credentials, + # the message payload contains the service account secret in plaintext. + # Do not log or persist raw message JSON in production environments + # (e.g., logger.info(message), queue storage, etc.). The secret is + # needed by Consumer for HTTP Basic Auth but should never be logged. + # # You can also instantiate the library consumers yourself, and use # them wherever you would like. For example, the working that # consumes the above queue might work like this: @@ -50,13 +56,6 @@ def self.config_http(&block) # mixpanel.send!(*JSON.load(message_json)) # end # - # IMPORTANT SECURITY NOTE: When using service account credentials, - # the message payload contains the service account secret in plaintext. - # Do not log or persist raw message JSON in production environments. - # The secret is stripped from the HTTP request body but remains in the - # serialized message that custom sinks, BufferedConsumer, or async - # executors receive. - # # Mixpanel::Consumer is the default consumer. It sends each message, # as the message is recieved, directly to Mixpanel. class Consumer From 9a3c29b2de98155ded0e6ae019bc2f8325580e79 Mon Sep 17 00:00:00 2001 From: John La <10409759+lajohn4747@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:33:49 +0000 Subject: [PATCH 19/28] Mirror python implementation --- CHANGELOG.md | 19 +++++++++ Readme.rdoc | 20 +++++++-- lib/mixpanel-ruby/consumer.rb | 68 ++++++++++++++++++++++++------- lib/mixpanel-ruby/events.rb | 32 +++++++-------- lib/mixpanel-ruby/tracker.rb | 24 +++++++++-- spec/mixpanel-ruby/events_spec.rb | 27 ++++++------ 6 files changed, 139 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8727123a..4baf701b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## [Unreleased] + +### Changed + +- `Tracker.new()` now accepts `credentials:` parameter for service account authentication +- `Consumer.new()` and `BufferedConsumer.new()` now accept `credentials:` parameter +- `Tracker.new()` now accepts `consumer:` parameter to provide a custom consumer instance +- When using service account credentials, pass `nil` as the first parameter to `import()`: `tracker.import(nil, distinct_id, event, ...)` instead of passing credentials + +### Added + +- New secure API pattern: credentials passed to constructor are stored as instance variables and used only for HTTP Basic Auth headers, never serialized to JSON +- Warning when both `consumer` and `credentials` parameters are provided to `Tracker.new()` (credentials are ignored in this case - pass them to the consumer instead) +- Comprehensive test coverage for secure credential handling in `spec/mixpanel-ruby/credentials_security_spec.rb` + +### Fixed + +- `BufferedConsumer` now properly preserves `api_key` when flushing batched messages (previously it was dropped during flush) + ## [v3.1.0](https://github.com/mixpanel/mixpanel-ruby/tree/v3.1.0) (2026-05-13) Initial entry for the standardized release process. See `Readme.rdoc` for prior version history. diff --git a/Readme.rdoc b/Readme.rdoc index d4705779..dcd08333 100644 --- a/Readme.rdoc +++ b/Readme.rdoc @@ -34,7 +34,7 @@ right away. Service accounts provide secure server-to-server authentication and are recommended over API keys for import operations and feature flags. -=== Import with Service Account +=== Import with Service Account (Recommended) require 'mixpanel-ruby' @@ -45,10 +45,22 @@ API keys for import operations and feature flags. 'your-project-id' ) - tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN) + # Pass credentials to tracker constructor + # Credentials are stored securely and never serialized to JSON + tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN, credentials: credentials) - # Import historical events using service account - tracker.import(credentials, 'User1', 'Past Event', { + # Import historical events - pass nil for api_key (first parameter) + # Consumer uses credentials from constructor for authentication + tracker.import(nil, 'User1', 'Past Event', { + 'time' => 1369353600, + 'Source' => 'Import' + }) + +=== Import with API Key (Legacy) + + # Legacy approach with API key string + tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN) + tracker.import("API_KEY", 'User1', 'Past Event', { 'time' => 1369353600, 'Source' => 'Import' }) diff --git a/lib/mixpanel-ruby/consumer.rb b/lib/mixpanel-ruby/consumer.rb index 83089a7d..814a927a 100644 --- a/lib/mixpanel-ruby/consumer.rb +++ b/lib/mixpanel-ruby/consumer.rb @@ -40,11 +40,15 @@ def self.config_http(&block) # @kestrel.set(ANALYTICS_QUEUE, [type, message].to_json) # end # - # IMPORTANT SECURITY NOTE: When using service account credentials, - # the message payload contains the service account secret in plaintext. - # Do not log or persist raw message JSON in production environments - # (e.g., logger.info(message), queue storage, etc.). The secret is - # needed by Consumer for HTTP Basic Auth but should never be logged. + # IMPORTANT SECURITY NOTE: For maximum security, pass credentials to the + # Consumer or Tracker constructor (credentials: ...) instead of including them + # in import() calls. When credentials are passed to the constructor, they are + # stored as instance variables and used only for HTTP Basic Auth headers - they + # never appear in message JSON, preventing accidental credential leakage in logs. + # + # For backward compatibility, credentials can still be passed to import() calls, + # but this is deprecated and will serialize credentials into message JSON, which + # could leak secrets in logs or queue storage. # # You can also instantiate the library consumers yourself, and use # them wherever you would like. For example, the working that @@ -64,14 +68,19 @@ class Consumer # they will be used instead of the default Mixpanel endpoints. # This can be useful for proxying, debugging, or if you prefer # not to use SSL for your events. + # + # @param credentials [ServiceAccountCredentials, nil] Service account credentials for authentication. + # Credentials are only used for the /import endpoint and feature flags. def initialize(events_endpoint=nil, update_endpoint=nil, groups_endpoint=nil, - import_endpoint=nil) + import_endpoint=nil, + credentials: nil) @events_endpoint = events_endpoint || 'https://api.mixpanel.com/track' @update_endpoint = update_endpoint || 'https://api.mixpanel.com/engage' @groups_endpoint = groups_endpoint || 'https://api.mixpanel.com/groups' @import_endpoint = import_endpoint || 'https://api.mixpanel.com/import' + @credentials = credentials end # Send the given string message to Mixpanel. Type should be @@ -91,12 +100,20 @@ def send!(type, message) decoded_message = JSON.load(message) api_key = decoded_message["api_key"] - credentials = decoded_message["credentials"] + + # For backward compatibility: extract credentials from message if present + # (deprecated - credentials should be passed to constructor instead) + message_credentials = decoded_message["credentials"] + + # Use instance credentials if available, otherwise fall back to message credentials (deprecated) + credentials = @credentials || message_credentials + data = Base64.encode64(decoded_message["data"].to_json).gsub("\n", '') form_data = {"data" => data, "verbose" => 1} # Only add api_key to form data if using legacy API key (not service account credentials) + # Service account credentials use HTTP Basic Auth instead if api_key && !credentials form_data.merge!("api_key" => api_key) end @@ -143,15 +160,17 @@ 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. # - # For service account authentication, pass credentials (hash with - # 'username', 'secret', 'project_id') and type (:import). + # For service account authentication, pass credentials (ServiceAccountCredentials object + # or hash with 'username', 'secret', 'project_id') and type (:import). def request(endpoint, form_data, credentials = nil, type = nil) 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'] + # Support both ServiceAccountCredentials object and hash (for backward compatibility) + project_id = credentials.is_a?(ServiceAccountCredentials) ? credentials.project_id : credentials['project_id'] + query_params['project_id'] = project_id uri.query = URI.encode_www_form(query_params) end @@ -160,7 +179,12 @@ def request(endpoint, form_data, credentials = nil, type = nil) # Use Basic Auth with service account credentials for import endpoint if credentials && type == :import - request.basic_auth(credentials['username'], credentials['secret']) + # Support both ServiceAccountCredentials object and hash (for backward compatibility) + if credentials.is_a?(ServiceAccountCredentials) + request.basic_auth(credentials.username, credentials.secret) + else + request.basic_auth(credentials['username'], credentials['secret']) + end end client = Net::HTTP.new(uri.host, uri.port) @@ -217,17 +241,21 @@ class BufferedConsumer # to the Mixpanel::Tracker constructor. If a block is passed to # the constructor, the *_endpoint constructor arguments are # ignored. - def initialize(events_endpoint=nil, update_endpoint=nil, import_endpoint=nil, max_buffer_length=MAX_LENGTH, &block) + # + # @param credentials [ServiceAccountCredentials, nil] Service account credentials for authentication. + # Credentials are only used for the /import endpoint and feature flags. + def initialize(events_endpoint=nil, update_endpoint=nil, import_endpoint=nil, max_buffer_length=MAX_LENGTH, credentials: nil, &block) @max_length = [max_buffer_length, MAX_LENGTH].min @buffers = { :event => [], :profile_update => [], } + @credentials = credentials if block @sink = block else - consumer = Consumer.new(events_endpoint, update_endpoint, import_endpoint) + consumer = Consumer.new(events_endpoint, update_endpoint, nil, import_endpoint, credentials: credentials) @sink = consumer.method(:send!) end end @@ -268,8 +296,18 @@ def flush_type(type) sent_messages = 0 begin @buffers[type].each_slice(@max_length) do |chunk| - data = chunk.map {|message| JSON.load(message)['data'] } - @sink.call(type, {'data' => data}.to_json) + # Extract data from all messages in chunk + parsed_messages = chunk.map {|message| JSON.load(message) } + data = parsed_messages.map {|msg| msg['data'] } + + # Preserve api_key and credentials from first message (if present) + # Note: credentials from message are deprecated - they should be passed to constructor + first_message = parsed_messages.first + batch_message = {'data' => data} + batch_message['api_key'] = first_message['api_key'] if first_message['api_key'] + batch_message['credentials'] = first_message['credentials'] if first_message['credentials'] + + @sink.call(type, batch_message.to_json) sent_messages += chunk.length end rescue diff --git a/lib/mixpanel-ruby/events.rb b/lib/mixpanel-ruby/events.rb index 8c0107ce..1cd04a98 100644 --- a/lib/mixpanel-ruby/events.rb +++ b/lib/mixpanel-ruby/events.rb @@ -22,14 +22,15 @@ class Events # # tracker has all of the methods of Mixpanel::Events # tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN) # - def initialize(token, error_handler=nil, &block) + def initialize(token, error_handler=nil, credentials: nil, &block) @token = token @error_handler = error_handler || ErrorHandler.new + @credentials = credentials if block @sink = block else - consumer = Consumer.new + consumer = Consumer.new(credentials: credentials) @sink = consumer.method(:send!) end end @@ -87,19 +88,19 @@ def track(distinct_id, event, properties={}, ip=nil) # we pass the time of the method call as the time the event occured, if you # wish to override this pass a timestamp in the properties hash. # - # tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN) - # - # # Using deprecated API key (still supported) - # tracker.import("API_KEY", "12345", "Credit Card Declined") - # - # # Using service account credentials (recommended) + # # Recommended: Pass credentials to constructor, pass nil for api_key # credentials = Mixpanel::ServiceAccountCredentials.new(username, secret, project_id) - # tracker.import(credentials, "12345", "Welcome Email Sent", { + # tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN, credentials: credentials) + # tracker.import(nil, "12345", "Welcome Email Sent", { # 'Email Template' => 'Pretty Pink Welcome', # 'User Sign-up Cohort' => 'July 2013', # 'time' => 1369353600, # }) - def import(api_key_or_credentials, distinct_id, event, properties={}, ip=nil) + # + # # Legacy: Pass API key as first parameter + # tracker.import("API_KEY", "12345", "Credit Card Declined") + # + def import(api_key, distinct_id, event, properties={}, ip=nil) properties = { 'distinct_id' => distinct_id, 'token' => @token, @@ -118,12 +119,11 @@ def import(api_key_or_credentials, distinct_id, event, properties={}, ip=nil) 'data' => data, } - # 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.' - message['api_key'] = api_key_or_credentials + # Only include api_key in message if provided (legacy auth) + # When using service account credentials (recommended), pass nil for api_key + # and the Consumer will use credentials from its instance variable + if api_key + message['api_key'] = api_key end ret = true diff --git a/lib/mixpanel-ruby/tracker.rb b/lib/mixpanel-ruby/tracker.rb index 27f118ce..ca314b31 100644 --- a/lib/mixpanel-ruby/tracker.rb +++ b/lib/mixpanel-ruby/tracker.rb @@ -64,11 +64,29 @@ class Tracker < Events # and Mixpanel::BufferedConsumer#send! # # Optional parameters: - # - credentials: ServiceAccountCredentials for feature flags authentication + # - credentials: ServiceAccountCredentials for authentication (used for import and feature flags) + # - consumer: A custom consumer instance (Mixpanel::Consumer or Mixpanel::BufferedConsumer). + # If provided, credentials passed here are ignored - pass them to the consumer constructor instead. # - 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) + def initialize(token, error_handler=nil, credentials: nil, consumer: nil, local_flags_config: nil, remote_flags_config: nil, &block) + # Warn if both consumer and credentials provided + if consumer && credentials + warn "[Mixpanel] Credentials passed to Tracker are ignored when a custom consumer is provided. " \ + "Pass credentials to your consumer's constructor instead: " \ + "Consumer.new(credentials: ...)" + end + + # Use provided consumer or fall back to block/default behavior from parent + if consumer + # Override parent's default behavior - use the provided consumer + @sink = consumer.method(:send!) + else + # Pass credentials to parent (Events) if no consumer provided + # Parent will create default Consumer with credentials + super(token, error_handler, credentials: credentials, &block) + end + @token = token @people = People.new(token, error_handler, &block) @groups = Groups.new(token, error_handler, &block) diff --git a/spec/mixpanel-ruby/events_spec.rb b/spec/mixpanel-ruby/events_spec.rb index 72d2cdc7..a0ee1c80 100644 --- a/spec/mixpanel-ruby/events_spec.rb +++ b/spec/mixpanel-ruby/events_spec.rb @@ -34,11 +34,9 @@ end it 'should send a well formed import/ message' do - expect { - @events.import('API_KEY', 'TEST ID', 'Test Event', { - 'Circumstances' => 'During a test' - }) - }.to output(/DEPRECATION.*API key for import is deprecated/).to_stderr + @events.import('API_KEY', 'TEST ID', 'Test Event', { + 'Circumstances' => 'During a test' + }) expect(@log).to eq([[:import, { 'api_key' => 'API_KEY', 'data' => { @@ -79,7 +77,14 @@ it 'should send a well formed import/ message with service account credentials' do credentials = Mixpanel::ServiceAccountCredentials.new('test-user', 'test-secret', 'test-project-123') - @events.import(credentials, 'TEST ID', 'Test Event', { + + # Create new Events instance with credentials in constructor + events_with_creds = Mixpanel::Events.new('TEST TOKEN', nil, credentials: credentials) do |type, message| + @log << [type, JSON.load(message)] + end + + # New API: pass nil for api_key, credentials come from instance + events_with_creds.import(nil, 'TEST ID', 'Test Event', { 'Circumstances' => 'During a test' }) @@ -87,13 +92,9 @@ expect(@log[0][0]).to eq(:import) message = @log[0][1] - # Secret IS included in serialization so Consumer can use it for HTTP Basic Auth - expect(message['credentials']).to eq({ - 'username' => 'test-user', - 'secret' => 'test-secret', - 'project_id' => 'test-project-123' - }) - expect(message['api_key']).to be_nil + # Credentials should NOT be in message (secure API) + expect(message).not_to have_key('credentials') + expect(message).not_to have_key('api_key') expect(message['data']).to eq({ 'event' => 'Test Event', 'properties' => { From 41c21cb4f944789c4f10bb3a9017a6b2964901c5 Mon Sep 17 00:00:00 2001 From: John La <10409759+lajohn4747@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:02:02 +0000 Subject: [PATCH 20/28] Fix greptile issues --- lib/mixpanel-ruby/consumer.rb | 30 +++++----------- lib/mixpanel-ruby/credentials.rb | 8 ++--- lib/mixpanel-ruby/tracker.rb | 5 ++- spec/mixpanel-ruby/consumer_spec.rb | 48 +++++++++++++------------- spec/mixpanel-ruby/credentials_spec.rb | 11 +++--- 5 files changed, 46 insertions(+), 56 deletions(-) diff --git a/lib/mixpanel-ruby/consumer.rb b/lib/mixpanel-ruby/consumer.rb index 814a927a..af2bfc85 100644 --- a/lib/mixpanel-ruby/consumer.rb +++ b/lib/mixpanel-ruby/consumer.rb @@ -40,15 +40,10 @@ def self.config_http(&block) # @kestrel.set(ANALYTICS_QUEUE, [type, message].to_json) # end # - # IMPORTANT SECURITY NOTE: For maximum security, pass credentials to the - # Consumer or Tracker constructor (credentials: ...) instead of including them - # in import() calls. When credentials are passed to the constructor, they are - # stored as instance variables and used only for HTTP Basic Auth headers - they - # never appear in message JSON, preventing accidental credential leakage in logs. - # - # For backward compatibility, credentials can still be passed to import() calls, - # but this is deprecated and will serialize credentials into message JSON, which - # could leak secrets in logs or queue storage. + # IMPORTANT SECURITY NOTE: Always pass credentials to the Consumer or Tracker + # constructor (credentials: ...). Credentials are stored as instance variables + # and used only for HTTP Basic Auth headers - they never appear in message JSON, + # preventing accidental credential leakage in logs or queue storage. # # You can also instantiate the library consumers yourself, and use # them wherever you would like. For example, the working that @@ -101,20 +96,13 @@ def send!(type, message) decoded_message = JSON.load(message) api_key = decoded_message["api_key"] - # For backward compatibility: extract credentials from message if present - # (deprecated - credentials should be passed to constructor instead) - message_credentials = decoded_message["credentials"] - - # Use instance credentials if available, otherwise fall back to message credentials (deprecated) - credentials = @credentials || message_credentials - data = Base64.encode64(decoded_message["data"].to_json).gsub("\n", '') form_data = {"data" => data, "verbose" => 1} # Only add api_key to form data if using legacy API key (not service account credentials) # Service account credentials use HTTP Basic Auth instead - if api_key && !credentials + if api_key && !@credentials form_data.merge!("api_key" => api_key) end @@ -123,8 +111,8 @@ def send!(type, message) # backward-compatibility with 2-arg custom Consumer#request overrides. # Credentials are only used for imports, so only pass them for that type. response_code, response_body = - if credentials && type == :import - request(endpoint, form_data, credentials, type) + if @credentials && type == :import + request(endpoint, form_data, @credentials, type) else request(endpoint, form_data) end @@ -300,12 +288,10 @@ def flush_type(type) parsed_messages = chunk.map {|message| JSON.load(message) } data = parsed_messages.map {|msg| msg['data'] } - # Preserve api_key and credentials from first message (if present) - # Note: credentials from message are deprecated - they should be passed to constructor + # Preserve api_key from first message (if present) first_message = parsed_messages.first batch_message = {'data' => data} batch_message['api_key'] = first_message['api_key'] if first_message['api_key'] - batch_message['credentials'] = first_message['credentials'] if first_message['credentials'] @sink.call(type, batch_message.to_json) sent_messages += chunk.length diff --git a/lib/mixpanel-ruby/credentials.rb b/lib/mixpanel-ruby/credentials.rb index 836dee0c..25c74424 100644 --- a/lib/mixpanel-ruby/credentials.rb +++ b/lib/mixpanel-ruby/credentials.rb @@ -24,10 +24,10 @@ def initialize(username, secret, project_id) # JSON serialization support - called automatically by JSON.generate/to_json # - # SECURITY NOTE: The secret IS included in the serialized output because it's - # needed by the Consumer for HTTP Basic Auth. This means the secret will be - # present in message payloads passed to custom sinks, BufferedConsumer, or - # async executors. Do not log or persist these messages in plaintext. + # SECURITY NOTE: The secret IS included in the serialized output for internal + # use (e.g., debugging, testing). However, credentials should NEVER be included + # in message payloads sent to Consumer - they are stored as instance variables + # in Consumer/Tracker and used only for HTTP Basic Auth headers. def as_json(options = nil) { 'username' => @username, diff --git a/lib/mixpanel-ruby/tracker.rb b/lib/mixpanel-ruby/tracker.rb index ca314b31..056e3f0d 100644 --- a/lib/mixpanel-ruby/tracker.rb +++ b/lib/mixpanel-ruby/tracker.rb @@ -80,6 +80,10 @@ def initialize(token, error_handler=nil, credentials: nil, consumer: nil, local_ # Use provided consumer or fall back to block/default behavior from parent if consumer # Override parent's default behavior - use the provided consumer + # Initialize instance variables that super would have set + @token = token + @error_handler = error_handler || ErrorHandler.new + @credentials = credentials @sink = consumer.method(:send!) else # Pass credentials to parent (Events) if no consumer provided @@ -87,7 +91,6 @@ def initialize(token, error_handler=nil, credentials: nil, consumer: nil, local_ super(token, error_handler, credentials: credentials, &block) end - @token = token @people = People.new(token, error_handler, &block) @groups = Groups.new(token, error_handler, &block) diff --git a/spec/mixpanel-ruby/consumer_spec.rb b/spec/mixpanel-ruby/consumer_spec.rb index 0b6f4b7e..39c081d5 100644 --- a/spec/mixpanel-ruby/consumer_spec.rb +++ b/spec/mixpanel-ruby/consumer_spec.rb @@ -36,30 +36,6 @@ 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) @@ -119,6 +95,30 @@ def request(*args) it_behaves_like 'consumer' end + context 'service account credentials' do + 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 = Mixpanel::ServiceAccountCredentials.new('test-user', 'test-secret', 'test-project-123') + consumer = Mixpanel::Consumer.new(nil, nil, nil, nil, credentials: credentials) + + consumer.send!(:import, {'data' => 'TEST EVENT MESSAGE'}.to_json) + + # Should use Basic Auth header with username:secret + # Should add project_id as query parameter + # Should NOT include credentials in POST body or message + 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 + end + end describe Mixpanel::BufferedConsumer do diff --git a/spec/mixpanel-ruby/credentials_spec.rb b/spec/mixpanel-ruby/credentials_spec.rb index 38b427ae..0b7dd92b 100644 --- a/spec/mixpanel-ruby/credentials_spec.rb +++ b/spec/mixpanel-ruby/credentials_spec.rb @@ -56,7 +56,8 @@ credentials = Mixpanel::ServiceAccountCredentials.new('user', 'secret', 'project123') json_str = credentials.to_json parsed = JSON.parse(json_str) - # Secret IS included so Consumer can use it for HTTP Basic Auth + # All fields are serialized for internal use (e.g., debugging) + # Note: credentials should NEVER be included in messages sent to Consumer expect(parsed).to eq({ 'username' => 'user', 'secret' => 'secret', @@ -68,10 +69,10 @@ it 'survives JSON round-trip' do credentials = Mixpanel::ServiceAccountCredentials.new('user', 'secret', 'project123') - message = {'credentials' => credentials}.to_json - decoded = JSON.load(message) - # Secret IS included so Consumer can use it for HTTP Basic Auth - expect(decoded['credentials']).to eq({ + serialized = credentials.to_json + decoded = JSON.parse(serialized) + # All fields survive serialization + expect(decoded).to eq({ 'username' => 'user', 'secret' => 'secret', 'project_id' => 'project123' From 09f5dace2767f6be7629a6a2dd93e77636cac266 Mon Sep 17 00:00:00 2001 From: John La <10409759+lajohn4747@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:12:26 +0000 Subject: [PATCH 21/28] Fix docstring --- lib/mixpanel-ruby/tracker.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/mixpanel-ruby/tracker.rb b/lib/mixpanel-ruby/tracker.rb index 056e3f0d..0a1bf65f 100644 --- a/lib/mixpanel-ruby/tracker.rb +++ b/lib/mixpanel-ruby/tracker.rb @@ -157,7 +157,8 @@ def track(distinct_id, event, properties={}, ip=nil) # # # Using service account credentials (recommended) # credentials = Mixpanel::ServiceAccountCredentials.new(username, secret, project_id) - # tracker.import(credentials, "12345", "Welcome Email Sent", { + # tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN, credentials: credentials) + # tracker.import(nil, "12345", "Welcome Email Sent", { # 'Email Template' => 'Pretty Pink Welcome', # 'User Sign-up Cohort' => 'July 2013', # 'time' => 1310111365 From 440cc42ea84ec404ae4918064a3fb96662f2f1f5 Mon Sep 17 00:00:00 2001 From: John La <10409759+lajohn4747@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:40:38 +0000 Subject: [PATCH 22/28] Claude review --- lib/mixpanel-ruby/consumer.rb | 40 +++++++++++++++-------- lib/mixpanel-ruby/credentials.rb | 18 ---------- lib/mixpanel-ruby/flags/flags_provider.rb | 1 + lib/mixpanel-ruby/tracker.rb | 2 +- spec/mixpanel-ruby/credentials_spec.rb | 28 ---------------- 5 files changed, 28 insertions(+), 61 deletions(-) diff --git a/lib/mixpanel-ruby/consumer.rb b/lib/mixpanel-ruby/consumer.rb index af2bfc85..8a7a62cd 100644 --- a/lib/mixpanel-ruby/consumer.rb +++ b/lib/mixpanel-ruby/consumer.rb @@ -107,12 +107,11 @@ def send!(type, message) end begin - # Only widen the request() call for service-account import path to preserve - # backward-compatibility with 2-arg custom Consumer#request overrides. - # Credentials are only used for imports, so only pass them for that type. + # Use keyword arguments for credentials to maintain backward compatibility + # with custom Consumer subclasses that override request(endpoint, form_data) response_code, response_body = if @credentials && type == :import - request(endpoint, form_data, @credentials, type) + request(endpoint, form_data, credentials: @credentials, type: type) else request(endpoint, form_data) end @@ -149,15 +148,32 @@ def send(type, message) # the request never receives a response for some reason. # # For service account authentication, pass credentials (ServiceAccountCredentials object - # or hash with 'username', 'secret', 'project_id') and type (:import). - def request(endpoint, form_data, credentials = nil, type = nil) + # or hash with 'username', 'secret', 'project_id') and type (:import) as keyword arguments. + # The positional parameters are preserved for backward compatibility with custom Consumer subclasses. + def request(endpoint, form_data, credentials: nil, type: nil) 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 - # Support both ServiceAccountCredentials object and hash (for backward compatibility) - project_id = credentials.is_a?(ServiceAccountCredentials) ? credentials.project_id : credentials['project_id'] + + # Extract and validate credentials + if credentials.is_a?(ServiceAccountCredentials) + username = credentials.username + secret = credentials.secret + project_id = credentials.project_id + elsif credentials.is_a?(Hash) + username = credentials['username'] || credentials[:username] + secret = credentials['secret'] || credentials[:secret] + project_id = credentials['project_id'] || credentials[:project_id] + + raise ArgumentError, "credentials hash missing 'username'" unless username + raise ArgumentError, "credentials hash missing 'secret'" unless secret + raise ArgumentError, "credentials hash missing 'project_id'" unless project_id + else + raise ArgumentError, "credentials must be ServiceAccountCredentials or Hash, got #{credentials.class}" + end + query_params['project_id'] = project_id uri.query = URI.encode_www_form(query_params) end @@ -167,12 +183,8 @@ def request(endpoint, form_data, credentials = nil, type = nil) # Use Basic Auth with service account credentials for import endpoint if credentials && type == :import - # Support both ServiceAccountCredentials object and hash (for backward compatibility) - if credentials.is_a?(ServiceAccountCredentials) - request.basic_auth(credentials.username, credentials.secret) - else - request.basic_auth(credentials['username'], credentials['secret']) - end + # username and secret were already extracted and validated above + request.basic_auth(username, secret) end client = Net::HTTP.new(uri.host, uri.port) diff --git a/lib/mixpanel-ruby/credentials.rb b/lib/mixpanel-ruby/credentials.rb index 25c74424..217a2cd7 100644 --- a/lib/mixpanel-ruby/credentials.rb +++ b/lib/mixpanel-ruby/credentials.rb @@ -22,23 +22,5 @@ def initialize(username, secret, project_id) @project_id = project_id end - # JSON serialization support - called automatically by JSON.generate/to_json - # - # SECURITY NOTE: The secret IS included in the serialized output for internal - # use (e.g., debugging, testing). However, credentials should NEVER be included - # in message payloads sent to Consumer - they are stored as instance variables - # in Consumer/Tracker and used only for HTTP Basic Auth headers. - def as_json(options = nil) - { - 'username' => @username, - 'secret' => @secret, - 'project_id' => @project_id - } - end - - # Explicit to_json method for direct .to_json calls - def to_json(*args) - as_json.to_json(*args) - end end end diff --git a/lib/mixpanel-ruby/flags/flags_provider.rb b/lib/mixpanel-ruby/flags/flags_provider.rb index eb8a7159..a0da1a8a 100644 --- a/lib/mixpanel-ruby/flags/flags_provider.rb +++ b/lib/mixpanel-ruby/flags/flags_provider.rb @@ -41,6 +41,7 @@ def call_flags_endpoint(additional_params = nil) params = common_params.merge(additional_params || {}) # Add project_id as query parameter when using service account credentials + # Note: project_id is required for service account auth but not used for token auth if @credentials params['project_id'] = @credentials.project_id end diff --git a/lib/mixpanel-ruby/tracker.rb b/lib/mixpanel-ruby/tracker.rb index 0a1bf65f..6c7252ca 100644 --- a/lib/mixpanel-ruby/tracker.rb +++ b/lib/mixpanel-ruby/tracker.rb @@ -163,7 +163,7 @@ def track(distinct_id, event, properties={}, ip=nil) # 'User Sign-up Cohort' => 'July 2013', # 'time' => 1310111365 # }) - def import(api_key_or_credentials, distinct_id, event, properties={}, ip=nil) + def import(api_key, distinct_id, event, properties={}, ip=nil) # This is here strictly to allow rdoc to include the relevant # documentation super diff --git a/spec/mixpanel-ruby/credentials_spec.rb b/spec/mixpanel-ruby/credentials_spec.rb index 0b7dd92b..61cdbe84 100644 --- a/spec/mixpanel-ruby/credentials_spec.rb +++ b/spec/mixpanel-ruby/credentials_spec.rb @@ -51,32 +51,4 @@ end end - describe 'JSON serialization' do - it 'serializes to JSON correctly' do - credentials = Mixpanel::ServiceAccountCredentials.new('user', 'secret', 'project123') - json_str = credentials.to_json - parsed = JSON.parse(json_str) - # All fields are serialized for internal use (e.g., debugging) - # Note: credentials should NEVER be included in messages sent to Consumer - expect(parsed).to eq({ - 'username' => 'user', - 'secret' => 'secret', - 'project_id' => 'project123' - }) - # Secret is also accessible on the object - expect(credentials.secret).to eq('secret') - end - - it 'survives JSON round-trip' do - credentials = Mixpanel::ServiceAccountCredentials.new('user', 'secret', 'project123') - serialized = credentials.to_json - decoded = JSON.parse(serialized) - # All fields survive serialization - expect(decoded).to eq({ - 'username' => 'user', - 'secret' => 'secret', - 'project_id' => 'project123' - }) - end - end end From 1434e4e7b73fbfe0be8b96a097ccd5bec67c5132 Mon Sep 17 00:00:00 2001 From: John La <10409759+lajohn4747@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:19:40 +0000 Subject: [PATCH 23/28] Add comprehensive test coverage for service account credentials - Add tests for hash-based credentials (string and symbol keys) - Add tests for credential validation error paths - Add test for BufferedConsumer with credentials - Add test for connection error handling - Add test verifying api_key is not sent when credentials are present - Coverage now at 96.85% (was 95.43%) --- spec/mixpanel-ruby/consumer_spec.rb | 115 ++++++++++++++++++ .../credentials_security_spec.rb | 108 ++++++++++++++++ spec/mixpanel-ruby/tracker_consumer_spec.rb | 34 ++++++ 3 files changed, 257 insertions(+) create mode 100644 spec/mixpanel-ruby/credentials_security_spec.rb create mode 100644 spec/mixpanel-ruby/tracker_consumer_spec.rb diff --git a/spec/mixpanel-ruby/consumer_spec.rb b/spec/mixpanel-ruby/consumer_spec.rb index 39c081d5..14c7e6cd 100644 --- a/spec/mixpanel-ruby/consumer_spec.rb +++ b/spec/mixpanel-ruby/consumer_spec.rb @@ -3,6 +3,7 @@ require 'webmock' require 'mixpanel-ruby/consumer' +require 'mixpanel-ruby/credentials' describe Mixpanel::Consumer do before { WebMock.reset! } @@ -117,6 +118,90 @@ def request(*args) } ) end + + it 'should accept credentials as a hash with string keys' do + stub_request(:any, 'https://api.mixpanel.com/import?project_id=proj-456').to_return({:body => '{"status": 1, "error": null}'}) + consumer = Mixpanel::Consumer.new + + credentials_hash = {'username' => 'hash-user', 'secret' => 'hash-secret', 'project_id' => 'proj-456'} + # Directly call request with credentials hash to test hash handling + consumer.request('https://api.mixpanel.com/import', {'data' => 'test', 'verbose' => '1'}, credentials: credentials_hash, type: :import) + + expect(WebMock).to have_requested(:post, 'https://api.mixpanel.com/import?project_id=proj-456'). + with( + :headers => { + 'Authorization' => 'Basic ' + Base64.strict_encode64('hash-user:hash-secret') + } + ) + end + + it 'should accept credentials as a hash with symbol keys' do + stub_request(:any, 'https://api.mixpanel.com/import?project_id=proj-789').to_return({:body => '{"status": 1, "error": null}'}) + consumer = Mixpanel::Consumer.new + + credentials_hash = {username: 'sym-user', secret: 'sym-secret', project_id: 'proj-789'} + consumer.request('https://api.mixpanel.com/import', {'data' => 'test', 'verbose' => '1'}, credentials: credentials_hash, type: :import) + + expect(WebMock).to have_requested(:post, 'https://api.mixpanel.com/import?project_id=proj-789'). + with( + :headers => { + 'Authorization' => 'Basic ' + Base64.strict_encode64('sym-user:sym-secret') + } + ) + end + + it 'should raise ArgumentError when credentials hash is missing username' do + consumer = Mixpanel::Consumer.new + credentials_hash = {secret: 'secret', project_id: 'proj'} + + expect { + consumer.request('https://api.mixpanel.com/import', {'data' => 'test', 'verbose' => '1'}, credentials: credentials_hash, type: :import) + }.to raise_error(ArgumentError, "credentials hash missing 'username'") + end + + it 'should raise ArgumentError when credentials hash is missing secret' do + consumer = Mixpanel::Consumer.new + credentials_hash = {username: 'user', project_id: 'proj'} + + expect { + consumer.request('https://api.mixpanel.com/import', {'data' => 'test', 'verbose' => '1'}, credentials: credentials_hash, type: :import) + }.to raise_error(ArgumentError, "credentials hash missing 'secret'") + end + + it 'should raise ArgumentError when credentials hash is missing project_id' do + consumer = Mixpanel::Consumer.new + credentials_hash = {username: 'user', secret: 'secret'} + + expect { + consumer.request('https://api.mixpanel.com/import', {'data' => 'test', 'verbose' => '1'}, credentials: credentials_hash, type: :import) + }.to raise_error(ArgumentError, "credentials hash missing 'project_id'") + end + + it 'should raise ArgumentError when credentials is not ServiceAccountCredentials or Hash' do + consumer = Mixpanel::Consumer.new + + expect { + consumer.request('https://api.mixpanel.com/import', {'data' => 'test', 'verbose' => '1'}, credentials: 'invalid', type: :import) + }.to raise_error(ArgumentError, /credentials must be ServiceAccountCredentials or Hash, got String/) + end + + it 'should not include api_key when credentials are present' do + stub_request(:any, 'https://api.mixpanel.com/import?project_id=test-project').to_return({:body => '{"status": 1, "error": null}'}) + credentials = Mixpanel::ServiceAccountCredentials.new('user', 'secret', 'test-project') + consumer = Mixpanel::Consumer.new(nil, nil, nil, nil, credentials: credentials) + + # Message includes api_key but it should be ignored when credentials are present + consumer.send!(:import, {'data' => 'TEST EVENT MESSAGE', 'api_key' => 'SHOULD_BE_IGNORED'}.to_json) + + # api_key should NOT be in the request body (only data and verbose) + expect(WebMock).to have_requested(:post, 'https://api.mixpanel.com/import?project_id=test-project'). + with( + :body => { + 'data' => 'IlRFU1QgRVZFTlQgTUVTU0FHRSI=', + 'verbose' => '1' + } + ) + end end end @@ -229,4 +314,34 @@ def request(*args) end end + context 'with service account credentials' do + it 'should pass credentials to consumer when using BufferedConsumer' do + stub_request(:any, 'https://api.mixpanel.com/import?project_id=buffered-project').to_return({:body => '{"status": 1, "error": null}'}) + credentials = Mixpanel::ServiceAccountCredentials.new('buffered-user', 'buffered-secret', 'buffered-project') + consumer = Mixpanel::BufferedConsumer.new(nil, nil, nil, 2, credentials: credentials) + + # Import messages are not buffered - they're sent immediately + consumer.send!(:import, {'data' => 'EVENT 1'}.to_json) + + # Verify credentials were used in the request + expect(WebMock).to have_requested(:post, 'https://api.mixpanel.com/import?project_id=buffered-project'). + with( + :headers => { + 'Authorization' => 'Basic ' + Base64.strict_encode64('buffered-user:buffered-secret') + } + ).once + end + end + +end + +describe 'Connection error handling' do + it 'should raise ConnectionError when network error occurs' do + stub_request(:any, 'https://api.mixpanel.com/track').to_raise(StandardError.new('Network timeout')) + consumer = Mixpanel::Consumer.new + + expect { + consumer.send!(:event, {'data' => 'TEST EVENT MESSAGE'}.to_json) + }.to raise_error(Mixpanel::ConnectionError, /Could not connect to Mixpanel, with error "Network timeout"/) + end end diff --git a/spec/mixpanel-ruby/credentials_security_spec.rb b/spec/mixpanel-ruby/credentials_security_spec.rb new file mode 100644 index 00000000..fdfd4778 --- /dev/null +++ b/spec/mixpanel-ruby/credentials_security_spec.rb @@ -0,0 +1,108 @@ +require 'mixpanel-ruby' +require 'json' + +describe 'Credentials Security' do + describe 'import with constructor credentials (recommended)' do + it 'should NOT include credentials in message payload' do + messages = [] + credentials = Mixpanel::ServiceAccountCredentials.new('test-user', 'test-secret', 'test-project') + + # Create tracker with credentials in constructor + tracker = Mixpanel::Tracker.new('TOKEN', nil, credentials: credentials) do |type, message| + messages << [type, message] + end + + # Call import with nil api_key (recommended pattern) + tracker.import(nil, 'user123', 'Test Event', {'prop' => 'value'}) + + # Verify message was sent + expect(messages.length).to eq(1) + type, message_json = messages[0] + expect(type).to eq(:import) + + # Parse the message + message = JSON.parse(message_json) + + # CRITICAL: Credentials should NOT be in the message + expect(message).not_to have_key('credentials') + expect(message_json).not_to include('test-secret') + expect(message_json).not_to include('test-user') + + # Message should only contain data + expect(message).to have_key('data') + expect(message['data']).to have_key('event') + expect(message['data']['event']).to eq('Test Event') + end + + it 'should NOT include credentials in buffered messages' do + messages = [] + credentials = Mixpanel::ServiceAccountCredentials.new('test-user', 'test-secret', 'test-project') + + # Create buffered consumer with credentials + consumer = Mixpanel::BufferedConsumer.new(nil, nil, nil, 5, credentials: credentials) do |type, message| + messages << [type, message] + end + + # Send multiple event messages (events are buffered, imports are not) + 5.times do |i| + consumer.send!(:event, {'data' => {'event' => "Event #{i}"}}.to_json) + end + + # Verify batched message was sent (auto-flushed when buffer reaches max_length) + expect(messages.length).to eq(1) + type, message_json = messages[0] + + # CRITICAL: Credentials should NOT be in the batched message + expect(message_json).not_to include('credentials') + expect(message_json).not_to include('test-secret') + expect(message_json).not_to include('test-user') + end + end + + describe 'tracking events' do + it 'should never include credentials in event messages' do + messages = [] + credentials = Mixpanel::ServiceAccountCredentials.new('test-user', 'test-secret', 'test-project') + + tracker = Mixpanel::Tracker.new('TOKEN', nil, credentials: credentials) do |type, message| + messages << [type, message] + end + + # Track a regular event + tracker.track('user123', 'Test Event', {'prop' => 'value'}) + + # Verify message + expect(messages.length).to eq(1) + type, message_json = messages[0] + expect(type).to eq(:event) + + # Credentials should NEVER be in event messages + expect(message_json).not_to include('credentials') + expect(message_json).not_to include('test-secret') + end + end + + describe 'legacy API key pattern' do + it 'should include api_key in message but not credentials' do + messages = [] + tracker = Mixpanel::Tracker.new('TOKEN') do |type, message| + messages << [type, message] + end + + # Use legacy API key pattern + tracker.import('MY_API_KEY', 'user123', 'Test Event', {'prop' => 'value'}) + + # Verify message + expect(messages.length).to eq(1) + type, message_json = messages[0] + message = JSON.parse(message_json) + + # API key should be in message (legacy pattern) + expect(message).to have_key('api_key') + expect(message['api_key']).to eq('MY_API_KEY') + + # But credentials should NOT be + expect(message).not_to have_key('credentials') + end + end +end diff --git a/spec/mixpanel-ruby/tracker_consumer_spec.rb b/spec/mixpanel-ruby/tracker_consumer_spec.rb new file mode 100644 index 00000000..81343dce --- /dev/null +++ b/spec/mixpanel-ruby/tracker_consumer_spec.rb @@ -0,0 +1,34 @@ +require 'mixpanel-ruby' +require 'mixpanel-ruby/consumer' + +describe 'Mixpanel::Tracker with custom consumer' do + it 'should properly initialize error_handler when consumer is provided' do + custom_consumer = Mixpanel::Consumer.new + tracker = Mixpanel::Tracker.new('TEST_TOKEN', nil, consumer: custom_consumer) + + # Verify error_handler is initialized (not nil) + expect(tracker.instance_variable_get(:@error_handler)).not_to be_nil + expect(tracker.instance_variable_get(:@error_handler)).to be_a(Mixpanel::ErrorHandler) + end + + it 'should use provided error_handler when consumer is provided' do + custom_error_handler = Mixpanel::ErrorHandler.new + custom_consumer = Mixpanel::Consumer.new + tracker = Mixpanel::Tracker.new('TEST_TOKEN', custom_error_handler, consumer: custom_consumer) + + # Verify the provided error_handler is used + expect(tracker.instance_variable_get(:@error_handler)).to eq(custom_error_handler) + end + + it 'should handle errors gracefully when consumer is provided' do + # Create a consumer that will raise an error + failing_consumer = Mixpanel::Consumer.new + allow(failing_consumer).to receive(:send!).and_raise(Mixpanel::ConnectionError.new("Test error")) + + tracker = Mixpanel::Tracker.new('TEST_TOKEN', nil, consumer: failing_consumer) + + # This should not raise - error should be handled by error_handler + result = tracker.track('user123', 'Test Event') + expect(result).to be false + end +end From 02f14b6a8cccbd6d783d6f4437f40c7142dea695 Mon Sep 17 00:00:00 2001 From: John La <10409759+lajohn4747@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:30:49 +0000 Subject: [PATCH 24/28] Fix Priority 1 issues from code review 1. Add validation in Events#import to require authentication - Raises ArgumentError if both api_key and credentials are nil - Prevents confusing silent failures 2. Simplify Tracker#initialize to always call super - Properly initializes parent class in all cases - Overrides sink only when custom consumer provided - Fixes OOP principle violation 3. Add test for import without authentication - Verifies ArgumentError is raised appropriately --- lib/mixpanel-ruby/events.rb | 5 +++++ lib/mixpanel-ruby/tracker.rb | 18 +++++------------- spec/mixpanel-ruby/events_spec.rb | 12 ++++++++++++ 3 files changed, 22 insertions(+), 13 deletions(-) diff --git a/lib/mixpanel-ruby/events.rb b/lib/mixpanel-ruby/events.rb index 1cd04a98..60e56146 100644 --- a/lib/mixpanel-ruby/events.rb +++ b/lib/mixpanel-ruby/events.rb @@ -101,6 +101,11 @@ def track(distinct_id, event, properties={}, ip=nil) # tracker.import("API_KEY", "12345", "Credit Card Declined") # def import(api_key, distinct_id, event, properties={}, ip=nil) + # Validate that at least one authentication method is provided + if api_key.nil? && @credentials.nil? + raise ArgumentError, 'import requires authentication: provide either api_key parameter or credentials in constructor' + end + properties = { 'distinct_id' => distinct_id, 'token' => @token, diff --git a/lib/mixpanel-ruby/tracker.rb b/lib/mixpanel-ruby/tracker.rb index 6c7252ca..38280ba4 100644 --- a/lib/mixpanel-ruby/tracker.rb +++ b/lib/mixpanel-ruby/tracker.rb @@ -77,19 +77,11 @@ def initialize(token, error_handler=nil, credentials: nil, consumer: nil, local_ "Consumer.new(credentials: ...)" end - # Use provided consumer or fall back to block/default behavior from parent - if consumer - # Override parent's default behavior - use the provided consumer - # Initialize instance variables that super would have set - @token = token - @error_handler = error_handler || ErrorHandler.new - @credentials = credentials - @sink = consumer.method(:send!) - else - # Pass credentials to parent (Events) if no consumer provided - # Parent will create default Consumer with credentials - super(token, error_handler, credentials: credentials, &block) - end + # Always call super to properly initialize parent class + super(token, error_handler, credentials: credentials, &block) + + # Override sink if custom consumer provided + @sink = consumer.method(:send!) if consumer @people = People.new(token, error_handler, &block) @groups = Groups.new(token, error_handler, &block) diff --git a/spec/mixpanel-ruby/events_spec.rb b/spec/mixpanel-ruby/events_spec.rb index a0ee1c80..a7a9f731 100644 --- a/spec/mixpanel-ruby/events_spec.rb +++ b/spec/mixpanel-ruby/events_spec.rb @@ -107,4 +107,16 @@ } }) end + + it 'should raise ArgumentError when import is called without authentication' do + # Create Events without credentials + events = Mixpanel::Events.new('TEST TOKEN') do |type, message| + @log << [type, JSON.load(message)] + end + + # Try to import with nil api_key and no credentials + expect { + events.import(nil, 'TEST ID', 'Test Event', {}) + }.to raise_error(ArgumentError, /import requires authentication/) + end end From 51974d642435d5e1a52725c22db8d5adfa1cc2e8 Mon Sep 17 00:00:00 2001 From: John La <10409759+lajohn4747@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:42:24 +0000 Subject: [PATCH 25/28] Remove dead Hash credentials code Hash credentials were never used in the actual API flow: - All constructors accept ServiceAccountCredentials or nil - @credentials is always ServiceAccountCredentials or nil - Hash path in Consumer#request was only exercised by tests that directly called request() This removes unnecessary complexity and ~50 lines of dead code. Also add coverage/ and vendor/ to .gitignore to prevent accidental commits. --- .gitignore | 2 + lib/mixpanel-ruby/consumer.rb | 29 ++++---------- spec/mixpanel-ruby/consumer_spec.rb | 62 +---------------------------- 3 files changed, 11 insertions(+), 82 deletions(-) diff --git a/.gitignore b/.gitignore index b2480083..fd23d368 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ Gemfile.lock html mixpanel-ruby*.gem .bundle +coverage/ +vendor/ diff --git a/lib/mixpanel-ruby/consumer.rb b/lib/mixpanel-ruby/consumer.rb index 8a7a62cd..a5f69d4a 100644 --- a/lib/mixpanel-ruby/consumer.rb +++ b/lib/mixpanel-ruby/consumer.rb @@ -147,34 +147,20 @@ 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. # - # For service account authentication, pass credentials (ServiceAccountCredentials object - # or hash with 'username', 'secret', 'project_id') and type (:import) as keyword arguments. + # For service account authentication, pass credentials (ServiceAccountCredentials object) + # and type (:import) as keyword arguments. # The positional parameters are preserved for backward compatibility with custom Consumer subclasses. def request(endpoint, form_data, credentials: nil, type: nil) 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 - - # Extract and validate credentials - if credentials.is_a?(ServiceAccountCredentials) - username = credentials.username - secret = credentials.secret - project_id = credentials.project_id - elsif credentials.is_a?(Hash) - username = credentials['username'] || credentials[:username] - secret = credentials['secret'] || credentials[:secret] - project_id = credentials['project_id'] || credentials[:project_id] - - raise ArgumentError, "credentials hash missing 'username'" unless username - raise ArgumentError, "credentials hash missing 'secret'" unless secret - raise ArgumentError, "credentials hash missing 'project_id'" unless project_id - else - raise ArgumentError, "credentials must be ServiceAccountCredentials or Hash, got #{credentials.class}" + unless credentials.is_a?(ServiceAccountCredentials) + raise ArgumentError, "credentials must be ServiceAccountCredentials, got #{credentials.class}" end - query_params['project_id'] = project_id + 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 @@ -183,8 +169,7 @@ def request(endpoint, form_data, credentials: nil, type: nil) # Use Basic Auth with service account credentials for import endpoint if credentials && type == :import - # username and secret were already extracted and validated above - request.basic_auth(username, secret) + request.basic_auth(credentials.username, credentials.secret) end client = Net::HTTP.new(uri.host, uri.port) diff --git a/spec/mixpanel-ruby/consumer_spec.rb b/spec/mixpanel-ruby/consumer_spec.rb index 14c7e6cd..a1bbf474 100644 --- a/spec/mixpanel-ruby/consumer_spec.rb +++ b/spec/mixpanel-ruby/consumer_spec.rb @@ -119,70 +119,12 @@ def request(*args) ) end - it 'should accept credentials as a hash with string keys' do - stub_request(:any, 'https://api.mixpanel.com/import?project_id=proj-456').to_return({:body => '{"status": 1, "error": null}'}) - consumer = Mixpanel::Consumer.new - - credentials_hash = {'username' => 'hash-user', 'secret' => 'hash-secret', 'project_id' => 'proj-456'} - # Directly call request with credentials hash to test hash handling - consumer.request('https://api.mixpanel.com/import', {'data' => 'test', 'verbose' => '1'}, credentials: credentials_hash, type: :import) - - expect(WebMock).to have_requested(:post, 'https://api.mixpanel.com/import?project_id=proj-456'). - with( - :headers => { - 'Authorization' => 'Basic ' + Base64.strict_encode64('hash-user:hash-secret') - } - ) - end - - it 'should accept credentials as a hash with symbol keys' do - stub_request(:any, 'https://api.mixpanel.com/import?project_id=proj-789').to_return({:body => '{"status": 1, "error": null}'}) - consumer = Mixpanel::Consumer.new - - credentials_hash = {username: 'sym-user', secret: 'sym-secret', project_id: 'proj-789'} - consumer.request('https://api.mixpanel.com/import', {'data' => 'test', 'verbose' => '1'}, credentials: credentials_hash, type: :import) - - expect(WebMock).to have_requested(:post, 'https://api.mixpanel.com/import?project_id=proj-789'). - with( - :headers => { - 'Authorization' => 'Basic ' + Base64.strict_encode64('sym-user:sym-secret') - } - ) - end - - it 'should raise ArgumentError when credentials hash is missing username' do - consumer = Mixpanel::Consumer.new - credentials_hash = {secret: 'secret', project_id: 'proj'} - - expect { - consumer.request('https://api.mixpanel.com/import', {'data' => 'test', 'verbose' => '1'}, credentials: credentials_hash, type: :import) - }.to raise_error(ArgumentError, "credentials hash missing 'username'") - end - - it 'should raise ArgumentError when credentials hash is missing secret' do - consumer = Mixpanel::Consumer.new - credentials_hash = {username: 'user', project_id: 'proj'} - - expect { - consumer.request('https://api.mixpanel.com/import', {'data' => 'test', 'verbose' => '1'}, credentials: credentials_hash, type: :import) - }.to raise_error(ArgumentError, "credentials hash missing 'secret'") - end - - it 'should raise ArgumentError when credentials hash is missing project_id' do - consumer = Mixpanel::Consumer.new - credentials_hash = {username: 'user', secret: 'secret'} - - expect { - consumer.request('https://api.mixpanel.com/import', {'data' => 'test', 'verbose' => '1'}, credentials: credentials_hash, type: :import) - }.to raise_error(ArgumentError, "credentials hash missing 'project_id'") - end - - it 'should raise ArgumentError when credentials is not ServiceAccountCredentials or Hash' do + it 'should raise ArgumentError when credentials is not ServiceAccountCredentials' do consumer = Mixpanel::Consumer.new expect { consumer.request('https://api.mixpanel.com/import', {'data' => 'test', 'verbose' => '1'}, credentials: 'invalid', type: :import) - }.to raise_error(ArgumentError, /credentials must be ServiceAccountCredentials or Hash, got String/) + }.to raise_error(ArgumentError, /credentials must be ServiceAccountCredentials, got String/) end it 'should not include api_key when credentials are present' do From bc11c4ee9225815283db56e5619017ac2804b85e Mon Sep 17 00:00:00 2001 From: John La <10409759+lajohn4747@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:47:27 +0000 Subject: [PATCH 26/28] Clean up code --- .gitignore | 2 -- lib/mixpanel-ruby/consumer.rb | 12 ++---------- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index fd23d368..b2480083 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,3 @@ Gemfile.lock html mixpanel-ruby*.gem .bundle -coverage/ -vendor/ diff --git a/lib/mixpanel-ruby/consumer.rb b/lib/mixpanel-ruby/consumer.rb index a5f69d4a..10d301c9 100644 --- a/lib/mixpanel-ruby/consumer.rb +++ b/lib/mixpanel-ruby/consumer.rb @@ -281,16 +281,8 @@ def flush_type(type) sent_messages = 0 begin @buffers[type].each_slice(@max_length) do |chunk| - # Extract data from all messages in chunk - parsed_messages = chunk.map {|message| JSON.load(message) } - data = parsed_messages.map {|msg| msg['data'] } - - # Preserve api_key from first message (if present) - first_message = parsed_messages.first - batch_message = {'data' => data} - batch_message['api_key'] = first_message['api_key'] if first_message['api_key'] - - @sink.call(type, batch_message.to_json) + data = chunk.map {|message| JSON.load(message)['data'] } + @sink.call(type, {'data' => data}.to_json) sent_messages += chunk.length end rescue From 0e30bb6de72a85c05b5af9e438145ddc67f0f091 Mon Sep 17 00:00:00 2001 From: John La <10409759+lajohn4747@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:58:50 +0000 Subject: [PATCH 27/28] Remove custom consumer from tracker --- CHANGELOG.md | 20 +++++------- lib/mixpanel-ruby/tracker.rb | 15 +-------- spec/mixpanel-ruby/tracker_consumer_spec.rb | 34 --------------------- 3 files changed, 9 insertions(+), 60 deletions(-) delete mode 100644 spec/mixpanel-ruby/tracker_consumer_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 4baf701b..30fbf64a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,22 +2,18 @@ ## [Unreleased] -### Changed - -- `Tracker.new()` now accepts `credentials:` parameter for service account authentication -- `Consumer.new()` and `BufferedConsumer.new()` now accept `credentials:` parameter -- `Tracker.new()` now accepts `consumer:` parameter to provide a custom consumer instance -- When using service account credentials, pass `nil` as the first parameter to `import()`: `tracker.import(nil, distinct_id, event, ...)` instead of passing credentials - ### Added -- New secure API pattern: credentials passed to constructor are stored as instance variables and used only for HTTP Basic Auth headers, never serialized to JSON -- Warning when both `consumer` and `credentials` parameters are provided to `Tracker.new()` (credentials are ignored in this case - pass them to the consumer instead) -- Comprehensive test coverage for secure credential handling in `spec/mixpanel-ruby/credentials_security_spec.rb` +- Service account authentication support via `ServiceAccountCredentials` class +- `Tracker.new()`, `Events.new()`, `Consumer.new()`, and `BufferedConsumer.new()` now accept `credentials:` parameter +- Credentials are passed to feature flags providers (local and remote) for authenticated API access +- Authentication validation: `import()` now raises `ArgumentError` when called without either `api_key` or `credentials` +- Comprehensive test coverage for credential handling and security in `spec/mixpanel-ruby/credentials_spec.rb` and `spec/mixpanel-ruby/credentials_security_spec.rb` -### Fixed +### Changed -- `BufferedConsumer` now properly preserves `api_key` when flushing batched messages (previously it was dropped during flush) +- When using service account credentials, pass `nil` as the first parameter to `import()`: `tracker.import(nil, distinct_id, event, properties)` instead of passing an API key +- Credentials are stored as instance variables and used only for HTTP Basic Auth headers, never serialized to JSON payloads ## [v3.1.0](https://github.com/mixpanel/mixpanel-ruby/tree/v3.1.0) (2026-05-13) diff --git a/lib/mixpanel-ruby/tracker.rb b/lib/mixpanel-ruby/tracker.rb index 38280ba4..ea899dbe 100644 --- a/lib/mixpanel-ruby/tracker.rb +++ b/lib/mixpanel-ruby/tracker.rb @@ -65,24 +65,11 @@ class Tracker < Events # # Optional parameters: # - credentials: ServiceAccountCredentials for authentication (used for import and feature flags) - # - consumer: A custom consumer instance (Mixpanel::Consumer or Mixpanel::BufferedConsumer). - # If provided, credentials passed here are ignored - pass them to the consumer constructor instead. # - 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, consumer: nil, local_flags_config: nil, remote_flags_config: nil, &block) - # Warn if both consumer and credentials provided - if consumer && credentials - warn "[Mixpanel] Credentials passed to Tracker are ignored when a custom consumer is provided. " \ - "Pass credentials to your consumer's constructor instead: " \ - "Consumer.new(credentials: ...)" - end - - # Always call super to properly initialize parent class + def initialize(token, error_handler=nil, credentials: nil, local_flags_config: nil, remote_flags_config: nil, &block) super(token, error_handler, credentials: credentials, &block) - # Override sink if custom consumer provided - @sink = consumer.method(:send!) if consumer - @people = People.new(token, error_handler, &block) @groups = Groups.new(token, error_handler, &block) diff --git a/spec/mixpanel-ruby/tracker_consumer_spec.rb b/spec/mixpanel-ruby/tracker_consumer_spec.rb deleted file mode 100644 index 81343dce..00000000 --- a/spec/mixpanel-ruby/tracker_consumer_spec.rb +++ /dev/null @@ -1,34 +0,0 @@ -require 'mixpanel-ruby' -require 'mixpanel-ruby/consumer' - -describe 'Mixpanel::Tracker with custom consumer' do - it 'should properly initialize error_handler when consumer is provided' do - custom_consumer = Mixpanel::Consumer.new - tracker = Mixpanel::Tracker.new('TEST_TOKEN', nil, consumer: custom_consumer) - - # Verify error_handler is initialized (not nil) - expect(tracker.instance_variable_get(:@error_handler)).not_to be_nil - expect(tracker.instance_variable_get(:@error_handler)).to be_a(Mixpanel::ErrorHandler) - end - - it 'should use provided error_handler when consumer is provided' do - custom_error_handler = Mixpanel::ErrorHandler.new - custom_consumer = Mixpanel::Consumer.new - tracker = Mixpanel::Tracker.new('TEST_TOKEN', custom_error_handler, consumer: custom_consumer) - - # Verify the provided error_handler is used - expect(tracker.instance_variable_get(:@error_handler)).to eq(custom_error_handler) - end - - it 'should handle errors gracefully when consumer is provided' do - # Create a consumer that will raise an error - failing_consumer = Mixpanel::Consumer.new - allow(failing_consumer).to receive(:send!).and_raise(Mixpanel::ConnectionError.new("Test error")) - - tracker = Mixpanel::Tracker.new('TEST_TOKEN', nil, consumer: failing_consumer) - - # This should not raise - error should be handled by error_handler - result = tracker.track('user123', 'Test Event') - expect(result).to be false - end -end From 412fa9f2971f7ee3c845fc356bf236ff4b60968a Mon Sep 17 00:00:00 2001 From: John La <10409759+lajohn4747@users.noreply.github.com> Date: Thu, 9 Jul 2026 20:44:03 +0000 Subject: [PATCH 28/28] Use a separate method for service credential import --- Readme.rdoc | 9 ++- lib/mixpanel-ruby/events.rb | 52 ++++++++++++++- lib/mixpanel-ruby/tracker.rb | 22 +++++++ spec/mixpanel-ruby/events_spec.rb | 106 ++++++++++++++++++++++++++++++ 4 files changed, 184 insertions(+), 5 deletions(-) diff --git a/Readme.rdoc b/Readme.rdoc index dcd08333..0db04b45 100644 --- a/Readme.rdoc +++ b/Readme.rdoc @@ -49,8 +49,13 @@ API keys for import operations and feature flags. # Credentials are stored securely and never serialized to JSON tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN, credentials: credentials) - # Import historical events - pass nil for api_key (first parameter) - # Consumer uses credentials from constructor for authentication + # Import historical events using import_events (recommended) + tracker.import_events('User1', 'Past Event', { + 'time' => 1369353600, + 'Source' => 'Import' + }) + + # Alternative: use import() with nil as first parameter tracker.import(nil, 'User1', 'Past Event', { 'time' => 1369353600, 'Source' => 'Import' diff --git a/lib/mixpanel-ruby/events.rb b/lib/mixpanel-ruby/events.rb index 60e56146..eb9acb04 100644 --- a/lib/mixpanel-ruby/events.rb +++ b/lib/mixpanel-ruby/events.rb @@ -27,6 +27,10 @@ def initialize(token, error_handler=nil, credentials: nil, &block) @error_handler = error_handler || ErrorHandler.new @credentials = credentials + if block && credentials + warn '[WARNING] credentials passed to Events/Tracker are ignored when a custom sink block is provided. Pass credentials to your consumer directly.' + end + if block @sink = block else @@ -88,19 +92,61 @@ def track(distinct_id, event, properties={}, ip=nil) # we pass the time of the method call as the time the event occured, if you # wish to override this pass a timestamp in the properties hash. # - # # Recommended: Pass credentials to constructor, pass nil for api_key + # # Recommended: Use import_events() instead # credentials = Mixpanel::ServiceAccountCredentials.new(username, secret, project_id) # tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN, credentials: credentials) - # tracker.import(nil, "12345", "Welcome Email Sent", { + # tracker.import_events("12345", "Welcome Email Sent", { # 'Email Template' => 'Pretty Pink Welcome', # 'User Sign-up Cohort' => 'July 2013', # 'time' => 1369353600, # }) # - # # Legacy: Pass API key as first parameter + # # Legacy: Pass API key as first parameter (deprecated) # tracker.import("API_KEY", "12345", "Credit Card Declined") # def import(api_key, distinct_id, event, properties={}, ip=nil) + # Warn about deprecated API key usage + if api_key && !api_key.to_s.empty? + warn '[DEPRECATION] Passing api_key to import() is deprecated. Use ServiceAccountCredentials in the constructor instead. See https://docs.mixpanel.com/docs/tracking-methods/sdks/ruby#service-account-authentication' + end + + # Warn when using import(nil, ...) - recommend import_events instead + if api_key.nil? && @credentials + warn '[DEPRECATION] Using import(nil, ...) is deprecated. Use import_events(...) instead for cleaner API.' + end + + # Delegate to internal implementation + import_internal(api_key, distinct_id, event, properties, ip) + end + + # Import an event using service account credentials from the constructor. + # This is the recommended method for importing historical events with service accounts. + # + # Credentials must be provided in the Tracker/Events constructor. This method is cleaner + # than import() as it doesn't require passing nil as the first parameter. + # + # credentials = Mixpanel::ServiceAccountCredentials.new(username, secret, project_id) + # tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN, credentials: credentials) + # + # tracker.import_events('user123', 'Past Event', { + # 'time' => 1369353600, + # 'Source' => 'Import' + # }) + # + def import_events(distinct_id, event, properties={}, ip=nil) + unless @credentials + raise ArgumentError, 'import_events requires credentials in constructor. Use: Tracker.new(token, credentials: credentials)' + end + + # Delegate to internal implementation with nil api_key (uses constructor credentials) + import_internal(nil, distinct_id, event, properties, ip) + end + + private + + # Internal implementation for importing events. + # Called by both import() and import_events(). + def import_internal(api_key, distinct_id, event, properties, ip) # Validate that at least one authentication method is provided if api_key.nil? && @credentials.nil? raise ArgumentError, 'import requires authentication: provide either api_key parameter or credentials in constructor' diff --git a/lib/mixpanel-ruby/tracker.rb b/lib/mixpanel-ruby/tracker.rb index ea899dbe..ff30f939 100644 --- a/lib/mixpanel-ruby/tracker.rb +++ b/lib/mixpanel-ruby/tracker.rb @@ -148,6 +148,28 @@ def import(api_key, distinct_id, event, properties={}, ip=nil) super end + # Import an event using service account credentials from the constructor. + # This is the recommended method for importing historical events with service accounts. + # + # Service account credentials must be provided when creating the Tracker. + # This method provides a cleaner API than import() as it doesn't require + # passing nil as the first parameter. + # + # credentials = Mixpanel::ServiceAccountCredentials.new(username, secret, project_id) + # tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN, credentials: credentials) + # + # # Import a historical event + # tracker.import_events('user123', 'Past Event', { + # 'Email Template' => 'Welcome Email', + # 'time' => 1369353600 + # }) + # + def import_events(distinct_id, event, properties={}, ip=nil) + # This is here strictly to allow rdoc to include the relevant + # documentation + super + end + # Creates a distinct_id alias. \Events and updates with an alias # will be considered by mixpanel to have the same source, and # refer to the same profile. diff --git a/spec/mixpanel-ruby/events_spec.rb b/spec/mixpanel-ruby/events_spec.rb index a7a9f731..73a2ee07 100644 --- a/spec/mixpanel-ruby/events_spec.rb +++ b/spec/mixpanel-ruby/events_spec.rb @@ -119,4 +119,110 @@ events.import(nil, 'TEST ID', 'Test Event', {}) }.to raise_error(ArgumentError, /import requires authentication/) end + + describe 'import_events' do + it 'should send a well formed import/ message with constructor credentials' do + credentials = Mixpanel::ServiceAccountCredentials.new('test-user', 'test-secret', 'test-project-123') + + events_with_creds = Mixpanel::Events.new('TEST TOKEN', nil, credentials: credentials) do |type, message| + @log << [type, JSON.load(message)] + end + + # Clean API - no nil! + events_with_creds.import_events('TEST ID', 'Test Event', { + 'Circumstances' => 'During a test' + }) + + expect(@log.length).to eq(1) + expect(@log[0][0]).to eq(:import) + + message = @log[0][1] + # Credentials should NOT be in message + expect(message).not_to have_key('credentials') + expect(message).not_to have_key('api_key') + expect(message['data']).to eq({ + 'event' => 'Test Event', + 'properties' => { + 'Circumstances' => 'During a test', + 'distinct_id' => 'TEST ID', + 'mp_lib' => 'ruby', + '$lib_version' => Mixpanel::VERSION, + 'token' => 'TEST TOKEN', + 'time' => @time_now.to_i * 1000 + } + }) + end + + it 'should raise ArgumentError when called without constructor credentials' do + events = Mixpanel::Events.new('TEST TOKEN') do |type, message| + @log << [type, JSON.load(message)] + end + + expect { + events.import_events('TEST ID', 'Test Event', {}) + }.to raise_error(ArgumentError, /import_events requires credentials in constructor/) + end + end + + describe 'warnings' do + it 'should warn when credentials are passed with a custom block' do + credentials = Mixpanel::ServiceAccountCredentials.new('user', 'secret', 'project') + + expect { + Mixpanel::Events.new('TEST TOKEN', nil, credentials: credentials) do |type, message| + # Custom block + end + }.to output(/credentials passed to Events\/Tracker are ignored/).to_stderr + end + + it 'should not warn when credentials are passed without a block' do + credentials = Mixpanel::ServiceAccountCredentials.new('user', 'secret', 'project') + + expect { + Mixpanel::Events.new('TEST TOKEN', nil, credentials: credentials) + }.not_to output.to_stderr + end + + it 'should warn when using deprecated api_key in import' do + events = Mixpanel::Events.new('TEST TOKEN') do |type, message| + @log << [type, JSON.load(message)] + end + + expect { + events.import('API_KEY', 'TEST ID', 'Test Event', {}) + }.to output(/DEPRECATION.*api_key to import\(\) is deprecated/).to_stderr + + # Second call should also warn + expect { + events.import('API_KEY', 'TEST ID', 'Test Event 2', {}) + }.to output(/DEPRECATION.*api_key to import\(\) is deprecated/).to_stderr + end + + it 'should warn when using import(nil, ...) with credentials' do + credentials = Mixpanel::ServiceAccountCredentials.new('user', 'secret', 'project') + events = Mixpanel::Events.new('TEST TOKEN', nil, credentials: credentials) do |type, message| + @log << [type, JSON.load(message)] + end + + expect { + events.import(nil, 'TEST ID', 'Test Event', {}) + }.to output(/DEPRECATION.*import\(nil, \.\.\.\) is deprecated.*import_events/).to_stderr + + # Second call should also warn + expect { + events.import(nil, 'TEST ID', 'Test Event 2', {}) + }.to output(/DEPRECATION.*import\(nil, \.\.\.\) is deprecated.*import_events/).to_stderr + end + + it 'should not warn when using import_events' do + credentials = Mixpanel::ServiceAccountCredentials.new('user', 'secret', 'project') + events = Mixpanel::Events.new('TEST TOKEN', nil, credentials: credentials) do |type, message| + @log << [type, JSON.load(message)] + end + + expect { + events.import_events('TEST ID', 'Test Event', {}) + }.not_to output(/DEPRECATION/).to_stderr + end + end end