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
@@ -7,20 +7,24 @@ module Mixpanel
7
7
  class RemoteFlagsProvider < FlagsProvider
8
8
  DEFAULT_CONFIG = {
9
9
  api_host: 'api.mixpanel.com',
10
- request_timeout_in_seconds: 10
10
+ request_timeout_in_seconds: 10,
11
+ exposure_executor: nil
11
12
  }.freeze
12
13
 
13
14
  # @param token [String] Mixpanel project token
14
15
  # @param config [Hash] Remote flags configuration
15
16
  # @param tracker_callback [Proc] Callback to track events
16
17
  # @param error_handler [Mixpanel::ErrorHandler] Error handler
17
- def initialize(token, config, tracker_callback, error_handler)
18
+ # @param credentials [ServiceAccountCredentials, nil] Optional service account credentials
19
+ def initialize(token, config, tracker_callback, error_handler, credentials = nil)
18
20
  merged_config = DEFAULT_CONFIG.merge(config || {})
19
21
 
20
22
  provider_config = {
21
23
  token: token,
22
24
  api_host: merged_config[:api_host],
23
- request_timeout_in_seconds: merged_config[:request_timeout_in_seconds]
25
+ request_timeout_in_seconds: merged_config[:request_timeout_in_seconds],
26
+ exposure_executor: merged_config[:exposure_executor],
27
+ credentials: credentials
24
28
  }
25
29
 
26
30
  super(provider_config, '/flags', tracker_callback, 'remote', error_handler)
@@ -59,13 +63,18 @@ module Mixpanel
59
63
  flags = response['flags'] || {}
60
64
  selected_variant_data = flags[flag_key]
61
65
 
62
- return fallback_variant unless selected_variant_data
66
+ # The /flags endpoint only returns variants the user is enrolled in,
67
+ # so a missing key could mean the flag doesn't exist OR the user
68
+ # isn't in any rollout. The remote SDK can't tell them apart without
69
+ # server-side help — surface as FLAG_NOT_FOUND for now.
70
+ return fallback_variant.as_fallback(FallbackReason.flag_not_found) unless selected_variant_data
63
71
 
64
72
  selected_variant = SelectedVariant.new(
65
73
  variant_key: selected_variant_data['variant_key'],
66
74
  variant_value: selected_variant_data['variant_value'],
67
75
  experiment_id: selected_variant_data['experiment_id'],
68
- is_experiment_active: selected_variant_data['is_experiment_active']
76
+ is_experiment_active: selected_variant_data['is_experiment_active'],
77
+ variant_source: VariantSource::REMOTE
69
78
  )
70
79
 
71
80
  track_exposure_event(flag_key, selected_variant, context, latency_ms) if report_exposure
@@ -73,7 +82,12 @@ module Mixpanel
73
82
  return selected_variant
74
83
  rescue MixpanelError => e
75
84
  @error_handler.handle(e)
76
- return fallback_variant
85
+ # Attach the backend's message so the OpenFeature wrapper can forward
86
+ # it into ResolutionDetails#error_message — without this the caller
87
+ # sees a bare GENERAL error and has to dig through logs to find out
88
+ # the backend rejected the request (e.g. "distinct_id must be
89
+ # provided in evalContext as a string"). SDK-83.
90
+ return fallback_variant.as_fallback(FallbackReason.backend_error(e.message))
77
91
  end
78
92
 
79
93
  # Check if flag is enabled (for boolean flags)
@@ -104,7 +118,8 @@ module Mixpanel
104
118
  variant_key: variant_data['variant_key'],
105
119
  variant_value: variant_data['variant_value'],
106
120
  experiment_id: variant_data['experiment_id'],
107
- is_experiment_active: variant_data['is_experiment_active']
121
+ is_experiment_active: variant_data['is_experiment_active'],
122
+ variant_source: VariantSource::REMOTE
108
123
  )
109
124
  end
110
125
 
@@ -1,35 +1,114 @@
1
1
  module Mixpanel
2
2
  module Flags
3
+ # Where a SelectedVariant came from. Set by the providers on every returned
4
+ # variant — coarse-grained (local / remote / fallback). For the specific
5
+ # reason behind a fallback, see {FallbackReason}.
6
+ module VariantSource
7
+ LOCAL = 'local'.freeze
8
+ REMOTE = 'remote'.freeze
9
+ FALLBACK = 'fallback'.freeze
10
+ end
11
+
12
+ # Why the SDK returned the developer fallback. Only meaningful when
13
+ # SelectedVariant#variant_source == VariantSource::FALLBACK.
14
+ #
15
+ # `kind` is the discriminator (matches the PHP constant set). `message`
16
+ # is set on the reasons that carry useful detail (BACKEND_ERROR with the
17
+ # backend's response, MISSING_CONTEXT_KEY with the missing attribute);
18
+ # nil otherwise. The OpenFeature wrapper dispatches on kind and forwards
19
+ # message into ResolutionDetails#error_message.
20
+ class FallbackReason
21
+ KINDS = %i[flag_not_found missing_context_key no_rollout_match backend_error].freeze
22
+
23
+ attr_reader :kind, :message
24
+
25
+ def initialize(kind, message: nil)
26
+ raise ArgumentError, "Unknown FallbackReason kind: #{kind.inspect}" unless KINDS.include?(kind)
27
+
28
+ @kind = kind
29
+ @message = message
30
+ freeze
31
+ end
32
+
33
+ def ==(other)
34
+ other.is_a?(FallbackReason) && other.kind == @kind && other.message == @message
35
+ end
36
+ alias_method :eql?, :==
37
+
38
+ def hash
39
+ [self.class, @kind, @message].hash
40
+ end
41
+
42
+ def to_h
43
+ { kind: @kind, message: @message }.compact
44
+ end
45
+
46
+ # Factory methods. Reasons without meaningful detail return a frozen
47
+ # singleton; reasons with detail allocate per call.
48
+ def self.flag_not_found; FLAG_NOT_FOUND; end
49
+ def self.no_rollout_match; NO_ROLLOUT_MATCH; end
50
+ def self.missing_context_key(key = nil); new(:missing_context_key, message: key); end
51
+ def self.backend_error(message); new(:backend_error, message: message); end
52
+
53
+ FLAG_NOT_FOUND = new(:flag_not_found)
54
+ NO_ROLLOUT_MATCH = new(:no_rollout_match)
55
+ end
56
+
3
57
  # Selected variant returned from flag evaluation
4
58
  class SelectedVariant
5
59
  attr_accessor :variant_key, :variant_value, :experiment_id,
6
- :is_experiment_active, :is_qa_tester
60
+ :is_experiment_active, :is_qa_tester,
61
+ :variant_source, :fallback_reason
7
62
 
8
- # @param variant_key [String, nil] The variant key
9
- # @param variant_value [Object] The variant value (any type)
10
- # @param experiment_id [String, nil] Associated experiment ID
11
- # @param is_experiment_active [Boolean, nil] Whether experiment is active
12
- # @param is_qa_tester [Boolean, nil] Whether user is a QA tester
13
63
  def initialize(variant_key: nil, variant_value: nil, experiment_id: nil,
14
- is_experiment_active: nil, is_qa_tester: nil)
64
+ is_experiment_active: nil, is_qa_tester: nil,
65
+ variant_source: nil, fallback_reason: nil)
15
66
  @variant_key = variant_key
16
67
  @variant_value = variant_value
17
68
  @experiment_id = experiment_id
18
69
  @is_experiment_active = is_experiment_active
19
70
  @is_qa_tester = is_qa_tester
71
+ @variant_source = variant_source
72
+ @fallback_reason = fallback_reason
73
+ end
74
+
75
+ # Return a copy of this variant tagged with the given source. Clears
76
+ # fallback_reason — use {#as_fallback} when returning a fallback.
77
+ def with_source(source)
78
+ copy_with(variant_source: source, fallback_reason: nil)
79
+ end
80
+
81
+ # Return a copy tagged as a fallback with the given reason.
82
+ def as_fallback(reason)
83
+ copy_with(variant_source: VariantSource::FALLBACK, fallback_reason: reason)
20
84
  end
21
85
 
22
86
  # Convert to hash representation
23
- # @return [Hash]
24
87
  def to_h
25
88
  {
26
89
  variant_key: @variant_key,
27
90
  variant_value: @variant_value,
28
91
  experiment_id: @experiment_id,
29
92
  is_experiment_active: @is_experiment_active,
30
- is_qa_tester: @is_qa_tester
93
+ is_qa_tester: @is_qa_tester,
94
+ variant_source: @variant_source,
95
+ fallback_reason: @fallback_reason&.to_h
31
96
  }.compact
32
97
  end
98
+
99
+ private
100
+
101
+ def copy_with(variant_source: @variant_source, fallback_reason: @fallback_reason)
102
+ SelectedVariant.new(
103
+ variant_key: @variant_key,
104
+ variant_value: @variant_value,
105
+ experiment_id: @experiment_id,
106
+ is_experiment_active: @is_experiment_active,
107
+ is_qa_tester: @is_qa_tester,
108
+ variant_source: variant_source,
109
+ fallback_reason: fallback_reason
110
+ )
111
+ end
33
112
  end
34
113
  end
35
114
  end
@@ -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
- def initialize(token, error_handler=nil, local_flags_config: nil, remote_flags_config: nil, &block)
66
- super(token, error_handler, &block)
67
- @token = token
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
- # # Import event that user "12345"'s credit card was declined
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
- # # Properties describe the circumstances of the event,
131
- # # or aspects of the source or user associated with the event
132
- # tracker.import("API_KEY", "12345", "Welcome Email Sent", {
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.
@@ -1,3 +1,3 @@
1
1
  module Mixpanel
2
- VERSION = '3.1.0'
2
+ VERSION = '3.3.0'
3
3
  end
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'
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## [openfeature/v0.1.0](https://github.com/mixpanel/mixpanel-ruby/tree/openfeature/v0.1.0) (2026-05-13)
4
+
5
+ Initial release of the Mixpanel OpenFeature provider for Ruby.
@@ -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
  [![Gem Version](https://img.shields.io/gem/v/mixpanel-ruby-openfeature.svg)](https://rubygems.org/gems/mixpanel-ruby-openfeature)
4
6
  [![OpenFeature](https://img.shields.io/badge/OpenFeature-compatible-green)](https://openfeature.dev/)
5
7
  [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](https://github.com/mixpanel/mixpanel-ruby/blob/master/LICENSE)
@@ -199,6 +201,34 @@ When you are done using the provider, shut it down to stop any background pollin
199
201
  provider.shutdown
200
202
  ```
201
203
 
204
+ ## Async Exposure Tracking
205
+
206
+ By default, every flag evaluation tracks an exposure event inline — the `/track` HTTP round trip happens on the calling thread before `fetch_*_value` returns. For latency-sensitive code paths, pass an `:exposure_executor` so exposure tracking runs off-thread. The executor is duck-typed — anything that responds to `#post(&block)` works:
207
+
208
+ ```ruby
209
+ # With concurrent-ruby (recommended)
210
+ require 'concurrent'
211
+ exposure_executor = Concurrent::FixedThreadPool.new(1)
212
+
213
+ provider = Mixpanel::OpenFeature::Provider.from_local(
214
+ 'YOUR_PROJECT_TOKEN',
215
+ { exposure_executor: exposure_executor },
216
+ )
217
+
218
+ # Without concurrent-ruby (minimal wrapper)
219
+ class ThreadPerCall
220
+ def post(&block) = Thread.new(&block)
221
+ end
222
+ provider = Mixpanel::OpenFeature::Provider.from_local(
223
+ 'YOUR_PROJECT_TOKEN',
224
+ { exposure_executor: ThreadPerCall.new },
225
+ )
226
+ ```
227
+
228
+ Available on both local and remote flag configs. Defaults to `nil` (inline behavior); existing setups are unaffected.
229
+
230
+ > **Executor lifecycle:** `provider.shutdown` stops the flags provider's own resources (polling) but does NOT shut down a user-supplied executor. Own the executor's lifecycle explicitly — e.g. `at_exit { exposure_executor.shutdown; exposure_executor.wait_for_termination(5) }` for `Concurrent::FixedThreadPool`. Skipping this can leave background threads alive at process exit and delay shutdown.
231
+
202
232
  ## Error Handling
203
233
 
204
234
  The provider uses OpenFeature's standard error codes to indicate issues during flag evaluation:
@@ -69,16 +69,41 @@ module Mixpanel
69
69
 
70
70
  begin
71
71
  result = @flags_provider.get_variant(flag_key, fallback, context, report_exposure: true)
72
- rescue StandardError
73
- return error_result(default_value, ::OpenFeature::SDK::Provider::ErrorCode::GENERAL)
72
+ rescue StandardError => e
73
+ return error_result(default_value, ::OpenFeature::SDK::Provider::ErrorCode::GENERAL, e.message)
74
74
  end
75
75
 
76
- if result.equal?(fallback)
76
+ # variant_source distinguishes local / remote / fallback. When fallback,
77
+ # fallback_reason carries the specific reason (PHP-aligned constants)
78
+ # so we can map each to the spec-correct OpenFeature response instead
79
+ # of collapsing every fallback to FLAG_NOT_FOUND. SDK-83: BACKEND_ERROR
80
+ # also carries the backend message, forwarded as error_message.
81
+ case result.fallback_reason&.kind
82
+ when :flag_not_found
77
83
  return ::OpenFeature::SDK::Provider::ResolutionDetails.new(
78
84
  value: default_value,
79
85
  error_code: ::OpenFeature::SDK::Provider::ErrorCode::FLAG_NOT_FOUND,
80
86
  reason: ::OpenFeature::SDK::Provider::Reason::DEFAULT
81
87
  )
88
+ when :missing_context_key
89
+ return error_result(
90
+ default_value,
91
+ ::OpenFeature::SDK::Provider::ErrorCode::TARGETING_KEY_MISSING,
92
+ result.fallback_reason.message
93
+ )
94
+ when :no_rollout_match
95
+ # Flag exists, user just didn't match any rollout — per the
96
+ # OpenFeature spec this is `reason: DEFAULT` with no error.
97
+ return ::OpenFeature::SDK::Provider::ResolutionDetails.new(
98
+ value: default_value,
99
+ reason: ::OpenFeature::SDK::Provider::Reason::DEFAULT
100
+ )
101
+ when :backend_error
102
+ return error_result(
103
+ default_value,
104
+ ::OpenFeature::SDK::Provider::ErrorCode::GENERAL,
105
+ result.fallback_reason.message
106
+ )
82
107
  end
83
108
 
84
109
  value = result.variant_value
@@ -158,10 +183,11 @@ module Mixpanel
158
183
  end
159
184
  end
160
185
 
161
- def error_result(default_value, error_code)
186
+ def error_result(default_value, error_code, error_message = nil)
162
187
  ::OpenFeature::SDK::Provider::ResolutionDetails.new(
163
188
  value: default_value,
164
189
  error_code: error_code,
190
+ error_message: error_message,
165
191
  reason: ::OpenFeature::SDK::Provider::Reason::ERROR
166
192
  )
167
193
  end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mixpanel
4
+ module OpenFeature
5
+ VERSION = '0.1.0'
6
+ end
7
+ end
@@ -1,3 +1,4 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'openfeature/version'
3
4
  require_relative 'openfeature/provider'
@@ -1,8 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require File.join(File.dirname(__FILE__), 'lib/mixpanel/openfeature/version.rb')
4
+
3
5
  Gem::Specification.new do |spec|
4
6
  spec.name = 'mixpanel-ruby-openfeature'
5
- spec.version = '0.1.0'
7
+ spec.version = Mixpanel::OpenFeature::VERSION
6
8
  spec.authors = ['Mixpanel']
7
9
  spec.email = 'support@mixpanel.com'
8
10
  spec.summary = 'OpenFeature provider for Mixpanel feature flags'
@@ -75,15 +75,25 @@ RSpec.describe Mixpanel::OpenFeature::Provider do
75
75
  def setup_flag(flag_key, value, variant_key: 'variant-key')
76
76
  allow(mock_flags).to receive(:get_variant) do |key, fallback, _ctx, **_kwargs|
77
77
  if key == flag_key
78
- Mixpanel::Flags::SelectedVariant.new(variant_key: variant_key, variant_value: value)
78
+ Mixpanel::Flags::SelectedVariant.new(
79
+ variant_key: variant_key,
80
+ variant_value: value,
81
+ variant_source: Mixpanel::Flags::VariantSource::LOCAL
82
+ )
79
83
  else
80
- fallback
84
+ fallback.as_fallback(Mixpanel::Flags::FallbackReason.flag_not_found)
81
85
  end
82
86
  end
83
87
  end
84
88
 
89
+ def setup_fallback(reason)
90
+ allow(mock_flags).to receive(:get_variant) do |_key, fallback, _ctx, **_kwargs|
91
+ fallback.as_fallback(reason)
92
+ end
93
+ end
94
+
85
95
  def setup_flag_not_found
86
- allow(mock_flags).to receive(:get_variant) { |_key, fallback, _ctx, **_kwargs| fallback }
96
+ setup_fallback(Mixpanel::Flags::FallbackReason.flag_not_found)
87
97
  end
88
98
 
89
99
  # --- Metadata ---
@@ -305,6 +315,67 @@ RSpec.describe Mixpanel::OpenFeature::Provider do
305
315
  end
306
316
  end
307
317
 
318
+ # --- NO_ROLLOUT_MATCH (flag exists, no rollout matched) ---
319
+
320
+ describe 'no rollout match' do
321
+ before { setup_fallback(Mixpanel::Flags::FallbackReason.no_rollout_match) }
322
+
323
+ it 'returns DEFAULT reason without an error code' do
324
+ result = provider.fetch_boolean_value(flag_key: 'flag', default_value: true)
325
+ expect(result.value).to be true
326
+ expect(result.reason).to eq('DEFAULT')
327
+ expect(result.error_code).to be_nil
328
+ end
329
+
330
+ it 'works for strings' do
331
+ result = provider.fetch_string_value(flag_key: 'flag', default_value: 'default')
332
+ expect(result.value).to eq('default')
333
+ expect(result.reason).to eq('DEFAULT')
334
+ expect(result.error_code).to be_nil
335
+ end
336
+ end
337
+
338
+ # --- MISSING_CONTEXT_KEY ---
339
+
340
+ describe 'missing context key' do
341
+ before { setup_fallback(Mixpanel::Flags::FallbackReason.missing_context_key('distinct_id')) }
342
+
343
+ it 'returns TARGETING_KEY_MISSING for boolean' do
344
+ result = provider.fetch_boolean_value(flag_key: 'flag', default_value: false)
345
+ expect(result.value).to be false
346
+ expect(result.error_code).to eq('TARGETING_KEY_MISSING')
347
+ expect(result.reason).to eq('ERROR')
348
+ end
349
+
350
+ it 'returns TARGETING_KEY_MISSING for string and forwards the missing key as error_message' do
351
+ result = provider.fetch_string_value(flag_key: 'flag', default_value: 'default')
352
+ expect(result.value).to eq('default')
353
+ expect(result.error_code).to eq('TARGETING_KEY_MISSING')
354
+ expect(result.error_message).to eq('distinct_id')
355
+ expect(result.reason).to eq('ERROR')
356
+ end
357
+ end
358
+
359
+ # --- BACKEND_ERROR (SDK-83: forwards the backend's response message) ---
360
+
361
+ describe 'backend error' do
362
+ before do
363
+ setup_fallback(
364
+ Mixpanel::Flags::FallbackReason.backend_error(
365
+ 'HTTP 400: distinct_id must be provided in evalContext as a string'
366
+ )
367
+ )
368
+ end
369
+
370
+ it 'maps to GENERAL and forwards the backend message as error_message' do
371
+ result = provider.fetch_string_value(flag_key: 'flag', default_value: 'default')
372
+ expect(result.value).to eq('default')
373
+ expect(result.error_code).to eq('GENERAL')
374
+ expect(result.reason).to eq('ERROR')
375
+ expect(result.error_message).to include('distinct_id must be provided')
376
+ end
377
+ end
378
+
308
379
  # --- PROVIDER_NOT_READY ---
309
380
 
310
381
  describe 'provider not ready' do
@@ -3,6 +3,7 @@ require 'spec_helper'
3
3
  require 'webmock'
4
4
 
5
5
  require 'mixpanel-ruby/consumer'
6
+ require 'mixpanel-ruby/credentials'
6
7
 
7
8
  describe Mixpanel::Consumer do
8
9
  before { WebMock.reset! }
@@ -95,6 +96,56 @@ describe Mixpanel::Consumer do
95
96
  it_behaves_like 'consumer'
96
97
  end
97
98
 
99
+ context 'service account credentials' do
100
+ it 'should send a request to api.mixpanel.com/import with service account credentials' do
101
+ stub_request(:any, 'https://api.mixpanel.com/import?project_id=test-project-123').to_return({:body => '{"status": 1, "error": null}'})
102
+ credentials = Mixpanel::ServiceAccountCredentials.new('test-user', 'test-secret', 'test-project-123')
103
+ consumer = Mixpanel::Consumer.new(nil, nil, nil, nil, credentials: credentials)
104
+
105
+ consumer.send!(:import, {'data' => 'TEST EVENT MESSAGE'}.to_json)
106
+
107
+ # Should use Basic Auth header with username:secret
108
+ # Should add project_id as query parameter
109
+ # Should NOT include credentials in POST body or message
110
+ expect(WebMock).to have_requested(:post, 'https://api.mixpanel.com/import?project_id=test-project-123').
111
+ with(
112
+ :body => {
113
+ 'data' => 'IlRFU1QgRVZFTlQgTUVTU0FHRSI=',
114
+ 'verbose' => '1'
115
+ },
116
+ :headers => {
117
+ 'Authorization' => 'Basic ' + Base64.strict_encode64('test-user:test-secret')
118
+ }
119
+ )
120
+ end
121
+
122
+ it 'should raise ArgumentError when credentials is not ServiceAccountCredentials' do
123
+ consumer = Mixpanel::Consumer.new
124
+
125
+ expect {
126
+ consumer.request('https://api.mixpanel.com/import', {'data' => 'test', 'verbose' => '1'}, credentials: 'invalid', type: :import)
127
+ }.to raise_error(ArgumentError, /credentials must be ServiceAccountCredentials, got String/)
128
+ end
129
+
130
+ it 'should not include api_key when credentials are present' do
131
+ stub_request(:any, 'https://api.mixpanel.com/import?project_id=test-project').to_return({:body => '{"status": 1, "error": null}'})
132
+ credentials = Mixpanel::ServiceAccountCredentials.new('user', 'secret', 'test-project')
133
+ consumer = Mixpanel::Consumer.new(nil, nil, nil, nil, credentials: credentials)
134
+
135
+ # Message includes api_key but it should be ignored when credentials are present
136
+ consumer.send!(:import, {'data' => 'TEST EVENT MESSAGE', 'api_key' => 'SHOULD_BE_IGNORED'}.to_json)
137
+
138
+ # api_key should NOT be in the request body (only data and verbose)
139
+ expect(WebMock).to have_requested(:post, 'https://api.mixpanel.com/import?project_id=test-project').
140
+ with(
141
+ :body => {
142
+ 'data' => 'IlRFU1QgRVZFTlQgTUVTU0FHRSI=',
143
+ 'verbose' => '1'
144
+ }
145
+ )
146
+ end
147
+ end
148
+
98
149
  end
99
150
 
100
151
  describe Mixpanel::BufferedConsumer do
@@ -205,4 +256,34 @@ describe Mixpanel::BufferedConsumer do
205
256
  end
206
257
  end
207
258
 
259
+ context 'with service account credentials' do
260
+ it 'should pass credentials to consumer when using BufferedConsumer' do
261
+ stub_request(:any, 'https://api.mixpanel.com/import?project_id=buffered-project').to_return({:body => '{"status": 1, "error": null}'})
262
+ credentials = Mixpanel::ServiceAccountCredentials.new('buffered-user', 'buffered-secret', 'buffered-project')
263
+ consumer = Mixpanel::BufferedConsumer.new(nil, nil, nil, 2, credentials: credentials)
264
+
265
+ # Import messages are not buffered - they're sent immediately
266
+ consumer.send!(:import, {'data' => 'EVENT 1'}.to_json)
267
+
268
+ # Verify credentials were used in the request
269
+ expect(WebMock).to have_requested(:post, 'https://api.mixpanel.com/import?project_id=buffered-project').
270
+ with(
271
+ :headers => {
272
+ 'Authorization' => 'Basic ' + Base64.strict_encode64('buffered-user:buffered-secret')
273
+ }
274
+ ).once
275
+ end
276
+ end
277
+
278
+ end
279
+
280
+ describe 'Connection error handling' do
281
+ it 'should raise ConnectionError when network error occurs' do
282
+ stub_request(:any, 'https://api.mixpanel.com/track').to_raise(StandardError.new('Network timeout'))
283
+ consumer = Mixpanel::Consumer.new
284
+
285
+ expect {
286
+ consumer.send!(:event, {'data' => 'TEST EVENT MESSAGE'}.to_json)
287
+ }.to raise_error(Mixpanel::ConnectionError, /Could not connect to Mixpanel, with error "Network timeout"/)
288
+ end
208
289
  end