mixpanel-ruby 3.1.0 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/dependabot.yml +14 -0
- data/.github/modules.json +18 -0
- data/.github/scripts/generate-changelog.sh +87 -0
- data/.github/workflows/pr-title-check.yml +51 -0
- data/.github/workflows/prepare-release.yml +189 -0
- data/.github/workflows/release-rubygems.yml +217 -0
- data/CHANGELOG.md +30 -0
- data/Readme.rdoc +65 -0
- data/lib/mixpanel-ruby/consumer.rb +54 -6
- data/lib/mixpanel-ruby/credentials.rb +26 -0
- data/lib/mixpanel-ruby/events.rb +69 -11
- data/lib/mixpanel-ruby/flags/flags_provider.rb +16 -2
- data/lib/mixpanel-ruby/flags/local_flags_provider.rb +85 -18
- data/lib/mixpanel-ruby/flags/remote_flags_provider.rb +4 -2
- data/lib/mixpanel-ruby/tracker.rb +39 -9
- data/lib/mixpanel-ruby/version.rb +1 -1
- data/lib/mixpanel-ruby.rb +1 -0
- data/openfeature-provider/CHANGELOG.md +5 -0
- data/openfeature-provider/README.md +2 -0
- data/openfeature-provider/lib/mixpanel/openfeature/version.rb +7 -0
- data/openfeature-provider/lib/mixpanel/openfeature.rb +1 -0
- data/openfeature-provider/mixpanel-ruby-openfeature.gemspec +3 -1
- data/spec/mixpanel-ruby/consumer_spec.rb +81 -0
- data/spec/mixpanel-ruby/credentials_security_spec.rb +108 -0
- data/spec/mixpanel-ruby/credentials_spec.rb +54 -0
- data/spec/mixpanel-ruby/events_spec.rb +152 -0
- data/spec/mixpanel-ruby/flags/local_flags_spec.rb +209 -2
- data/spec/mixpanel-ruby/flags/remote_flags_spec.rb +36 -0
- data/spec/mixpanel-ruby/tracker_spec.rb +18 -0
- metadata +14 -2
data/Readme.rdoc
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
= mixpanel-ruby: The official Mixpanel Ruby library
|
|
2
2
|
|
|
3
|
+
##### _July 10, 2026_ - [v3.2.0](https://github.com/mixpanel/mixpanel-ruby/releases/tag/v3.2.0)
|
|
4
|
+
|
|
3
5
|
mixpanel-ruby is a library for tracking events and sending \Mixpanel profile
|
|
4
6
|
updates to \Mixpanel from your ruby applications.
|
|
5
7
|
|
|
@@ -27,6 +29,69 @@ The primary class you will use to track events is Mixpanel::Tracker. An instance
|
|
|
27
29
|
Mixpanel::Tracker is enough to send events directly to \Mixpanel, and get you integrated
|
|
28
30
|
right away.
|
|
29
31
|
|
|
32
|
+
== Service Account Authentication
|
|
33
|
+
|
|
34
|
+
Service accounts provide secure server-to-server authentication and are recommended over
|
|
35
|
+
API keys for import operations and feature flags.
|
|
36
|
+
|
|
37
|
+
=== Import with Service Account (Recommended)
|
|
38
|
+
|
|
39
|
+
require 'mixpanel-ruby'
|
|
40
|
+
|
|
41
|
+
# Create service account credentials
|
|
42
|
+
credentials = Mixpanel::ServiceAccountCredentials.new(
|
|
43
|
+
'your-service-account-username',
|
|
44
|
+
'your-service-account-secret',
|
|
45
|
+
'your-project-id'
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# Pass credentials to tracker constructor
|
|
49
|
+
# Credentials are stored securely and never serialized to JSON
|
|
50
|
+
tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN, credentials: credentials)
|
|
51
|
+
|
|
52
|
+
# Import historical events using import_events (recommended)
|
|
53
|
+
tracker.import_events('User1', 'Past Event', {
|
|
54
|
+
'time' => 1369353600,
|
|
55
|
+
'Source' => 'Import'
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
# Alternative: use import() with nil as first parameter
|
|
59
|
+
tracker.import(nil, 'User1', 'Past Event', {
|
|
60
|
+
'time' => 1369353600,
|
|
61
|
+
'Source' => 'Import'
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
=== Import with API Key (Legacy)
|
|
65
|
+
|
|
66
|
+
# Legacy approach with API key string
|
|
67
|
+
tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN)
|
|
68
|
+
tracker.import("API_KEY", 'User1', 'Past Event', {
|
|
69
|
+
'time' => 1369353600,
|
|
70
|
+
'Source' => 'Import'
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
=== Feature Flags with Service Account
|
|
74
|
+
|
|
75
|
+
require 'mixpanel-ruby'
|
|
76
|
+
|
|
77
|
+
credentials = Mixpanel::ServiceAccountCredentials.new(
|
|
78
|
+
'your-service-account-username',
|
|
79
|
+
'your-service-account-secret',
|
|
80
|
+
'your-project-id'
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Pass credentials directly to the tracker
|
|
84
|
+
tracker = Mixpanel::Tracker.new(
|
|
85
|
+
YOUR_MIXPANEL_TOKEN,
|
|
86
|
+
nil,
|
|
87
|
+
credentials: credentials,
|
|
88
|
+
local_flags_config: {},
|
|
89
|
+
remote_flags_config: {}
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# Use feature flags
|
|
93
|
+
is_enabled = tracker.remote_flags.is_enabled?('my-flag', { 'distinct_id' => 'User1' })
|
|
94
|
+
|
|
30
95
|
== Additional Information
|
|
31
96
|
|
|
32
97
|
For more information please visit:
|
|
@@ -40,6 +40,11 @@ module Mixpanel
|
|
|
40
40
|
# @kestrel.set(ANALYTICS_QUEUE, [type, message].to_json)
|
|
41
41
|
# end
|
|
42
42
|
#
|
|
43
|
+
# IMPORTANT SECURITY NOTE: Always pass credentials to the Consumer or Tracker
|
|
44
|
+
# constructor (credentials: ...). Credentials are stored as instance variables
|
|
45
|
+
# and used only for HTTP Basic Auth headers - they never appear in message JSON,
|
|
46
|
+
# preventing accidental credential leakage in logs or queue storage.
|
|
47
|
+
#
|
|
43
48
|
# You can also instantiate the library consumers yourself, and use
|
|
44
49
|
# them wherever you would like. For example, the working that
|
|
45
50
|
# consumes the above queue might work like this:
|
|
@@ -58,14 +63,19 @@ module Mixpanel
|
|
|
58
63
|
# they will be used instead of the default Mixpanel endpoints.
|
|
59
64
|
# This can be useful for proxying, debugging, or if you prefer
|
|
60
65
|
# not to use SSL for your events.
|
|
66
|
+
#
|
|
67
|
+
# @param credentials [ServiceAccountCredentials, nil] Service account credentials for authentication.
|
|
68
|
+
# Credentials are only used for the /import endpoint and feature flags.
|
|
61
69
|
def initialize(events_endpoint=nil,
|
|
62
70
|
update_endpoint=nil,
|
|
63
71
|
groups_endpoint=nil,
|
|
64
|
-
import_endpoint=nil
|
|
72
|
+
import_endpoint=nil,
|
|
73
|
+
credentials: nil)
|
|
65
74
|
@events_endpoint = events_endpoint || 'https://api.mixpanel.com/track'
|
|
66
75
|
@update_endpoint = update_endpoint || 'https://api.mixpanel.com/engage'
|
|
67
76
|
@groups_endpoint = groups_endpoint || 'https://api.mixpanel.com/groups'
|
|
68
77
|
@import_endpoint = import_endpoint || 'https://api.mixpanel.com/import'
|
|
78
|
+
@credentials = credentials
|
|
69
79
|
end
|
|
70
80
|
|
|
71
81
|
# Send the given string message to Mixpanel. Type should be
|
|
@@ -85,13 +95,26 @@ module Mixpanel
|
|
|
85
95
|
|
|
86
96
|
decoded_message = JSON.load(message)
|
|
87
97
|
api_key = decoded_message["api_key"]
|
|
98
|
+
|
|
88
99
|
data = Base64.encode64(decoded_message["data"].to_json).gsub("\n", '')
|
|
89
100
|
|
|
90
101
|
form_data = {"data" => data, "verbose" => 1}
|
|
91
|
-
|
|
102
|
+
|
|
103
|
+
# Only add api_key to form data if using legacy API key (not service account credentials)
|
|
104
|
+
# Service account credentials use HTTP Basic Auth instead
|
|
105
|
+
if api_key && !@credentials
|
|
106
|
+
form_data.merge!("api_key" => api_key)
|
|
107
|
+
end
|
|
92
108
|
|
|
93
109
|
begin
|
|
94
|
-
|
|
110
|
+
# Use keyword arguments for credentials to maintain backward compatibility
|
|
111
|
+
# with custom Consumer subclasses that override request(endpoint, form_data)
|
|
112
|
+
response_code, response_body =
|
|
113
|
+
if @credentials && type == :import
|
|
114
|
+
request(endpoint, form_data, credentials: @credentials, type: type)
|
|
115
|
+
else
|
|
116
|
+
request(endpoint, form_data)
|
|
117
|
+
end
|
|
95
118
|
rescue => e
|
|
96
119
|
raise ConnectionError.new("Could not connect to Mixpanel, with error \"#{e.message}\".")
|
|
97
120
|
end
|
|
@@ -123,11 +146,32 @@ module Mixpanel
|
|
|
123
146
|
#
|
|
124
147
|
# as the result of the response. Response code should be nil if
|
|
125
148
|
# the request never receives a response for some reason.
|
|
126
|
-
|
|
149
|
+
#
|
|
150
|
+
# For service account authentication, pass credentials (ServiceAccountCredentials object)
|
|
151
|
+
# and type (:import) as keyword arguments.
|
|
152
|
+
# The positional parameters are preserved for backward compatibility with custom Consumer subclasses.
|
|
153
|
+
def request(endpoint, form_data, credentials: nil, type: nil)
|
|
127
154
|
uri = URI(endpoint)
|
|
155
|
+
|
|
156
|
+
# Add project_id as query parameter for import endpoint with service account credentials
|
|
157
|
+
if credentials && type == :import
|
|
158
|
+
unless credentials.is_a?(ServiceAccountCredentials)
|
|
159
|
+
raise ArgumentError, "credentials must be ServiceAccountCredentials, got #{credentials.class}"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
query_params = URI.decode_www_form(uri.query || '').to_h
|
|
163
|
+
query_params['project_id'] = credentials.project_id
|
|
164
|
+
uri.query = URI.encode_www_form(query_params)
|
|
165
|
+
end
|
|
166
|
+
|
|
128
167
|
request = Net::HTTP::Post.new(uri.request_uri)
|
|
129
168
|
request.set_form_data(form_data)
|
|
130
169
|
|
|
170
|
+
# Use Basic Auth with service account credentials for import endpoint
|
|
171
|
+
if credentials && type == :import
|
|
172
|
+
request.basic_auth(credentials.username, credentials.secret)
|
|
173
|
+
end
|
|
174
|
+
|
|
131
175
|
client = Net::HTTP.new(uri.host, uri.port)
|
|
132
176
|
client.use_ssl = true
|
|
133
177
|
client.open_timeout = 10
|
|
@@ -182,17 +226,21 @@ module Mixpanel
|
|
|
182
226
|
# to the Mixpanel::Tracker constructor. If a block is passed to
|
|
183
227
|
# the constructor, the *_endpoint constructor arguments are
|
|
184
228
|
# ignored.
|
|
185
|
-
|
|
229
|
+
#
|
|
230
|
+
# @param credentials [ServiceAccountCredentials, nil] Service account credentials for authentication.
|
|
231
|
+
# Credentials are only used for the /import endpoint and feature flags.
|
|
232
|
+
def initialize(events_endpoint=nil, update_endpoint=nil, import_endpoint=nil, max_buffer_length=MAX_LENGTH, credentials: nil, &block)
|
|
186
233
|
@max_length = [max_buffer_length, MAX_LENGTH].min
|
|
187
234
|
@buffers = {
|
|
188
235
|
:event => [],
|
|
189
236
|
:profile_update => [],
|
|
190
237
|
}
|
|
238
|
+
@credentials = credentials
|
|
191
239
|
|
|
192
240
|
if block
|
|
193
241
|
@sink = block
|
|
194
242
|
else
|
|
195
|
-
consumer = Consumer.new(events_endpoint, update_endpoint, import_endpoint)
|
|
243
|
+
consumer = Consumer.new(events_endpoint, update_endpoint, nil, import_endpoint, credentials: credentials)
|
|
196
244
|
@sink = consumer.method(:send!)
|
|
197
245
|
end
|
|
198
246
|
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module Mixpanel
|
|
2
|
+
# Service account credentials for server-to-server authentication
|
|
3
|
+
# This is the recommended authentication method over API keys
|
|
4
|
+
class ServiceAccountCredentials
|
|
5
|
+
attr_reader :username, :secret, :project_id
|
|
6
|
+
|
|
7
|
+
# Create service account credentials
|
|
8
|
+
# @param username [String] Service account username
|
|
9
|
+
# @param secret [String] Service account secret
|
|
10
|
+
# @param project_id [String, Integer] Mixpanel project ID (accepts string or integer)
|
|
11
|
+
def initialize(username, secret, project_id)
|
|
12
|
+
raise ArgumentError, 'username is required' if username.nil? || username.empty?
|
|
13
|
+
raise ArgumentError, 'secret is required' if secret.nil? || secret.empty?
|
|
14
|
+
raise ArgumentError, 'project_id is required' if project_id.nil?
|
|
15
|
+
|
|
16
|
+
# Convert project_id to string if it's an integer (Mixpanel dashboard shows numeric IDs)
|
|
17
|
+
project_id = project_id.to_s if project_id.is_a?(Integer)
|
|
18
|
+
raise ArgumentError, 'project_id is required' if project_id.empty?
|
|
19
|
+
|
|
20
|
+
@username = username
|
|
21
|
+
@secret = secret
|
|
22
|
+
@project_id = project_id
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
end
|
|
26
|
+
end
|
data/lib/mixpanel-ruby/events.rb
CHANGED
|
@@ -22,14 +22,19 @@ module Mixpanel
|
|
|
22
22
|
# # tracker has all of the methods of Mixpanel::Events
|
|
23
23
|
# tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN)
|
|
24
24
|
#
|
|
25
|
-
def initialize(token, error_handler=nil, &block)
|
|
25
|
+
def initialize(token, error_handler=nil, credentials: nil, &block)
|
|
26
26
|
@token = token
|
|
27
27
|
@error_handler = error_handler || ErrorHandler.new
|
|
28
|
+
@credentials = credentials
|
|
29
|
+
|
|
30
|
+
if block && credentials
|
|
31
|
+
warn '[WARNING] credentials passed to Events/Tracker are ignored when a custom sink block is provided. Pass credentials to your consumer directly.'
|
|
32
|
+
end
|
|
28
33
|
|
|
29
34
|
if block
|
|
30
35
|
@sink = block
|
|
31
36
|
else
|
|
32
|
-
consumer = Consumer.new
|
|
37
|
+
consumer = Consumer.new(credentials: credentials)
|
|
33
38
|
@sink = consumer.method(:send!)
|
|
34
39
|
end
|
|
35
40
|
end
|
|
@@ -87,19 +92,66 @@ module Mixpanel
|
|
|
87
92
|
# we pass the time of the method call as the time the event occured, if you
|
|
88
93
|
# wish to override this pass a timestamp in the properties hash.
|
|
89
94
|
#
|
|
90
|
-
#
|
|
91
|
-
#
|
|
92
|
-
#
|
|
93
|
-
# tracker.
|
|
94
|
-
#
|
|
95
|
-
# # Properties describe the circumstances of the event,
|
|
96
|
-
# # or aspects of the source or user associated with the event
|
|
97
|
-
# tracker.import("API_KEY", "12345", "Welcome Email Sent", {
|
|
95
|
+
# # Recommended: Use import_events() instead
|
|
96
|
+
# credentials = Mixpanel::ServiceAccountCredentials.new(username, secret, project_id)
|
|
97
|
+
# tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN, credentials: credentials)
|
|
98
|
+
# tracker.import_events("12345", "Welcome Email Sent", {
|
|
98
99
|
# 'Email Template' => 'Pretty Pink Welcome',
|
|
99
100
|
# 'User Sign-up Cohort' => 'July 2013',
|
|
100
101
|
# 'time' => 1369353600,
|
|
101
102
|
# })
|
|
103
|
+
#
|
|
104
|
+
# # Legacy: Pass API key as first parameter (deprecated)
|
|
105
|
+
# tracker.import("API_KEY", "12345", "Credit Card Declined")
|
|
106
|
+
#
|
|
102
107
|
def import(api_key, distinct_id, event, properties={}, ip=nil)
|
|
108
|
+
# Warn about deprecated API key usage
|
|
109
|
+
if api_key && !api_key.to_s.empty?
|
|
110
|
+
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'
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Warn when using import(nil, ...) - recommend import_events instead
|
|
114
|
+
if api_key.nil? && @credentials
|
|
115
|
+
warn '[DEPRECATION] Using import(nil, ...) is deprecated. Use import_events(...) instead for cleaner API.'
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Delegate to internal implementation
|
|
119
|
+
import_internal(api_key, distinct_id, event, properties, ip)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Import an event using service account credentials from the constructor.
|
|
123
|
+
# This is the recommended method for importing historical events with service accounts.
|
|
124
|
+
#
|
|
125
|
+
# Credentials must be provided in the Tracker/Events constructor. This method is cleaner
|
|
126
|
+
# than import() as it doesn't require passing nil as the first parameter.
|
|
127
|
+
#
|
|
128
|
+
# credentials = Mixpanel::ServiceAccountCredentials.new(username, secret, project_id)
|
|
129
|
+
# tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN, credentials: credentials)
|
|
130
|
+
#
|
|
131
|
+
# tracker.import_events('user123', 'Past Event', {
|
|
132
|
+
# 'time' => 1369353600,
|
|
133
|
+
# 'Source' => 'Import'
|
|
134
|
+
# })
|
|
135
|
+
#
|
|
136
|
+
def import_events(distinct_id, event, properties={}, ip=nil)
|
|
137
|
+
unless @credentials
|
|
138
|
+
raise ArgumentError, 'import_events requires credentials in constructor. Use: Tracker.new(token, credentials: credentials)'
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Delegate to internal implementation with nil api_key (uses constructor credentials)
|
|
142
|
+
import_internal(nil, distinct_id, event, properties, ip)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
# Internal implementation for importing events.
|
|
148
|
+
# Called by both import() and import_events().
|
|
149
|
+
def import_internal(api_key, distinct_id, event, properties, ip)
|
|
150
|
+
# Validate that at least one authentication method is provided
|
|
151
|
+
if api_key.nil? && @credentials.nil?
|
|
152
|
+
raise ArgumentError, 'import requires authentication: provide either api_key parameter or credentials in constructor'
|
|
153
|
+
end
|
|
154
|
+
|
|
103
155
|
properties = {
|
|
104
156
|
'distinct_id' => distinct_id,
|
|
105
157
|
'token' => @token,
|
|
@@ -116,9 +168,15 @@ module Mixpanel
|
|
|
116
168
|
|
|
117
169
|
message = {
|
|
118
170
|
'data' => data,
|
|
119
|
-
'api_key' => api_key,
|
|
120
171
|
}
|
|
121
172
|
|
|
173
|
+
# Only include api_key in message if provided (legacy auth)
|
|
174
|
+
# When using service account credentials (recommended), pass nil for api_key
|
|
175
|
+
# and the Consumer will use credentials from its instance variable
|
|
176
|
+
if api_key
|
|
177
|
+
message['api_key'] = api_key
|
|
178
|
+
end
|
|
179
|
+
|
|
122
180
|
ret = true
|
|
123
181
|
begin
|
|
124
182
|
@sink.call(:import, message.to_json)
|
|
@@ -12,7 +12,7 @@ module Mixpanel
|
|
|
12
12
|
# Base class for feature flags providers
|
|
13
13
|
# Provides common HTTP handling and exposure event tracking
|
|
14
14
|
class FlagsProvider
|
|
15
|
-
# @param provider_config [Hash] Configuration with :token, :api_host, :request_timeout_in_seconds
|
|
15
|
+
# @param provider_config [Hash] Configuration with :token, :api_host, :request_timeout_in_seconds, :credentials (optional)
|
|
16
16
|
# @param endpoint [String] API endpoint path (e.g., '/flags' or '/flags/definitions')
|
|
17
17
|
# @param tracker_callback [Proc] Function used to track events (bound tracker.track method)
|
|
18
18
|
# @param evaluation_mode [String] The feature flag evaluation mode. This is either 'local' or 'remote'
|
|
@@ -23,6 +23,7 @@ module Mixpanel
|
|
|
23
23
|
@tracker_callback = tracker_callback
|
|
24
24
|
@evaluation_mode = evaluation_mode
|
|
25
25
|
@error_handler = error_handler
|
|
26
|
+
@credentials = provider_config[:credentials]
|
|
26
27
|
end
|
|
27
28
|
|
|
28
29
|
# Make HTTP request to flags API endpoint
|
|
@@ -31,12 +32,20 @@ module Mixpanel
|
|
|
31
32
|
# @raise [Mixpanel::ConnectionError] on network errors
|
|
32
33
|
# @raise [Mixpanel::ServerError] on HTTP errors
|
|
33
34
|
def call_flags_endpoint(additional_params = nil)
|
|
35
|
+
# Always use token in query params
|
|
34
36
|
common_params = Utils.prepare_common_query_params(
|
|
35
37
|
@provider_config[:token],
|
|
36
38
|
Mixpanel::VERSION
|
|
37
39
|
)
|
|
38
40
|
|
|
39
41
|
params = common_params.merge(additional_params || {})
|
|
42
|
+
|
|
43
|
+
# Add project_id as query parameter when using service account credentials
|
|
44
|
+
# Note: project_id is required for service account auth but not used for token auth
|
|
45
|
+
if @credentials
|
|
46
|
+
params['project_id'] = @credentials.project_id
|
|
47
|
+
end
|
|
48
|
+
|
|
40
49
|
query_string = URI.encode_www_form(params)
|
|
41
50
|
|
|
42
51
|
uri = URI::HTTPS.build(
|
|
@@ -53,7 +62,12 @@ module Mixpanel
|
|
|
53
62
|
|
|
54
63
|
request = Net::HTTP::Get.new(uri.request_uri)
|
|
55
64
|
|
|
56
|
-
|
|
65
|
+
# Use service account credentials for basic auth if provided, otherwise use token
|
|
66
|
+
if @credentials
|
|
67
|
+
request.basic_auth(@credentials.username, @credentials.secret)
|
|
68
|
+
else
|
|
69
|
+
request.basic_auth(@provider_config[:token], '')
|
|
70
|
+
end
|
|
57
71
|
|
|
58
72
|
request['Content-Type'] = 'application/json'
|
|
59
73
|
request['traceparent'] = Utils.generate_traceparent
|
|
@@ -18,13 +18,26 @@ module Mixpanel
|
|
|
18
18
|
# @param config [Hash] Local flags configuration
|
|
19
19
|
# @param tracker_callback [Proc] Callback to track events
|
|
20
20
|
# @param error_handler [Mixpanel::ErrorHandler] Error handler
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
# @param credentials [ServiceAccountCredentials, nil] Optional service account credentials
|
|
22
|
+
def initialize(token, config, tracker_callback, error_handler, credentials = nil)
|
|
23
|
+
# compact: an explicit nil from the caller (e.g.
|
|
24
|
+
# polling_interval_in_seconds: nil) must not override a sane default.
|
|
25
|
+
# Both the previous sleep(nil) and the current
|
|
26
|
+
# ConditionVariable#wait(mutex, nil) block indefinitely, which would
|
|
27
|
+
# silently disable polling — fall back to the default instead.
|
|
28
|
+
@config = DEFAULT_CONFIG.merge((config || {}).compact)
|
|
29
|
+
|
|
30
|
+
interval = @config[:polling_interval_in_seconds]
|
|
31
|
+
unless interval.is_a?(Numeric) && interval > 0
|
|
32
|
+
raise ArgumentError,
|
|
33
|
+
"polling_interval_in_seconds must be a positive number, got: #{interval.inspect}"
|
|
34
|
+
end
|
|
23
35
|
|
|
24
36
|
provider_config = {
|
|
25
37
|
token: token,
|
|
26
38
|
api_host: @config[:api_host],
|
|
27
|
-
request_timeout_in_seconds: @config[:request_timeout_in_seconds]
|
|
39
|
+
request_timeout_in_seconds: @config[:request_timeout_in_seconds],
|
|
40
|
+
credentials: credentials
|
|
28
41
|
}
|
|
29
42
|
|
|
30
43
|
super(provider_config, '/flags/definitions', tracker_callback, 'local', error_handler)
|
|
@@ -32,36 +45,80 @@ module Mixpanel
|
|
|
32
45
|
@flag_definitions = {}
|
|
33
46
|
@polling_thread = nil
|
|
34
47
|
@stop_polling = false
|
|
48
|
+
@polling_mutex = Mutex.new
|
|
49
|
+
@polling_condition = ConditionVariable.new
|
|
50
|
+
# Separate from @polling_mutex: serializes the full start/stop lifecycle
|
|
51
|
+
# (including the join inside stop) so a concurrent start can't observe a
|
|
52
|
+
# mid-stop state and spawn a new polling thread before the old one
|
|
53
|
+
# finishes exiting. We can't hold @polling_mutex across the join — the
|
|
54
|
+
# polling thread needs it to wake from the condition wait.
|
|
55
|
+
@lifecycle_mutex = Mutex.new
|
|
35
56
|
end
|
|
36
57
|
|
|
37
58
|
# Start polling for flag definitions
|
|
38
59
|
# Fetches immediately, then at regular intervals if polling enabled
|
|
39
60
|
def start_polling_for_definitions!
|
|
61
|
+
# Clear @stop_polling BEFORE the initial fetch so a concurrent
|
|
62
|
+
# stop_polling_for_definitions! arriving during the fetch can flip it
|
|
63
|
+
# back to true and signal "abandon start". Under @polling_mutex, writes
|
|
64
|
+
# to @stop_polling are linearized: whichever of {start's clear, stop's
|
|
65
|
+
# set} happened latest is what we observe under the lifecycle lock
|
|
66
|
+
# below — i.e. last-writer-wins semantics preserve stop's postcondition
|
|
67
|
+
# (polling is off when stop returns) even across the unguarded fetch.
|
|
68
|
+
@polling_mutex.synchronize { @stop_polling = false }
|
|
69
|
+
|
|
70
|
+
# Initial fetch is intentionally outside @lifecycle_mutex: it's a
|
|
71
|
+
# blocking HTTP call, and holding the lifecycle lock across it would
|
|
72
|
+
# block a concurrent stop_polling_for_definitions! for the full request
|
|
73
|
+
# timeout.
|
|
40
74
|
fetch_flag_definitions
|
|
41
75
|
|
|
42
|
-
|
|
43
|
-
@stop_polling
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
76
|
+
@lifecycle_mutex.synchronize do
|
|
77
|
+
# If a stop arrived during/after our @stop_polling clear above, abort
|
|
78
|
+
# — the user's stop "wins" because it's the most recent intent.
|
|
79
|
+
stop_requested = @polling_mutex.synchronize { @stop_polling }
|
|
80
|
+
|
|
81
|
+
# .alive? guards against a prior polling thread that died abnormally
|
|
82
|
+
# (e.g. error_handler itself raised); without it @polling_thread would
|
|
83
|
+
# be non-nil-but-dead and we'd silently refuse to restart polling.
|
|
84
|
+
if !stop_requested && @config[:enable_polling] &&
|
|
85
|
+
(@polling_thread.nil? || !@polling_thread.alive?)
|
|
86
|
+
@polling_thread = Thread.new do
|
|
87
|
+
loop do
|
|
88
|
+
# Check @stop_polling INSIDE the mutex (before and after wait)
|
|
89
|
+
# so a broadcast from stop_polling_for_definitions! can't be
|
|
90
|
+
# lost if it arrives while we're outside the synchronized region
|
|
91
|
+
# (e.g. during fetch_flag_definitions below).
|
|
92
|
+
stopped = @polling_mutex.synchronize do
|
|
93
|
+
next true if @stop_polling
|
|
94
|
+
|
|
95
|
+
@polling_condition.wait(@polling_mutex, @config[:polling_interval_in_seconds])
|
|
96
|
+
@stop_polling
|
|
97
|
+
end
|
|
98
|
+
break if stopped
|
|
99
|
+
|
|
100
|
+
begin
|
|
101
|
+
fetch_flag_definitions
|
|
102
|
+
rescue StandardError => e
|
|
103
|
+
safe_handle_error(e)
|
|
104
|
+
end
|
|
53
105
|
end
|
|
54
106
|
end
|
|
55
107
|
end
|
|
56
108
|
end
|
|
57
109
|
rescue StandardError => e
|
|
58
|
-
|
|
110
|
+
safe_handle_error(e)
|
|
59
111
|
end
|
|
60
112
|
|
|
61
113
|
def stop_polling_for_definitions!
|
|
62
|
-
@
|
|
63
|
-
|
|
64
|
-
|
|
114
|
+
@lifecycle_mutex.synchronize do
|
|
115
|
+
@polling_mutex.synchronize do
|
|
116
|
+
@stop_polling = true
|
|
117
|
+
@polling_condition.broadcast
|
|
118
|
+
end
|
|
119
|
+
@polling_thread&.join
|
|
120
|
+
@polling_thread = nil
|
|
121
|
+
end
|
|
65
122
|
end
|
|
66
123
|
|
|
67
124
|
def shutdown
|
|
@@ -141,6 +198,16 @@ module Mixpanel
|
|
|
141
198
|
|
|
142
199
|
private
|
|
143
200
|
|
|
201
|
+
# Wrap @error_handler.handle so a misbehaving handler can't kill the
|
|
202
|
+
# polling thread mid-loop — that would leave @polling_thread non-nil but
|
|
203
|
+
# dead, and (without the .alive? check in start) silently prevent restart.
|
|
204
|
+
def safe_handle_error(error)
|
|
205
|
+
@error_handler.handle(error) if @error_handler
|
|
206
|
+
rescue StandardError
|
|
207
|
+
# Swallow: keeping the polling loop alive is more important than
|
|
208
|
+
# propagating a broken handler's failure.
|
|
209
|
+
end
|
|
210
|
+
|
|
144
211
|
def fetch_flag_definitions
|
|
145
212
|
response = call_flags_endpoint
|
|
146
213
|
|
|
@@ -14,13 +14,15 @@ module Mixpanel
|
|
|
14
14
|
# @param config [Hash] Remote flags configuration
|
|
15
15
|
# @param tracker_callback [Proc] Callback to track events
|
|
16
16
|
# @param error_handler [Mixpanel::ErrorHandler] Error handler
|
|
17
|
-
|
|
17
|
+
# @param credentials [ServiceAccountCredentials, nil] Optional service account credentials
|
|
18
|
+
def initialize(token, config, tracker_callback, error_handler, credentials = nil)
|
|
18
19
|
merged_config = DEFAULT_CONFIG.merge(config || {})
|
|
19
20
|
|
|
20
21
|
provider_config = {
|
|
21
22
|
token: token,
|
|
22
23
|
api_host: merged_config[:api_host],
|
|
23
|
-
request_timeout_in_seconds: merged_config[:request_timeout_in_seconds]
|
|
24
|
+
request_timeout_in_seconds: merged_config[:request_timeout_in_seconds],
|
|
25
|
+
credentials: credentials
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
super(provider_config, '/flags', tracker_callback, 'remote', error_handler)
|
|
@@ -62,9 +62,14 @@ module Mixpanel
|
|
|
62
62
|
# If a block is provided, it is passed a type (one of :event or :profile_update)
|
|
63
63
|
# and a string message. This same format is accepted by Mixpanel::Consumer#send!
|
|
64
64
|
# and Mixpanel::BufferedConsumer#send!
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
65
|
+
#
|
|
66
|
+
# Optional parameters:
|
|
67
|
+
# - credentials: ServiceAccountCredentials for authentication (used for import and feature flags)
|
|
68
|
+
# - local_flags_config: Configuration hash for local feature flags
|
|
69
|
+
# - remote_flags_config: Configuration hash for remote feature flags
|
|
70
|
+
def initialize(token, error_handler=nil, credentials: nil, local_flags_config: nil, remote_flags_config: nil, &block)
|
|
71
|
+
super(token, error_handler, credentials: credentials, &block)
|
|
72
|
+
|
|
68
73
|
@people = People.new(token, error_handler, &block)
|
|
69
74
|
@groups = Groups.new(token, error_handler, &block)
|
|
70
75
|
|
|
@@ -74,7 +79,8 @@ module Mixpanel
|
|
|
74
79
|
token,
|
|
75
80
|
local_flags_config,
|
|
76
81
|
method(:track), # Pass bound method as callback
|
|
77
|
-
error_handler || ErrorHandler.new
|
|
82
|
+
error_handler || ErrorHandler.new,
|
|
83
|
+
credentials
|
|
78
84
|
)
|
|
79
85
|
end
|
|
80
86
|
|
|
@@ -84,7 +90,8 @@ module Mixpanel
|
|
|
84
90
|
token,
|
|
85
91
|
remote_flags_config,
|
|
86
92
|
method(:track), # Pass bound method as callback
|
|
87
|
-
error_handler || ErrorHandler.new
|
|
93
|
+
error_handler || ErrorHandler.new,
|
|
94
|
+
credentials
|
|
88
95
|
)
|
|
89
96
|
end
|
|
90
97
|
end
|
|
@@ -122,14 +129,15 @@ module Mixpanel
|
|
|
122
129
|
#
|
|
123
130
|
# tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN)
|
|
124
131
|
#
|
|
125
|
-
# #
|
|
132
|
+
# # Using deprecated API key (still supported)
|
|
126
133
|
# tracker.import("API_KEY", "12345", "Credit Card Declined", {
|
|
127
134
|
# 'time' => 1310111365
|
|
128
135
|
# })
|
|
129
136
|
#
|
|
130
|
-
# #
|
|
131
|
-
#
|
|
132
|
-
# tracker.
|
|
137
|
+
# # Using service account credentials (recommended)
|
|
138
|
+
# credentials = Mixpanel::ServiceAccountCredentials.new(username, secret, project_id)
|
|
139
|
+
# tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN, credentials: credentials)
|
|
140
|
+
# tracker.import(nil, "12345", "Welcome Email Sent", {
|
|
133
141
|
# 'Email Template' => 'Pretty Pink Welcome',
|
|
134
142
|
# 'User Sign-up Cohort' => 'July 2013',
|
|
135
143
|
# 'time' => 1310111365
|
|
@@ -140,6 +148,28 @@ module Mixpanel
|
|
|
140
148
|
super
|
|
141
149
|
end
|
|
142
150
|
|
|
151
|
+
# Import an event using service account credentials from the constructor.
|
|
152
|
+
# This is the recommended method for importing historical events with service accounts.
|
|
153
|
+
#
|
|
154
|
+
# Service account credentials must be provided when creating the Tracker.
|
|
155
|
+
# This method provides a cleaner API than import() as it doesn't require
|
|
156
|
+
# passing nil as the first parameter.
|
|
157
|
+
#
|
|
158
|
+
# credentials = Mixpanel::ServiceAccountCredentials.new(username, secret, project_id)
|
|
159
|
+
# tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN, credentials: credentials)
|
|
160
|
+
#
|
|
161
|
+
# # Import a historical event
|
|
162
|
+
# tracker.import_events('user123', 'Past Event', {
|
|
163
|
+
# 'Email Template' => 'Welcome Email',
|
|
164
|
+
# 'time' => 1369353600
|
|
165
|
+
# })
|
|
166
|
+
#
|
|
167
|
+
def import_events(distinct_id, event, properties={}, ip=nil)
|
|
168
|
+
# This is here strictly to allow rdoc to include the relevant
|
|
169
|
+
# documentation
|
|
170
|
+
super
|
|
171
|
+
end
|
|
172
|
+
|
|
143
173
|
# Creates a distinct_id alias. \Events and updates with an alias
|
|
144
174
|
# will be considered by mixpanel to have the same source, and
|
|
145
175
|
# refer to the same profile.
|
data/lib/mixpanel-ruby.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
require 'mixpanel-ruby/consumer.rb'
|
|
2
2
|
require 'mixpanel-ruby/tracker.rb'
|
|
3
3
|
require 'mixpanel-ruby/version.rb'
|
|
4
|
+
require 'mixpanel-ruby/credentials.rb'
|
|
4
5
|
require 'mixpanel-ruby/flags/utils.rb'
|
|
5
6
|
require 'mixpanel-ruby/flags/types.rb'
|
|
6
7
|
require 'mixpanel-ruby/flags/flags_provider.rb'
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# mixpanel-ruby-openfeature
|
|
2
2
|
|
|
3
|
+
##### _May 13, 2026_ - [openfeature/v0.1.0](https://github.com/mixpanel/mixpanel-ruby/releases/tag/openfeature/v0.1.0)
|
|
4
|
+
|
|
3
5
|
[](https://rubygems.org/gems/mixpanel-ruby-openfeature)
|
|
4
6
|
[](https://openfeature.dev/)
|
|
5
7
|
[](https://github.com/mixpanel/mixpanel-ruby/blob/master/LICENSE)
|