mixpanel-ruby 3.2.0 → 3.4.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.
@@ -486,6 +486,68 @@ describe Mixpanel::Flags::LocalFlagsProvider do
486
486
  expect(result).to eq('fallback')
487
487
  end
488
488
 
489
+ it 'respects runtime evaluation rule with semver_compare operator when satisfied' do
490
+ runtime_eval = {
491
+ 'semver_compare' => [{'var' => 'app_version'}, '>=', '1.2.0']
492
+ }
493
+ flag = create_test_flag(runtime_evaluation_rule: runtime_eval)
494
+
495
+ stub_flag_definitions([flag])
496
+ provider.start_polling_for_definitions!
497
+
498
+ context = user_context_with_properties({'app_version' => '1.5.0'})
499
+ result = provider.get_variant_value('test_flag', 'fallback', context)
500
+
501
+ expect(result).not_to eq('fallback')
502
+ expect(['control', 'treatment']).to include(result)
503
+ end
504
+
505
+ it 'respects runtime evaluation rule with semver_compare operator when not satisfied' do
506
+ runtime_eval = {
507
+ 'semver_compare' => [{'var' => 'app_version'}, '>=', '1.2.0']
508
+ }
509
+ flag = create_test_flag(runtime_evaluation_rule: runtime_eval)
510
+
511
+ stub_flag_definitions([flag])
512
+ provider.start_polling_for_definitions!
513
+
514
+ context = user_context_with_properties({'app_version' => '1.0.0'})
515
+ result = provider.get_variant_value('test_flag', 'fallback', context)
516
+
517
+ expect(result).to eq('fallback')
518
+ end
519
+
520
+ it 'respects runtime evaluation rule with datetime_compare operator when satisfied' do
521
+ runtime_eval = {
522
+ 'datetime_compare' => [{'var' => 'signup'}, '>=', 1_784_160_000_000]
523
+ }
524
+ flag = create_test_flag(runtime_evaluation_rule: runtime_eval)
525
+
526
+ stub_flag_definitions([flag])
527
+ provider.start_polling_for_definitions!
528
+
529
+ context = user_context_with_properties({'signup' => '2026-07-17T00:00:00Z'})
530
+ result = provider.get_variant_value('test_flag', 'fallback', context)
531
+
532
+ expect(result).not_to eq('fallback')
533
+ expect(['control', 'treatment']).to include(result)
534
+ end
535
+
536
+ it 'respects runtime evaluation rule with datetime_compare operator when not satisfied' do
537
+ runtime_eval = {
538
+ 'datetime_compare' => [{'var' => 'signup'}, '>=', 1_784_160_000_000]
539
+ }
540
+ flag = create_test_flag(runtime_evaluation_rule: runtime_eval)
541
+
542
+ stub_flag_definitions([flag])
543
+ provider.start_polling_for_definitions!
544
+
545
+ context = user_context_with_properties({'signup' => '2026-07-15T00:00:00Z'})
546
+ result = provider.get_variant_value('test_flag', 'fallback', context)
547
+
548
+ expect(result).to eq('fallback')
549
+ end
550
+
489
551
  it 'picks correct variant with hundred percent split' do
490
552
  variants = [
491
553
  { 'key' => 'A', 'value' => 'variant_a', 'is_control' => false, 'split' => 100.0 },
@@ -749,6 +811,179 @@ describe Mixpanel::Flags::LocalFlagsProvider do
749
811
 
750
812
  provider.send(:track_exposure_event, 'test_flag', variant, test_context)
751
813
  end
814
+
815
+ it 'runs the tracker inline by default (no executor configured)' do
816
+ flag = create_test_flag
817
+ stub_flag_definitions([flag])
818
+ provider.start_polling_for_definitions!
819
+
820
+ variant = Mixpanel::Flags::SelectedVariant.new(
821
+ variant_key: 'treatment', variant_value: 'treatment'
822
+ )
823
+
824
+ calling_thread = Thread.current
825
+ tracker_thread = nil
826
+ allow(mock_tracker).to receive(:call) { tracker_thread = Thread.current }
827
+
828
+ provider.send(:track_exposure_event, 'test_flag', variant, test_context)
829
+ expect(tracker_thread).to be(calling_thread)
830
+ end
831
+
832
+ it 'dispatches the tracker via :exposure_executor when configured' do
833
+ executor = Object.new
834
+ def executor.post(&block)
835
+ Thread.new(&block)
836
+ end
837
+
838
+ tracker_thread = nil
839
+ tracker_ran = Queue.new
840
+ tracker = ->(_distinct_id, _event, _properties) {
841
+ tracker_thread = Thread.current
842
+ tracker_ran << :done
843
+ }
844
+
845
+ provider = Mixpanel::Flags::LocalFlagsProvider.new(
846
+ test_token,
847
+ { enable_polling: false, exposure_executor: executor },
848
+ tracker,
849
+ mock_error_handler
850
+ )
851
+
852
+ variant = Mixpanel::Flags::SelectedVariant.new(
853
+ variant_key: 'treatment', variant_value: 'treatment'
854
+ )
855
+ provider.send(:track_exposure_event, 'test_flag', variant, test_context)
856
+
857
+ # Bounded wait — a bare Queue#pop would hang CI forever if the
858
+ # tracker block raised before pushing :done.
859
+ Timeout.timeout(2) { tracker_ran.pop }
860
+ expect(tracker_thread).not_to be(Thread.current)
861
+ end
862
+
863
+ it 'reports non-MixpanelError exceptions from the async tracker to error_handler' do
864
+ handler_called = Queue.new
865
+ handler = double('error_handler')
866
+ allow(handler).to receive(:handle) { |e| handler_called << e }
867
+
868
+ executor = Object.new
869
+ def executor.post(&block)
870
+ Thread.new(&block)
871
+ end
872
+
873
+ tracker = ->(_distinct_id, _event, _properties) { raise NoMethodError, 'boom' }
874
+
875
+ provider = Mixpanel::Flags::LocalFlagsProvider.new(
876
+ test_token,
877
+ { enable_polling: false, exposure_executor: executor },
878
+ tracker,
879
+ handler
880
+ )
881
+
882
+ variant = Mixpanel::Flags::SelectedVariant.new(
883
+ variant_key: 'treatment', variant_value: 'treatment'
884
+ )
885
+ provider.send(:track_exposure_event, 'test_flag', variant, test_context)
886
+
887
+ err = Timeout.timeout(2) { handler_called.pop }
888
+ expect(err).to be_a(Mixpanel::MixpanelError)
889
+ expect(err.message).to include('NoMethodError')
890
+ expect(err.message).to include('boom')
891
+ end
892
+
893
+ # Inline path preserves the pre-executor behavior: non-MixpanelError
894
+ # from the tracker propagates to the flag evaluator's caller instead
895
+ # of being silently reported. Only the async path wraps everything,
896
+ # because on the executor thread propagation would just kill the
897
+ # background thread with no visibility.
898
+ it 'lets non-MixpanelError exceptions propagate on the inline path' do
899
+ tracker = ->(_distinct_id, _event, _properties) { raise NoMethodError, 'boom' }
900
+
901
+ inline_provider = Mixpanel::Flags::LocalFlagsProvider.new(
902
+ test_token,
903
+ { enable_polling: false },
904
+ tracker,
905
+ mock_error_handler
906
+ )
907
+
908
+ variant = Mixpanel::Flags::SelectedVariant.new(
909
+ variant_key: 'treatment', variant_value: 'treatment'
910
+ )
911
+
912
+ expect(mock_error_handler).not_to receive(:handle)
913
+ expect {
914
+ inline_provider.send(:track_exposure_event, 'test_flag', variant, test_context)
915
+ }.to raise_error(NoMethodError, 'boom')
916
+ end
917
+
918
+ # SDK-84: when local eval succeeds via a non-distinct_id Variant Assignment
919
+ # Key but the context lacks distinct_id, the exposure can't fire. Surface
920
+ # via error_handler instead of silently returning.
921
+ it 'reports through error_handler when distinct_id is missing' do
922
+ variant = Mixpanel::Flags::SelectedVariant.new(
923
+ variant_key: 'treatment', variant_value: 'treatment'
924
+ )
925
+
926
+ expect(mock_tracker).not_to receive(:call)
927
+ expect(mock_error_handler).to receive(:handle) do |err|
928
+ expect(err).to be_a(Mixpanel::MixpanelError)
929
+ expect(err.message).to include('test_flag')
930
+ expect(err.message).to include('distinct_id')
931
+ end
932
+
933
+ provider.send(
934
+ :track_exposure_event,
935
+ 'test_flag',
936
+ variant,
937
+ { 'device_id' => 'abc-123' }
938
+ )
939
+ end
940
+ end
941
+
942
+ describe '#get_variant variant_source / fallback_reason tagging' do
943
+ let(:fallback) { Mixpanel::Flags::SelectedVariant.new(variant_value: 'fb') }
944
+
945
+ it 'tags matched variants as LOCAL with no fallback_reason' do
946
+ flag = create_test_flag(rollout_percentage: 100.0)
947
+ stub_flag_definitions([flag])
948
+ provider.start_polling_for_definitions!
949
+
950
+ result = provider.get_variant('test_flag', fallback, test_context)
951
+ expect(result.variant_source).to eq(Mixpanel::Flags::VariantSource::LOCAL)
952
+ expect(result.fallback_reason).to be_nil
953
+ expect(result.variant_key).not_to be_nil
954
+ end
955
+
956
+ it 'tags missing flag as FALLBACK / flag_not_found' do
957
+ stub_flag_definitions([])
958
+ provider.start_polling_for_definitions!
959
+
960
+ result = provider.get_variant('missing', fallback, test_context)
961
+ expect(result.variant_source).to eq(Mixpanel::Flags::VariantSource::FALLBACK)
962
+ expect(result.fallback_reason.kind).to eq(:flag_not_found)
963
+ expect(result.fallback_reason.message).to be_nil
964
+ expect(result.variant_value).to eq('fb')
965
+ end
966
+
967
+ it 'tags missing context as FALLBACK / missing_context_key with the missing attribute' do
968
+ flag = create_test_flag(context: 'distinct_id')
969
+ stub_flag_definitions([flag])
970
+ provider.start_polling_for_definitions!
971
+
972
+ result = provider.get_variant('test_flag', fallback, {})
973
+ expect(result.variant_source).to eq(Mixpanel::Flags::VariantSource::FALLBACK)
974
+ expect(result.fallback_reason.kind).to eq(:missing_context_key)
975
+ expect(result.fallback_reason.message).to eq('distinct_id')
976
+ end
977
+
978
+ it 'tags no-rollout-match as FALLBACK / no_rollout_match' do
979
+ flag = create_test_flag(rollout_percentage: 0.0)
980
+ stub_flag_definitions([flag])
981
+ provider.start_polling_for_definitions!
982
+
983
+ result = provider.get_variant('test_flag', fallback, test_context)
984
+ expect(result.variant_source).to eq(Mixpanel::Flags::VariantSource::FALLBACK)
985
+ expect(result.fallback_reason.kind).to eq(:no_rollout_match)
986
+ end
752
987
  end
753
988
 
754
989
  describe 'polling' do
@@ -931,6 +1166,54 @@ describe Mixpanel::Flags::LocalFlagsProvider do
931
1166
  polling_provider.stop_polling_for_definitions!
932
1167
  end
933
1168
  end
1169
+
1170
+ # SDK-78: safe_handle_error previously dispatched only to @error_handler,
1171
+ # whose default Mixpanel::ErrorHandler#handle is a no-op — schema drift
1172
+ # (NoMethodError, JSON::ParserError, etc.) looped forever undetected.
1173
+ # Warn to stderr unconditionally so failures are visible without an
1174
+ # error_handler being configured.
1175
+ it 'warns to stderr when fetch raises and no error_handler is configured' do
1176
+ stub_request(:get, endpoint_url_regex).to_return(status: 500, body: 'server down')
1177
+
1178
+ polling_provider = Mixpanel::Flags::LocalFlagsProvider.new(
1179
+ test_token,
1180
+ { enable_polling: false },
1181
+ mock_tracker,
1182
+ nil
1183
+ )
1184
+
1185
+ expect { polling_provider.start_polling_for_definitions! }
1186
+ .to output(/\[Mixpanel\] Failed to fetch flag definitions: Mixpanel::ServerError/).to_stderr
1187
+ end
1188
+
1189
+ # Regression: previously the outer rescue on start_polling_for_definitions!
1190
+ # caught an initial-fetch failure and returned before the @lifecycle_mutex
1191
+ # block ever spawned the poller thread. A single startup blip (transient
1192
+ # 500 / network timeout) left the SDK permanently without a poller until
1193
+ # the caller retried — the whole point of polling.
1194
+ it 'still spawns the polling thread when the initial fetch fails' do
1195
+ stub_request(:get, endpoint_url_regex).to_return(status: 500, body: 'transient')
1196
+
1197
+ polling_provider = Mixpanel::Flags::LocalFlagsProvider.new(
1198
+ test_token,
1199
+ { enable_polling: true, polling_interval_in_seconds: 30 },
1200
+ mock_tracker,
1201
+ nil
1202
+ )
1203
+
1204
+ begin
1205
+ # Warning is expected because the initial fetch fails; capture stderr
1206
+ # so it doesn't pollute test output.
1207
+ expect { polling_provider.start_polling_for_definitions! }
1208
+ .to output(/\[Mixpanel\] Failed to fetch flag definitions/).to_stderr
1209
+
1210
+ polling_thread = polling_provider.instance_variable_get(:@polling_thread)
1211
+ expect(polling_thread).not_to be_nil
1212
+ expect(polling_thread).to be_alive
1213
+ ensure
1214
+ polling_provider.stop_polling_for_definitions!
1215
+ end
1216
+ end
934
1217
  end
935
1218
 
936
1219
  describe 'service account credentials' do
@@ -1,4 +1,5 @@
1
1
  require 'json'
2
+ require 'timeout'
2
3
  require 'mixpanel-ruby/flags/remote_flags_provider'
3
4
  require 'mixpanel-ruby/flags/types'
4
5
  require 'mixpanel-ruby/credentials'
@@ -118,6 +119,61 @@ describe Mixpanel::Flags::RemoteFlagsProvider do
118
119
  provider.get_variant_value('test_flag', 'control', test_context)
119
120
  end
120
121
 
122
+ it 'runs the tracker inline by default (no executor configured)' do
123
+ response = create_success_response({
124
+ 'test_flag' => {
125
+ 'variant_key' => 'treatment',
126
+ 'variant_value' => 'treatment'
127
+ }
128
+ })
129
+ stub_flags_request(response)
130
+
131
+ calling_thread = Thread.current
132
+ tracker_thread = nil
133
+ allow(mock_tracker).to receive(:call) { tracker_thread = Thread.current }
134
+
135
+ provider.get_variant_value('test_flag', 'control', test_context)
136
+ expect(tracker_thread).to be(calling_thread)
137
+ end
138
+
139
+ it 'dispatches the tracker via the configured exposure_executor off the calling thread' do
140
+ response = create_success_response({
141
+ 'test_flag' => {
142
+ 'variant_key' => 'treatment',
143
+ 'variant_value' => 'treatment'
144
+ }
145
+ })
146
+ stub_flags_request(response)
147
+
148
+ calling_thread = Thread.current
149
+ tracker_thread = nil
150
+ tracker_ran = Queue.new
151
+ tracker = ->(_distinct_id, _event, _properties) {
152
+ tracker_thread = Thread.current
153
+ tracker_ran << :done
154
+ }
155
+
156
+ # Minimal duck-typed executor: spawn a thread per call.
157
+ executor = Object.new
158
+ def executor.post(&block)
159
+ Thread.new(&block)
160
+ end
161
+
162
+ provider = Mixpanel::Flags::RemoteFlagsProvider.new(
163
+ test_token,
164
+ { exposure_executor: executor },
165
+ tracker,
166
+ mock_error_handler
167
+ )
168
+
169
+ provider.get_variant_value('test_flag', 'control', test_context)
170
+
171
+ # Bounded wait — a bare Queue#pop would hang CI forever if the
172
+ # tracker block raised before pushing :done.
173
+ Timeout.timeout(2) { tracker_ran.pop }
174
+ expect(tracker_thread).not_to be(calling_thread)
175
+ end
176
+
121
177
  it 'does not track exposure event when report_exposure is false' do
122
178
  response = create_success_response({
123
179
  'test_flag' => {
@@ -259,6 +315,18 @@ describe Mixpanel::Flags::RemoteFlagsProvider do
259
315
 
260
316
  provider.get_variant('any-flag', fallback_variant, test_context)
261
317
  end
318
+
319
+ it 'tags the fallback as BACKEND_ERROR with the response body on HTTP error (SDK-83)' do
320
+ stub_request(:get, %r{https://api\.mixpanel\.com/flags})
321
+ .to_return(status: 400, body: 'distinct_id must be provided in evalContext as a string')
322
+
323
+ fallback_variant = Mixpanel::Flags::SelectedVariant.new(variant_value: 'fb')
324
+ result = provider.get_variant('any-flag', fallback_variant, test_context, report_exposure: false)
325
+
326
+ expect(result.variant_source).to eq(Mixpanel::Flags::VariantSource::FALLBACK)
327
+ expect(result.fallback_reason.kind).to eq(:backend_error)
328
+ expect(result.fallback_reason.message).to include('distinct_id must be provided')
329
+ end
262
330
  end
263
331
 
264
332
  describe '#is_enabled' do
@@ -0,0 +1,55 @@
1
+ require 'json'
2
+ require 'mixpanel-ruby/flags/types'
3
+
4
+ describe Mixpanel::Flags::SelectedVariant do
5
+ describe '#to_h' do
6
+ it 'returns a plain hash for a matched variant' do
7
+ variant = described_class.new(
8
+ variant_key: 'treatment',
9
+ variant_value: 'v1',
10
+ experiment_id: 'exp-1',
11
+ is_experiment_active: true,
12
+ variant_source: Mixpanel::Flags::VariantSource::LOCAL
13
+ )
14
+
15
+ expect(variant.to_h).to eq(
16
+ variant_key: 'treatment',
17
+ variant_value: 'v1',
18
+ experiment_id: 'exp-1',
19
+ is_experiment_active: true,
20
+ variant_source: 'local'
21
+ )
22
+ end
23
+
24
+ # SDK-125: previously to_h returned the raw FallbackReason object under
25
+ # :fallback_reason, so downstream serializers (e.g. .to_json,
26
+ # structured logging) got an object representation instead of the
27
+ # {kind:, message:} hash FallbackReason itself defines.
28
+ it 'recurses into FallbackReason so the result is fully hash-y' do
29
+ variant = described_class.new(
30
+ variant_value: 'fallback-value',
31
+ variant_source: Mixpanel::Flags::VariantSource::FALLBACK,
32
+ fallback_reason: Mixpanel::Flags::FallbackReason.backend_error('boom')
33
+ )
34
+
35
+ expect(variant.to_h[:fallback_reason]).to eq(kind: :backend_error, message: 'boom')
36
+ end
37
+
38
+ it 'round-trips through JSON without leaking a FallbackReason object' do
39
+ variant = described_class.new(
40
+ variant_value: 'fallback-value',
41
+ variant_source: Mixpanel::Flags::VariantSource::FALLBACK,
42
+ fallback_reason: Mixpanel::Flags::FallbackReason.missing_context_key('distinct_id')
43
+ )
44
+
45
+ parsed = JSON.parse(variant.to_h.to_json, symbolize_names: true)
46
+
47
+ expect(parsed[:fallback_reason]).to eq(kind: 'missing_context_key', message: 'distinct_id')
48
+ end
49
+
50
+ it 'compacts fallback_reason when absent' do
51
+ variant = described_class.new(variant_value: 'x', variant_source: 'local')
52
+ expect(variant.to_h).not_to have_key(:fallback_reason)
53
+ end
54
+ end
55
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mixpanel-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.2.0
4
+ version: 3.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mixpanel
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-10 00:00:00.000000000 Z
11
+ date: 2026-09-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: mutex_m
@@ -44,14 +44,14 @@ dependencies:
44
44
  requirements:
45
45
  - - "~>"
46
46
  - !ruby/object:Gem::Version
47
- version: 0.1.5
47
+ version: '0.2'
48
48
  type: :runtime
49
49
  prerelease: false
50
50
  version_requirements: !ruby/object:Gem::Requirement
51
51
  requirements:
52
52
  - - "~>"
53
53
  - !ruby/object:Gem::Version
54
- version: 0.1.5
54
+ version: '0.2'
55
55
  - !ruby/object:Gem::Dependency
56
56
  name: activesupport
57
57
  requirement: !ruby/object:Gem::Requirement
@@ -194,6 +194,7 @@ files:
194
194
  - lib/mixpanel-ruby/credentials.rb
195
195
  - lib/mixpanel-ruby/error.rb
196
196
  - lib/mixpanel-ruby/events.rb
197
+ - lib/mixpanel-ruby/flags/custom_operators.rb
197
198
  - lib/mixpanel-ruby/flags/flags_provider.rb
198
199
  - lib/mixpanel-ruby/flags/local_flags_provider.rb
199
200
  - lib/mixpanel-ruby/flags/remote_flags_provider.rb
@@ -214,13 +215,17 @@ files:
214
215
  - openfeature-provider/mixpanel-ruby-openfeature.gemspec
215
216
  - openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb
216
217
  - openfeature-provider/spec/spec_helper.rb
218
+ - spec/fixtures/datetime_compare_tests.json
219
+ - spec/fixtures/semver_compare_tests.json
217
220
  - spec/mixpanel-ruby/consumer_spec.rb
218
221
  - spec/mixpanel-ruby/credentials_security_spec.rb
219
222
  - spec/mixpanel-ruby/credentials_spec.rb
220
223
  - spec/mixpanel-ruby/error_spec.rb
221
224
  - spec/mixpanel-ruby/events_spec.rb
225
+ - spec/mixpanel-ruby/flags/custom_operators_spec.rb
222
226
  - spec/mixpanel-ruby/flags/local_flags_spec.rb
223
227
  - spec/mixpanel-ruby/flags/remote_flags_spec.rb
228
+ - spec/mixpanel-ruby/flags/types_spec.rb
224
229
  - spec/mixpanel-ruby/flags/utils_spec.rb
225
230
  - spec/mixpanel-ruby/groups_spec.rb
226
231
  - spec/mixpanel-ruby/people_spec.rb