mixpanel-ruby 3.1.0 → 3.3.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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/.github/dependabot.yml +14 -0
  3. data/.github/modules.json +18 -0
  4. data/.github/scripts/generate-changelog.sh +87 -0
  5. data/.github/workflows/pr-title-check.yml +51 -0
  6. data/.github/workflows/prepare-release.yml +189 -0
  7. data/.github/workflows/release-rubygems.yml +217 -0
  8. data/CHANGELOG.md +39 -0
  9. data/Readme.rdoc +65 -0
  10. data/lib/mixpanel-ruby/consumer.rb +54 -6
  11. data/lib/mixpanel-ruby/credentials.rb +26 -0
  12. data/lib/mixpanel-ruby/events.rb +69 -11
  13. data/lib/mixpanel-ruby/flags/flags_provider.rb +57 -6
  14. data/lib/mixpanel-ruby/flags/local_flags_provider.rb +115 -27
  15. data/lib/mixpanel-ruby/flags/remote_flags_provider.rb +22 -7
  16. data/lib/mixpanel-ruby/flags/types.rb +88 -9
  17. data/lib/mixpanel-ruby/tracker.rb +39 -9
  18. data/lib/mixpanel-ruby/version.rb +1 -1
  19. data/lib/mixpanel-ruby.rb +1 -0
  20. data/openfeature-provider/CHANGELOG.md +5 -0
  21. data/openfeature-provider/README.md +30 -0
  22. data/openfeature-provider/lib/mixpanel/openfeature/provider.rb +30 -4
  23. data/openfeature-provider/lib/mixpanel/openfeature/version.rb +7 -0
  24. data/openfeature-provider/lib/mixpanel/openfeature.rb +1 -0
  25. data/openfeature-provider/mixpanel-ruby-openfeature.gemspec +3 -1
  26. data/openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb +74 -3
  27. data/spec/mixpanel-ruby/consumer_spec.rb +81 -0
  28. data/spec/mixpanel-ruby/credentials_security_spec.rb +108 -0
  29. data/spec/mixpanel-ruby/credentials_spec.rb +54 -0
  30. data/spec/mixpanel-ruby/events_spec.rb +152 -0
  31. data/spec/mixpanel-ruby/flags/local_flags_spec.rb +430 -2
  32. data/spec/mixpanel-ruby/flags/remote_flags_spec.rb +104 -0
  33. data/spec/mixpanel-ruby/flags/types_spec.rb +55 -0
  34. data/spec/mixpanel-ruby/tracker_spec.rb +18 -0
  35. metadata +15 -2
data/Readme.rdoc CHANGED
@@ -1,5 +1,7 @@
1
1
  = mixpanel-ruby: The official Mixpanel Ruby library
2
2
 
3
+ ##### _July 24, 2026_ - [v3.3.0](https://github.com/mixpanel/mixpanel-ruby/releases/tag/v3.3.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
- form_data.merge!("api_key" => api_key) if api_key
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
- response_code, response_body = request(endpoint, form_data)
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
- def request(endpoint, form_data)
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
- def initialize(events_endpoint=nil, update_endpoint=nil, import_endpoint=nil, max_buffer_length=MAX_LENGTH, &block)
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
@@ -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
- # tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN)
91
- #
92
- # # Track that user "12345"'s credit card was declined
93
- # tracker.import("API_KEY", "12345", "Credit Card Declined")
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, :exposure_executor, :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,8 @@ module Mixpanel
23
23
  @tracker_callback = tracker_callback
24
24
  @evaluation_mode = evaluation_mode
25
25
  @error_handler = error_handler
26
+ @exposure_executor = provider_config[:exposure_executor]
27
+ @credentials = provider_config[:credentials]
26
28
  end
27
29
 
28
30
  # Make HTTP request to flags API endpoint
@@ -31,12 +33,20 @@ module Mixpanel
31
33
  # @raise [Mixpanel::ConnectionError] on network errors
32
34
  # @raise [Mixpanel::ServerError] on HTTP errors
33
35
  def call_flags_endpoint(additional_params = nil)
36
+ # Always use token in query params
34
37
  common_params = Utils.prepare_common_query_params(
35
38
  @provider_config[:token],
36
39
  Mixpanel::VERSION
37
40
  )
38
41
 
39
42
  params = common_params.merge(additional_params || {})
43
+
44
+ # Add project_id as query parameter when using service account credentials
45
+ # Note: project_id is required for service account auth but not used for token auth
46
+ if @credentials
47
+ params['project_id'] = @credentials.project_id
48
+ end
49
+
40
50
  query_string = URI.encode_www_form(params)
41
51
 
42
52
  uri = URI::HTTPS.build(
@@ -53,7 +63,12 @@ module Mixpanel
53
63
 
54
64
  request = Net::HTTP::Get.new(uri.request_uri)
55
65
 
56
- request.basic_auth(@provider_config[:token], '')
66
+ # Use service account credentials for basic auth if provided, otherwise use token
67
+ if @credentials
68
+ request.basic_auth(@credentials.username, @credentials.secret)
69
+ else
70
+ request.basic_auth(@provider_config[:token], '')
71
+ end
57
72
 
58
73
  request['Content-Type'] = 'application/json'
59
74
  request['traceparent'] = Utils.generate_traceparent
@@ -88,6 +103,14 @@ module Mixpanel
88
103
  distinct_id = context['distinct_id'] || context[:distinct_id]
89
104
 
90
105
  unless distinct_id
106
+ # Local eval succeeds when the flag's Variant Assignment Key is
107
+ # something other than distinct_id (e.g., device_id), but the
108
+ # exposure event still needs distinct_id to attribute the user.
109
+ # Surface the drop instead of silently returning so callers can
110
+ # see they need to include distinct_id in the context.
111
+ @error_handler&.handle(MixpanelError.new(
112
+ "Cannot track exposure event for flag '#{flag_key}' without a distinct_id in the context"
113
+ ))
91
114
  return
92
115
  end
93
116
 
@@ -104,12 +127,40 @@ module Mixpanel
104
127
  properties['$is_experiment_active'] = selected_variant.is_experiment_active unless selected_variant.is_experiment_active.nil?
105
128
  properties['$is_qa_tester'] = selected_variant.is_qa_tester unless selected_variant.is_qa_tester.nil?
106
129
 
107
- begin
108
- @tracker_callback.call(distinct_id, Utils::EXPOSURE_EVENT, properties)
109
- rescue MixpanelError => e
110
- @error_handler.handle(e)
130
+ dispatch_exposure(distinct_id, properties)
131
+ end
132
+
133
+ private
134
+
135
+ # Dispatch the tracker call inline or via the configured executor.
136
+ # The executor is duck-typed — anything that responds to #post(&block)
137
+ # works (Concurrent::ExecutorService, or a Thread.new wrapper).
138
+ #
139
+ # Async path only: catch any non-MixpanelError from the tracker so it
140
+ # doesn't terminate the executor thread silently. Inline path
141
+ # preserves the original behavior — non-MixpanelError propagates to
142
+ # the flag evaluator's caller unchanged.
143
+ def dispatch_exposure(distinct_id, properties)
144
+ if @exposure_executor
145
+ begin
146
+ @exposure_executor.post do
147
+ invoke_tracker(distinct_id, properties)
148
+ rescue StandardError => e
149
+ @error_handler.handle(MixpanelError.new("Exposure event failed: #{e.class}: #{e.message}")) if @error_handler
150
+ end
151
+ rescue StandardError => e
152
+ @error_handler.handle(MixpanelError.new("Exposure event dropped — executor refused to accept task: #{e.message}")) if @error_handler
153
+ end
154
+ else
155
+ invoke_tracker(distinct_id, properties)
111
156
  end
112
157
  end
158
+
159
+ def invoke_tracker(distinct_id, properties)
160
+ @tracker_callback.call(distinct_id, Utils::EXPOSURE_EVENT, properties)
161
+ rescue MixpanelError => e
162
+ @error_handler.handle(e) if @error_handler
163
+ end
113
164
  end
114
165
  end
115
166
  end
@@ -11,20 +11,35 @@ module Mixpanel
11
11
  api_host: 'api.mixpanel.com',
12
12
  request_timeout_in_seconds: 10,
13
13
  enable_polling: true,
14
- polling_interval_in_seconds: 60
14
+ polling_interval_in_seconds: 60,
15
+ exposure_executor: nil
15
16
  }.freeze
16
17
 
17
18
  # @param token [String] Mixpanel project token
18
19
  # @param config [Hash] Local flags configuration
19
20
  # @param tracker_callback [Proc] Callback to track events
20
21
  # @param error_handler [Mixpanel::ErrorHandler] Error handler
21
- def initialize(token, config, tracker_callback, error_handler)
22
- @config = DEFAULT_CONFIG.merge(config || {})
22
+ # @param credentials [ServiceAccountCredentials, nil] Optional service account credentials
23
+ def initialize(token, config, tracker_callback, error_handler, credentials = nil)
24
+ # compact: an explicit nil from the caller (e.g.
25
+ # polling_interval_in_seconds: nil) must not override a sane default.
26
+ # Both the previous sleep(nil) and the current
27
+ # ConditionVariable#wait(mutex, nil) block indefinitely, which would
28
+ # silently disable polling — fall back to the default instead.
29
+ @config = DEFAULT_CONFIG.merge((config || {}).compact)
30
+
31
+ interval = @config[:polling_interval_in_seconds]
32
+ unless interval.is_a?(Numeric) && interval > 0
33
+ raise ArgumentError,
34
+ "polling_interval_in_seconds must be a positive number, got: #{interval.inspect}"
35
+ end
23
36
 
24
37
  provider_config = {
25
38
  token: token,
26
39
  api_host: @config[:api_host],
27
- request_timeout_in_seconds: @config[:request_timeout_in_seconds]
40
+ request_timeout_in_seconds: @config[:request_timeout_in_seconds],
41
+ exposure_executor: @config[:exposure_executor],
42
+ credentials: credentials
28
43
  }
29
44
 
30
45
  super(provider_config, '/flags/definitions', tracker_callback, 'local', error_handler)
@@ -32,36 +47,90 @@ module Mixpanel
32
47
  @flag_definitions = {}
33
48
  @polling_thread = nil
34
49
  @stop_polling = false
50
+ @polling_mutex = Mutex.new
51
+ @polling_condition = ConditionVariable.new
52
+ # Separate from @polling_mutex: serializes the full start/stop lifecycle
53
+ # (including the join inside stop) so a concurrent start can't observe a
54
+ # mid-stop state and spawn a new polling thread before the old one
55
+ # finishes exiting. We can't hold @polling_mutex across the join — the
56
+ # polling thread needs it to wake from the condition wait.
57
+ @lifecycle_mutex = Mutex.new
35
58
  end
36
59
 
37
60
  # Start polling for flag definitions
38
61
  # Fetches immediately, then at regular intervals if polling enabled
39
62
  def start_polling_for_definitions!
40
- fetch_flag_definitions
41
-
42
- if @config[:enable_polling] && !@polling_thread
43
- @stop_polling = false
44
- @polling_thread = Thread.new do
45
- loop do
46
- sleep @config[:polling_interval_in_seconds]
47
- break if @stop_polling
48
-
49
- begin
50
- fetch_flag_definitions
51
- rescue StandardError => e
52
- @error_handler.handle(e) if @error_handler
63
+ # Clear @stop_polling BEFORE the initial fetch so a concurrent
64
+ # stop_polling_for_definitions! arriving during the fetch can flip it
65
+ # back to true and signal "abandon start". Under @polling_mutex, writes
66
+ # to @stop_polling are linearized: whichever of {start's clear, stop's
67
+ # set} happened latest is what we observe under the lifecycle lock
68
+ # below — i.e. last-writer-wins semantics preserve stop's postcondition
69
+ # (polling is off when stop returns) even across the unguarded fetch.
70
+ @polling_mutex.synchronize { @stop_polling = false }
71
+
72
+ # Initial fetch is intentionally outside @lifecycle_mutex: it's a
73
+ # blocking HTTP call, and holding the lifecycle lock across it would
74
+ # block a concurrent stop_polling_for_definitions! for the full request
75
+ # timeout.
76
+ #
77
+ # A transient failure here (network blip, HTTP 500) should NOT prevent
78
+ # the polling thread from spawning — the loop retries on the configured
79
+ # interval. Without this inner rescue, the outer rescue below would
80
+ # catch and return, leaving the SDK permanently without polling until
81
+ # the caller manually retried.
82
+ begin
83
+ fetch_flag_definitions
84
+ rescue StandardError => e
85
+ safe_handle_error(e)
86
+ end
87
+
88
+ @lifecycle_mutex.synchronize do
89
+ # If a stop arrived during/after our @stop_polling clear above, abort
90
+ # — the user's stop "wins" because it's the most recent intent.
91
+ stop_requested = @polling_mutex.synchronize { @stop_polling }
92
+
93
+ # .alive? guards against a prior polling thread that died abnormally
94
+ # (e.g. error_handler itself raised); without it @polling_thread would
95
+ # be non-nil-but-dead and we'd silently refuse to restart polling.
96
+ if !stop_requested && @config[:enable_polling] &&
97
+ (@polling_thread.nil? || !@polling_thread.alive?)
98
+ @polling_thread = Thread.new do
99
+ loop do
100
+ # Check @stop_polling INSIDE the mutex (before and after wait)
101
+ # so a broadcast from stop_polling_for_definitions! can't be
102
+ # lost if it arrives while we're outside the synchronized region
103
+ # (e.g. during fetch_flag_definitions below).
104
+ stopped = @polling_mutex.synchronize do
105
+ next true if @stop_polling
106
+
107
+ @polling_condition.wait(@polling_mutex, @config[:polling_interval_in_seconds])
108
+ @stop_polling
109
+ end
110
+ break if stopped
111
+
112
+ begin
113
+ fetch_flag_definitions
114
+ rescue StandardError => e
115
+ safe_handle_error(e)
116
+ end
53
117
  end
54
118
  end
55
119
  end
56
120
  end
57
121
  rescue StandardError => e
58
- @error_handler.handle(e) if @error_handler
122
+ safe_handle_error(e)
59
123
  end
60
124
 
61
125
  def stop_polling_for_definitions!
62
- @stop_polling = true
63
- @polling_thread&.join
64
- @polling_thread = nil
126
+ @lifecycle_mutex.synchronize do
127
+ @polling_mutex.synchronize do
128
+ @stop_polling = true
129
+ @polling_condition.broadcast
130
+ end
131
+ @polling_thread&.join
132
+ @polling_thread = nil
133
+ end
65
134
  end
66
135
 
67
136
  def shutdown
@@ -102,11 +171,11 @@ module Mixpanel
102
171
  def get_variant(flag_key, fallback_variant, context, report_exposure: true)
103
172
  flag = @flag_definitions[flag_key]
104
173
 
105
- return fallback_variant unless flag
174
+ return fallback_variant.as_fallback(FallbackReason.flag_not_found) unless flag
106
175
 
107
176
  context_key = flag['context']
108
177
  unless context.key?(context_key) || context.key?(context_key.to_sym)
109
- return fallback_variant
178
+ return fallback_variant.as_fallback(FallbackReason.missing_context_key(context_key))
110
179
  end
111
180
 
112
181
  context_value = context[context_key] || context[context_key.to_sym]
@@ -118,10 +187,10 @@ module Mixpanel
118
187
  selected_variant = get_assigned_variant(flag, context_value, flag_key, rollout) if rollout
119
188
  end
120
189
 
121
- return fallback_variant unless selected_variant
190
+ return fallback_variant.as_fallback(FallbackReason.no_rollout_match) unless selected_variant
122
191
 
123
192
  track_exposure_event(flag_key, selected_variant, context) if report_exposure
124
- selected_variant
193
+ selected_variant.with_source(VariantSource::LOCAL)
125
194
  end
126
195
 
127
196
  # Get all variants for user context
@@ -130,10 +199,11 @@ module Mixpanel
130
199
  # @return [Hash] Map of flag_key => SelectedVariant
131
200
  def get_all_variants(context)
132
201
  variants = {}
202
+ fallback = SelectedVariant.new(variant_value: nil)
133
203
 
134
204
  @flag_definitions.each_key do |flag_key|
135
- variant = get_variant(flag_key, nil, context, report_exposure: false)
136
- variants[flag_key] = variant if variant
205
+ variant = get_variant(flag_key, fallback, context, report_exposure: false)
206
+ variants[flag_key] = variant if variant.variant_source == VariantSource::LOCAL
137
207
  end
138
208
 
139
209
  variants
@@ -141,6 +211,24 @@ module Mixpanel
141
211
 
142
212
  private
143
213
 
214
+ # Wrap @error_handler.handle so a misbehaving handler can't kill the
215
+ # polling thread mid-loop — that would leave @polling_thread non-nil but
216
+ # dead, and (without the .alive? check in start) silently prevent restart.
217
+ #
218
+ # Always warn to stderr as well. The default Mixpanel::ErrorHandler#handle
219
+ # is a no-op, so dispatching only via @error_handler swallows schema drift
220
+ # (NoMethodError, JSON::ParserError, etc.) — the loop runs forever
221
+ # undetected. Matches the convention in mixpanel-python / mixpanel-java /
222
+ # mixpanel-go / mixpanel-node, all of which log unconditionally and keep
223
+ # polling.
224
+ def safe_handle_error(error)
225
+ warn "[Mixpanel] Failed to fetch flag definitions: #{error.class}: #{error.message}"
226
+ @error_handler.handle(error) if @error_handler
227
+ rescue StandardError
228
+ # Swallow handler failures: keeping the polling loop alive is more
229
+ # important than propagating a broken handler's failure.
230
+ end
231
+
144
232
  def fetch_flag_definitions
145
233
  response = call_flags_endpoint
146
234