mixpanel-ruby 3.0.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.
Files changed (37) 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/.github/workflows/ruby.yml +31 -2
  9. data/CHANGELOG.md +30 -0
  10. data/Readme.rdoc +65 -0
  11. data/lib/mixpanel-ruby/consumer.rb +54 -6
  12. data/lib/mixpanel-ruby/credentials.rb +26 -0
  13. data/lib/mixpanel-ruby/events.rb +69 -11
  14. data/lib/mixpanel-ruby/flags/flags_provider.rb +28 -10
  15. data/lib/mixpanel-ruby/flags/local_flags_provider.rb +104 -40
  16. data/lib/mixpanel-ruby/flags/remote_flags_provider.rb +4 -2
  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/Gemfile +7 -0
  22. data/openfeature-provider/README.md +288 -0
  23. data/openfeature-provider/RELEASE.md +52 -0
  24. data/openfeature-provider/lib/mixpanel/openfeature/provider.rb +170 -0
  25. data/openfeature-provider/lib/mixpanel/openfeature/version.rb +7 -0
  26. data/openfeature-provider/lib/mixpanel/openfeature.rb +4 -0
  27. data/openfeature-provider/mixpanel-ruby-openfeature.gemspec +25 -0
  28. data/openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb +606 -0
  29. data/openfeature-provider/spec/spec_helper.rb +23 -0
  30. data/spec/mixpanel-ruby/consumer_spec.rb +81 -0
  31. data/spec/mixpanel-ruby/credentials_security_spec.rb +108 -0
  32. data/spec/mixpanel-ruby/credentials_spec.rb +54 -0
  33. data/spec/mixpanel-ruby/events_spec.rb +152 -0
  34. data/spec/mixpanel-ruby/flags/local_flags_spec.rb +209 -2
  35. data/spec/mixpanel-ruby/flags/remote_flags_spec.rb +36 -0
  36. data/spec/mixpanel-ruby/tracker_spec.rb +18 -0
  37. metadata +23 -3
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
- 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, :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,28 +62,37 @@ module Mixpanel
53
62
 
54
63
  request = Net::HTTP::Get.new(uri.request_uri)
55
64
 
56
- request.basic_auth(@provider_config[:token], '')
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
- request['traceparent'] = Utils.generate_traceparent()
73
+ request['traceparent'] = Utils.generate_traceparent
60
74
 
61
75
  begin
62
76
  response = http.request(request)
77
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
78
+ raise ConnectionError.new("Request timeout: #{e.message}")
79
+ rescue StandardError => e
80
+ raise ConnectionError.new("Network error: #{e.message}")
81
+ end
63
82
 
64
- unless response.code.to_i == 200
65
- raise ServerError.new("HTTP #{response.code}: #{response.body}")
66
- end
83
+ unless response.code == '200'
84
+ raise ServerError.new("HTTP #{response.code}: #{response.body}")
85
+ end
67
86
 
87
+ begin
68
88
  JSON.parse(response.body)
69
- rescue Net::OpenTimeout, Net::ReadTimeout => e
70
- raise ConnectionError.new("Request timeout: #{e.message}")
71
89
  rescue JSON::ParserError => e
72
90
  raise ServerError.new("Invalid JSON response: #{e.message}")
73
- rescue StandardError => e
74
- raise ConnectionError.new("Network error: #{e.message}")
75
91
  end
76
92
  end
77
93
 
94
+ def shutdown; end
95
+
78
96
  # Track exposure event to Mixpanel
79
97
  # @param flag_key [String] Feature flag key
80
98
  # @param selected_variant [SelectedVariant] The selected variant
@@ -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
- def initialize(token, config, tracker_callback, error_handler)
22
- @config = DEFAULT_CONFIG.merge(config || {})
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,84 @@ 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
- 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
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
- @error_handler.handle(e) if @error_handler
110
+ safe_handle_error(e)
59
111
  end
60
112
 
61
113
  def stop_polling_for_definitions!
62
- @stop_polling = true
63
- @polling_thread&.join
64
- @polling_thread = nil
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
122
+ end
123
+
124
+ def shutdown
125
+ stop_polling_for_definitions!
65
126
  end
66
127
 
67
128
  # Check if flag is enabled (for boolean flags)
@@ -107,24 +168,17 @@ module Mixpanel
107
168
 
108
169
  context_value = context[context_key] || context[context_key.to_sym]
109
170
 
110
- selected_variant = nil
171
+ selected_variant = get_variant_override_for_test_user(flag, context)
111
172
 
112
- test_variant = get_variant_override_for_test_user(flag, context)
113
- if test_variant
114
- selected_variant = test_variant
115
- else
173
+ unless selected_variant
116
174
  rollout = get_assigned_rollout(flag, context_value, context)
117
- if rollout
118
- selected_variant = get_assigned_variant(flag, context_value, flag_key, rollout)
119
- end
175
+ selected_variant = get_assigned_variant(flag, context_value, flag_key, rollout) if rollout
120
176
  end
121
177
 
122
- if selected_variant
123
- track_exposure_event(flag_key, selected_variant, context) if report_exposure
124
- return selected_variant
125
- end
178
+ return fallback_variant unless selected_variant
126
179
 
127
- fallback_variant
180
+ track_exposure_event(flag_key, selected_variant, context) if report_exposure
181
+ selected_variant
128
182
  end
129
183
 
130
184
  # Get all variants for user context
@@ -144,14 +198,22 @@ module Mixpanel
144
198
 
145
199
  private
146
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
+
147
211
  def fetch_flag_definitions
148
212
  response = call_flags_endpoint
149
213
 
150
- new_definitions = {}
151
- (response['flags'] || []).each do |flag_data|
152
- new_definitions[flag_data['key']] = flag_data
214
+ new_definitions = (response['flags'] || []).each_with_object({}) do |flag_data, definitions|
215
+ definitions[flag_data['key']] = flag_data
153
216
  end
154
-
155
217
  @flag_definitions = new_definitions
156
218
 
157
219
  response
@@ -175,9 +237,10 @@ module Mixpanel
175
237
  end
176
238
 
177
239
  def get_matching_variant(variant_key, flag)
178
- return nil unless flag['ruleset'] && flag['ruleset']['variants']
240
+ variants = flag.dig('ruleset', 'variants')
241
+ return nil unless variants
179
242
 
180
- flag['ruleset']['variants'].each do |v|
243
+ variants.each do |v|
181
244
  if variant_key.downcase == v['key'].downcase
182
245
  return SelectedVariant.new(
183
246
  variant_key: v['key'],
@@ -191,9 +254,10 @@ module Mixpanel
191
254
  end
192
255
 
193
256
  def get_assigned_rollout(flag, context_value, context)
194
- return nil unless flag['ruleset'] && flag['ruleset']['rollout']
257
+ rollouts = flag.dig('ruleset', 'rollout')
258
+ return nil unless rollouts
195
259
 
196
- flag['ruleset']['rollout'].each_with_index do |rollout, index|
260
+ rollouts.each_with_index do |rollout, index|
197
261
  salt = if flag['hash_salt']
198
262
  "#{flag['key']}#{flag['hash_salt']}#{index}"
199
263
  else
@@ -224,7 +288,7 @@ module Mixpanel
224
288
  salt = "#{flag_key}#{stored_salt}variant"
225
289
  variant_hash = Utils.normalized_hash(context_value.to_s, salt)
226
290
 
227
- variants = flag['ruleset']['variants'].map { |v| v.dup }
291
+ variants = flag['ruleset']['variants'].map(&:dup)
228
292
  if rollout['variant_splits']
229
293
  variants.each do |v|
230
294
  v['split'] = rollout['variant_splits'][v['key']] if rollout['variant_splits'].key?(v['key'])
@@ -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
- def initialize(token, config, tracker_callback, error_handler)
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)