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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +16 -0
- data/Readme.rdoc +1 -1
- data/lib/mixpanel-ruby/flags/custom_operators.rb +207 -0
- data/lib/mixpanel-ruby/flags/flags_provider.rb +42 -5
- data/lib/mixpanel-ruby/flags/local_flags_provider.rb +36 -11
- data/lib/mixpanel-ruby/flags/remote_flags_provider.rb +18 -5
- data/lib/mixpanel-ruby/flags/types.rb +88 -9
- data/lib/mixpanel-ruby/version.rb +1 -1
- data/mixpanel-ruby.gemspec +1 -1
- data/openfeature-provider/CHANGELOG.md +10 -0
- data/openfeature-provider/README.md +29 -1
- data/openfeature-provider/lib/mixpanel/openfeature/provider.rb +30 -4
- data/openfeature-provider/lib/mixpanel/openfeature/version.rb +1 -1
- data/openfeature-provider/mixpanel-ruby-openfeature.gemspec +1 -1
- data/openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb +74 -3
- data/spec/fixtures/datetime_compare_tests.json +100 -0
- data/spec/fixtures/semver_compare_tests.json +161 -0
- data/spec/mixpanel-ruby/flags/custom_operators_spec.rb +74 -0
- data/spec/mixpanel-ruby/flags/local_flags_spec.rb +283 -0
- data/spec/mixpanel-ruby/flags/remote_flags_spec.rb +68 -0
- data/spec/mixpanel-ruby/flags/types_spec.rb +55 -0
- metadata +9 -4
|
@@ -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.
|
|
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-
|
|
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.
|
|
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.
|
|
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
|