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/16] 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 f2b3912..9ec46f7 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 79df69c..4f27b51 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 3078f66..f76271a 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 be653cd..6432e0d 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 116f011..61ada12 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 9bf1bb0..b2d7b88 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 eaa6471..f2cf56f 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 667c672..4ef7c2e 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 57f6ade..adbf2cb 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/16] 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 0000000..305ea1e --- /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 61ada12..2c37318 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 0000000..abaa772 --- /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 adbf2cb..38ed416 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/16] 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 305ea1e..023f87a 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/16] 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 9ec46f7..ef07522 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 4ef7c2e..f650ecf 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 1fe54eb..f2e9054 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/16] 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 ef07522..d470577 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 b2d7b88..f10ee4d 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 f2cf56f..7ced48f 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 f650ecf..4d01b7d 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 f2e9054..1aa3a8a 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/16] 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 6969521..a187061 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 8e7308b..ad49035 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/16] 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 a187061..3e5ed08 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/16] 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 3e5ed08..cfb97fb 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 ad49035..f7f959c 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/16] 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 941f256..3fd29db 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/16] 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 f76271a..abcfd8b 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 6432e0d..8c0107c 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 2c37318..eb8a715 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 3fd29db..64c5b9f 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 38ed416..29e4a99 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/16] 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 64c5b9f..0b6f4b7 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/16] 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 023f87a..5b8f427 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/16] 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 abaa772..24ee415 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 29e4a99..e014e56 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/16] 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 5b8f427..8c07650 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 24ee415..38b427a 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/16] 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 e014e56..72d2cdc 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/16] 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 cc14074..5ea98fa 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 4d01b7d..84e80e0 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 0182c74..fad90d0 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!