Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
e70855a
Add service account support
lajohn4747 Jun 25, 2026
6ac0e4c
Fix comments
lajohn4747 Jun 25, 2026
7269367
Add explicit to_json method to ServiceAccountCredentials
lajohn4747 Jun 26, 2026
f3dc6da
Simplify credentials API for feature flags
lajohn4747 Jun 26, 2026
f7f4269
Remove credentials from config hash, pass as separate parameter
lajohn4747 Jun 26, 2026
03a0096
Fix flag provider tests for new credentials parameter
lajohn4747 Jun 26, 2026
2d12649
Fix remaining provider instantiation in polling test
lajohn4747 Jun 26, 2026
b520a0b
Add tests for service account credentials authentication in flag prov…
lajohn4747 Jun 28, 2026
dca931f
Add test for service account credentials in Consumer import endpoint
lajohn4747 Jun 29, 2026
880a281
Fix passing credentials to go import
lajohn4747 Jun 30, 2026
d7a06bf
Fix WebMock stub for service account import test
lajohn4747 Jun 30, 2026
8183119
security: exclude secret from ServiceAccountCredentials JSON serializ…
lajohn4747 Jun 30, 2026
9c5be70
test: update tests to expect secret not in JSON serialization
lajohn4747 Jun 30, 2026
3336b0e
fix: accept integer project_id and convert to string
lajohn4747 Jul 1, 2026
0e1377f
fix test
lajohn4747 Jul 1, 2026
9d57d4a
Merge branch 'master' into johnla-multi-567-add-service-account-suppo…
lajohn4747 Jul 1, 2026
9f9ef92
Fix tests after rebase
lajohn4747 Jul 1, 2026
e263b02
Address comments
lajohn4747 Jul 6, 2026
5e8836b
move notice earlier
lajohn4747 Jul 6, 2026
9a3c29b
Mirror python implementation
lajohn4747 Jul 9, 2026
41c21cb
Fix greptile issues
lajohn4747 Jul 9, 2026
09f5dac
Fix docstring
lajohn4747 Jul 9, 2026
440cc42
Claude review
lajohn4747 Jul 9, 2026
1434e4e
Add comprehensive test coverage for service account credentials
lajohn4747 Jul 9, 2026
02f14b6
Fix Priority 1 issues from code review
lajohn4747 Jul 9, 2026
51974d6
Remove dead Hash credentials code
lajohn4747 Jul 9, 2026
bc11c4e
Clean up code
lajohn4747 Jul 9, 2026
0e30bb6
Remove custom consumer from tracker
lajohn4747 Jul 9, 2026
412fa9f
Use a separate method for service credential import
lajohn4747 Jul 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
# Changelog

## [Unreleased]

### Added

- 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`

### Changed

- 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)

Initial entry for the standardized release process. See `Readme.rdoc` for prior version history.
63 changes: 63 additions & 0 deletions Readme.rdoc
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,69 @@ 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 (Recommended)

require 'mixpanel-ruby'

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

# 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 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'
})

=== 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'
})

=== Feature Flags with Service Account

require 'mixpanel-ruby'

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

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

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

== Additional Information

For more information please visit:
Expand Down
1 change: 1 addition & 0 deletions lib/mixpanel-ruby.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
require 'mixpanel-ruby/consumer.rb'
require 'mixpanel-ruby/tracker.rb'
require 'mixpanel-ruby/version.rb'
require 'mixpanel-ruby/credentials.rb'
require 'mixpanel-ruby/flags/utils.rb'
require 'mixpanel-ruby/flags/types.rb'
require 'mixpanel-ruby/flags/flags_provider.rb'
Expand Down
60 changes: 54 additions & 6 deletions lib/mixpanel-ruby/consumer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ def self.config_http(&block)
# @kestrel.set(ANALYTICS_QUEUE, [type, message].to_json)
# end
#
# 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
# consumes the above queue might work like this:
Expand All @@ -58,14 +63,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
Expand All @@ -85,13 +95,26 @@ def send!(type, message)

decoded_message = JSON.load(message)
api_key = decoded_message["api_key"]

data = Base64.encode64(decoded_message["data"].to_json).gsub("\n", '')

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

# Only add api_key to form data if using legacy API key (not service account credentials)
# Service account credentials use HTTP Basic Auth instead
if api_key && !@credentials
form_data.merge!("api_key" => api_key)
end

begin
response_code, response_body = request(endpoint, form_data)
# 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: @credentials, type: type)
else
request(endpoint, form_data)
end
rescue => e
raise ConnectionError.new("Could not connect to Mixpanel, with error \"#{e.message}\".")
end
Expand Down Expand Up @@ -123,11 +146,32 @@ 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)
#
# 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
unless credentials.is_a?(ServiceAccountCredentials)
raise ArgumentError, "credentials must be ServiceAccountCredentials, got #{credentials.class}"
end

query_params = URI.decode_www_form(uri.query || '').to_h
query_params['project_id'] = credentials.project_id
uri.query = URI.encode_www_form(query_params)
end

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

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

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

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

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

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

end
end
80 changes: 69 additions & 11 deletions lib/mixpanel-ruby/events.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,19 @@ 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 && 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
consumer = Consumer.new
consumer = Consumer.new(credentials: credentials)
@sink = consumer.method(:send!)
end
end
Expand Down Expand Up @@ -87,19 +92,66 @@ 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)
#
# # Track that user "12345"'s credit card was declined
# 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", {
# # Recommended: Use import_events() instead
# credentials = Mixpanel::ServiceAccountCredentials.new(username, secret, project_id)
# tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN, credentials: credentials)
# 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 (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'
end

properties = {
'distinct_id' => distinct_id,
'token' => @token,
Expand All @@ -116,9 +168,15 @@ def import(api_key, distinct_id, event, properties={}, ip=nil)

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

# 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
begin
@sink.call(:import, message.to_json)
Expand Down
Loading
Loading