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
@@ -1,6 +1,8 @@
1
1
  require 'json'
2
+ require 'timeout'
2
3
  require 'mixpanel-ruby/flags/local_flags_provider'
3
4
  require 'mixpanel-ruby/flags/types'
5
+ require 'mixpanel-ruby/credentials'
4
6
  require 'webmock/rspec'
5
7
 
6
8
  describe Mixpanel::Flags::LocalFlagsProvider do
@@ -16,7 +18,8 @@ describe Mixpanel::Flags::LocalFlagsProvider do
16
18
  test_token,
17
19
  config,
18
20
  mock_tracker,
19
- mock_error_handler
21
+ mock_error_handler,
22
+ nil # credentials
20
23
  )
21
24
  end
22
25
 
@@ -100,6 +103,34 @@ describe Mixpanel::Flags::LocalFlagsProvider do
100
103
  }
101
104
  end
102
105
 
106
+ describe '#initialize' do
107
+ it 'raises ArgumentError when polling_interval_in_seconds is not a positive number' do
108
+ [0, -1, 'sixty', :foo].each do |bad_value|
109
+ expect do
110
+ Mixpanel::Flags::LocalFlagsProvider.new(
111
+ test_token,
112
+ { polling_interval_in_seconds: bad_value },
113
+ mock_tracker,
114
+ mock_error_handler
115
+ )
116
+ end.to raise_error(ArgumentError, /polling_interval_in_seconds/)
117
+ end
118
+ end
119
+
120
+ it 'falls back to the default polling_interval_in_seconds when caller passes nil' do
121
+ # nil must not silently override the default — CV#wait(mutex, nil) blocks
122
+ # indefinitely, which would silently disable polling.
123
+ expect do
124
+ Mixpanel::Flags::LocalFlagsProvider.new(
125
+ test_token,
126
+ { polling_interval_in_seconds: nil },
127
+ mock_tracker,
128
+ mock_error_handler
129
+ )
130
+ end.not_to raise_error
131
+ end
132
+ end
133
+
103
134
  describe '#get_variant_value' do
104
135
  it 'returns fallback when no flag definitions' do
105
136
  stub_flag_definitions([])
@@ -718,6 +749,179 @@ describe Mixpanel::Flags::LocalFlagsProvider do
718
749
 
719
750
  provider.send(:track_exposure_event, 'test_flag', variant, test_context)
720
751
  end
752
+
753
+ it 'runs the tracker inline by default (no executor configured)' do
754
+ flag = create_test_flag
755
+ stub_flag_definitions([flag])
756
+ provider.start_polling_for_definitions!
757
+
758
+ variant = Mixpanel::Flags::SelectedVariant.new(
759
+ variant_key: 'treatment', variant_value: 'treatment'
760
+ )
761
+
762
+ calling_thread = Thread.current
763
+ tracker_thread = nil
764
+ allow(mock_tracker).to receive(:call) { tracker_thread = Thread.current }
765
+
766
+ provider.send(:track_exposure_event, 'test_flag', variant, test_context)
767
+ expect(tracker_thread).to be(calling_thread)
768
+ end
769
+
770
+ it 'dispatches the tracker via :exposure_executor when configured' do
771
+ executor = Object.new
772
+ def executor.post(&block)
773
+ Thread.new(&block)
774
+ end
775
+
776
+ tracker_thread = nil
777
+ tracker_ran = Queue.new
778
+ tracker = ->(_distinct_id, _event, _properties) {
779
+ tracker_thread = Thread.current
780
+ tracker_ran << :done
781
+ }
782
+
783
+ provider = Mixpanel::Flags::LocalFlagsProvider.new(
784
+ test_token,
785
+ { enable_polling: false, exposure_executor: executor },
786
+ tracker,
787
+ mock_error_handler
788
+ )
789
+
790
+ variant = Mixpanel::Flags::SelectedVariant.new(
791
+ variant_key: 'treatment', variant_value: 'treatment'
792
+ )
793
+ provider.send(:track_exposure_event, 'test_flag', variant, test_context)
794
+
795
+ # Bounded wait — a bare Queue#pop would hang CI forever if the
796
+ # tracker block raised before pushing :done.
797
+ Timeout.timeout(2) { tracker_ran.pop }
798
+ expect(tracker_thread).not_to be(Thread.current)
799
+ end
800
+
801
+ it 'reports non-MixpanelError exceptions from the async tracker to error_handler' do
802
+ handler_called = Queue.new
803
+ handler = double('error_handler')
804
+ allow(handler).to receive(:handle) { |e| handler_called << e }
805
+
806
+ executor = Object.new
807
+ def executor.post(&block)
808
+ Thread.new(&block)
809
+ end
810
+
811
+ tracker = ->(_distinct_id, _event, _properties) { raise NoMethodError, 'boom' }
812
+
813
+ provider = Mixpanel::Flags::LocalFlagsProvider.new(
814
+ test_token,
815
+ { enable_polling: false, exposure_executor: executor },
816
+ tracker,
817
+ handler
818
+ )
819
+
820
+ variant = Mixpanel::Flags::SelectedVariant.new(
821
+ variant_key: 'treatment', variant_value: 'treatment'
822
+ )
823
+ provider.send(:track_exposure_event, 'test_flag', variant, test_context)
824
+
825
+ err = Timeout.timeout(2) { handler_called.pop }
826
+ expect(err).to be_a(Mixpanel::MixpanelError)
827
+ expect(err.message).to include('NoMethodError')
828
+ expect(err.message).to include('boom')
829
+ end
830
+
831
+ # Inline path preserves the pre-executor behavior: non-MixpanelError
832
+ # from the tracker propagates to the flag evaluator's caller instead
833
+ # of being silently reported. Only the async path wraps everything,
834
+ # because on the executor thread propagation would just kill the
835
+ # background thread with no visibility.
836
+ it 'lets non-MixpanelError exceptions propagate on the inline path' do
837
+ tracker = ->(_distinct_id, _event, _properties) { raise NoMethodError, 'boom' }
838
+
839
+ inline_provider = Mixpanel::Flags::LocalFlagsProvider.new(
840
+ test_token,
841
+ { enable_polling: false },
842
+ tracker,
843
+ mock_error_handler
844
+ )
845
+
846
+ variant = Mixpanel::Flags::SelectedVariant.new(
847
+ variant_key: 'treatment', variant_value: 'treatment'
848
+ )
849
+
850
+ expect(mock_error_handler).not_to receive(:handle)
851
+ expect {
852
+ inline_provider.send(:track_exposure_event, 'test_flag', variant, test_context)
853
+ }.to raise_error(NoMethodError, 'boom')
854
+ end
855
+
856
+ # SDK-84: when local eval succeeds via a non-distinct_id Variant Assignment
857
+ # Key but the context lacks distinct_id, the exposure can't fire. Surface
858
+ # via error_handler instead of silently returning.
859
+ it 'reports through error_handler when distinct_id is missing' do
860
+ variant = Mixpanel::Flags::SelectedVariant.new(
861
+ variant_key: 'treatment', variant_value: 'treatment'
862
+ )
863
+
864
+ expect(mock_tracker).not_to receive(:call)
865
+ expect(mock_error_handler).to receive(:handle) do |err|
866
+ expect(err).to be_a(Mixpanel::MixpanelError)
867
+ expect(err.message).to include('test_flag')
868
+ expect(err.message).to include('distinct_id')
869
+ end
870
+
871
+ provider.send(
872
+ :track_exposure_event,
873
+ 'test_flag',
874
+ variant,
875
+ { 'device_id' => 'abc-123' }
876
+ )
877
+ end
878
+ end
879
+
880
+ describe '#get_variant variant_source / fallback_reason tagging' do
881
+ let(:fallback) { Mixpanel::Flags::SelectedVariant.new(variant_value: 'fb') }
882
+
883
+ it 'tags matched variants as LOCAL with no fallback_reason' do
884
+ flag = create_test_flag(rollout_percentage: 100.0)
885
+ stub_flag_definitions([flag])
886
+ provider.start_polling_for_definitions!
887
+
888
+ result = provider.get_variant('test_flag', fallback, test_context)
889
+ expect(result.variant_source).to eq(Mixpanel::Flags::VariantSource::LOCAL)
890
+ expect(result.fallback_reason).to be_nil
891
+ expect(result.variant_key).not_to be_nil
892
+ end
893
+
894
+ it 'tags missing flag as FALLBACK / flag_not_found' do
895
+ stub_flag_definitions([])
896
+ provider.start_polling_for_definitions!
897
+
898
+ result = provider.get_variant('missing', fallback, test_context)
899
+ expect(result.variant_source).to eq(Mixpanel::Flags::VariantSource::FALLBACK)
900
+ expect(result.fallback_reason.kind).to eq(:flag_not_found)
901
+ expect(result.fallback_reason.message).to be_nil
902
+ expect(result.variant_value).to eq('fb')
903
+ end
904
+
905
+ it 'tags missing context as FALLBACK / missing_context_key with the missing attribute' do
906
+ flag = create_test_flag(context: 'distinct_id')
907
+ stub_flag_definitions([flag])
908
+ provider.start_polling_for_definitions!
909
+
910
+ result = provider.get_variant('test_flag', fallback, {})
911
+ expect(result.variant_source).to eq(Mixpanel::Flags::VariantSource::FALLBACK)
912
+ expect(result.fallback_reason.kind).to eq(:missing_context_key)
913
+ expect(result.fallback_reason.message).to eq('distinct_id')
914
+ end
915
+
916
+ it 'tags no-rollout-match as FALLBACK / no_rollout_match' do
917
+ flag = create_test_flag(rollout_percentage: 0.0)
918
+ stub_flag_definitions([flag])
919
+ provider.start_polling_for_definitions!
920
+
921
+ result = provider.get_variant('test_flag', fallback, test_context)
922
+ expect(result.variant_source).to eq(Mixpanel::Flags::VariantSource::FALLBACK)
923
+ expect(result.fallback_reason.kind).to eq(:no_rollout_match)
924
+ end
721
925
  end
722
926
 
723
927
  describe 'polling' do
@@ -741,7 +945,8 @@ describe Mixpanel::Flags::LocalFlagsProvider do
741
945
  test_token,
742
946
  { enable_polling: true, polling_interval_in_seconds: 0.1 },
743
947
  mock_tracker,
744
- mock_error_handler
948
+ mock_error_handler,
949
+ nil # credentials
745
950
  )
746
951
 
747
952
  begin
@@ -755,5 +960,228 @@ describe Mixpanel::Flags::LocalFlagsProvider do
755
960
  polling_provider.stop_polling_for_definitions!
756
961
  end
757
962
  end
963
+
964
+ it 'shuts down promptly while the polling thread is waiting on the interval' do
965
+ flag = create_test_flag
966
+ stub_flag_definitions([flag])
967
+
968
+ polling_provider = Mixpanel::Flags::LocalFlagsProvider.new(
969
+ test_token,
970
+ # A long interval ensures the polling thread is parked on the wait when
971
+ # shutdown is requested — pre-fix, the join would have to ride out the
972
+ # full interval before the thread checked @stop_polling.
973
+ { enable_polling: true, polling_interval_in_seconds: 30 },
974
+ mock_tracker,
975
+ mock_error_handler
976
+ )
977
+
978
+ polling_provider.start_polling_for_definitions!
979
+ begin
980
+ # Wait until the polling thread is parked on the condition variable
981
+ # (status 'sleep') so the spec deterministically exercises the CV-wakeup
982
+ # path.
983
+ polling_thread = polling_provider.instance_variable_get(:@polling_thread)
984
+ Timeout.timeout(2) { sleep 0.01 until polling_thread.status == 'sleep' }
985
+
986
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
987
+ polling_provider.stop_polling_for_definitions!
988
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
989
+ expect(elapsed).to be < 1.0
990
+ ensure
991
+ polling_provider.stop_polling_for_definitions!
992
+ end
993
+ end
994
+
995
+ it 'shuts down promptly when stop races an in-flight fetch' do
996
+ flag = create_test_flag
997
+ fetch_count = 0
998
+ fetch_count_mutex = Mutex.new
999
+ second_fetch_started = Queue.new
1000
+ second_fetch_release = Queue.new
1001
+
1002
+ stub_request(:get, endpoint_url_regex)
1003
+ .to_return do |_request|
1004
+ this_call = fetch_count_mutex.synchronize { fetch_count += 1 }
1005
+ if this_call == 2
1006
+ second_fetch_started << true
1007
+ second_fetch_release.pop
1008
+ end
1009
+ {
1010
+ status: 200,
1011
+ body: { code: 200, flags: [flag] }.to_json,
1012
+ headers: { 'Content-Type' => 'application/json' }
1013
+ }
1014
+ end
1015
+
1016
+ polling_provider = Mixpanel::Flags::LocalFlagsProvider.new(
1017
+ test_token,
1018
+ # 1 s interval — large enough that the lost-wakeup race (if reintroduced)
1019
+ # would be detectable as ~1 s of extra teardown after the fetch finishes,
1020
+ # but small enough that the second polling-thread fetch starts quickly.
1021
+ { enable_polling: true, polling_interval_in_seconds: 1.0 },
1022
+ mock_tracker,
1023
+ mock_error_handler
1024
+ )
1025
+
1026
+ polling_provider.start_polling_for_definitions!
1027
+ begin
1028
+ # Bounded wait: if a regression keeps the polling thread from reaching
1029
+ # the second fetch, fail fast rather than hanging the suite.
1030
+ Timeout.timeout(5) { second_fetch_started.pop }
1031
+
1032
+ # Trigger shutdown while the polling thread is mid-fetch (NOT on the
1033
+ # CV), so the broadcast goes to no waiter. Run it in a thread so we can
1034
+ # release the fetch afterwards.
1035
+ stopper = Thread.new { polling_provider.stop_polling_for_definitions! }
1036
+ # Wait until the stopper has set @stop_polling and broadcast, so
1037
+ # releasing the fetch deterministically lands in the lost-wakeup window
1038
+ # this spec targets.
1039
+ Timeout.timeout(2) { sleep 0.01 until polling_provider.instance_variable_get(:@stop_polling) }
1040
+
1041
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
1042
+ second_fetch_release << true
1043
+ stopper.join
1044
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
1045
+
1046
+ # With the predicate-inside-mutex check, the polling thread re-enters
1047
+ # the mutex after fetch, sees @stop_polling = true, skips the wait, and
1048
+ # breaks immediately. Without it, the thread would call wait(1.0 s),
1049
+ # ride out the full interval, and only then break — elapsed would be
1050
+ # ~1 s.
1051
+ expect(elapsed).to be < 0.5
1052
+ ensure
1053
+ # Unblock any in-flight stub fetch in case we raised before releasing
1054
+ # it normally — otherwise the polling thread stays blocked at
1055
+ # second_fetch_release.pop and stop_polling_for_definitions!'s join
1056
+ # would hang the suite.
1057
+ second_fetch_release << true
1058
+ polling_provider.stop_polling_for_definitions!
1059
+ end
1060
+ end
1061
+
1062
+ it 'stop arriving during the initial fetch wins; start does not spawn a thread' do
1063
+ fetch_in_progress = Queue.new
1064
+ fetch_release = Queue.new
1065
+
1066
+ stub_request(:get, endpoint_url_regex).to_return do |_req|
1067
+ fetch_in_progress << true
1068
+ fetch_release.pop
1069
+ {
1070
+ status: 200,
1071
+ body: { code: 200, flags: [] }.to_json,
1072
+ headers: { 'Content-Type' => 'application/json' }
1073
+ }
1074
+ end
1075
+
1076
+ polling_provider = Mixpanel::Flags::LocalFlagsProvider.new(
1077
+ test_token,
1078
+ { enable_polling: true, polling_interval_in_seconds: 30 },
1079
+ mock_tracker,
1080
+ mock_error_handler
1081
+ )
1082
+
1083
+ starter = Thread.new { polling_provider.start_polling_for_definitions! }
1084
+ begin
1085
+ # Wait until start is blocked inside the initial fetch.
1086
+ Timeout.timeout(5) { fetch_in_progress.pop }
1087
+
1088
+ # Stop arrives while start is mid-fetch (start has already cleared
1089
+ # @stop_polling, so this is the bad case where stop's set must "win"
1090
+ # despite happening between start's clear and start's spawn-decision).
1091
+ polling_provider.stop_polling_for_definitions!
1092
+
1093
+ # Let start finish its fetch and reach the lifecycle check.
1094
+ fetch_release << true
1095
+ starter.join
1096
+
1097
+ # No polling thread should have been spawned — stop's postcondition
1098
+ # (polling is off when stop returns) must hold even when start was
1099
+ # mid-fetch at the time of the stop call.
1100
+ expect(polling_provider.instance_variable_get(:@polling_thread)).to be_nil
1101
+ ensure
1102
+ fetch_release << true
1103
+ starter&.join
1104
+ polling_provider.stop_polling_for_definitions!
1105
+ end
1106
+ end
1107
+
1108
+ # SDK-78: safe_handle_error previously dispatched only to @error_handler,
1109
+ # whose default Mixpanel::ErrorHandler#handle is a no-op — schema drift
1110
+ # (NoMethodError, JSON::ParserError, etc.) looped forever undetected.
1111
+ # Warn to stderr unconditionally so failures are visible without an
1112
+ # error_handler being configured.
1113
+ it 'warns to stderr when fetch raises and no error_handler is configured' do
1114
+ stub_request(:get, endpoint_url_regex).to_return(status: 500, body: 'server down')
1115
+
1116
+ polling_provider = Mixpanel::Flags::LocalFlagsProvider.new(
1117
+ test_token,
1118
+ { enable_polling: false },
1119
+ mock_tracker,
1120
+ nil
1121
+ )
1122
+
1123
+ expect { polling_provider.start_polling_for_definitions! }
1124
+ .to output(/\[Mixpanel\] Failed to fetch flag definitions: Mixpanel::ServerError/).to_stderr
1125
+ end
1126
+
1127
+ # Regression: previously the outer rescue on start_polling_for_definitions!
1128
+ # caught an initial-fetch failure and returned before the @lifecycle_mutex
1129
+ # block ever spawned the poller thread. A single startup blip (transient
1130
+ # 500 / network timeout) left the SDK permanently without a poller until
1131
+ # the caller retried — the whole point of polling.
1132
+ it 'still spawns the polling thread when the initial fetch fails' do
1133
+ stub_request(:get, endpoint_url_regex).to_return(status: 500, body: 'transient')
1134
+
1135
+ polling_provider = Mixpanel::Flags::LocalFlagsProvider.new(
1136
+ test_token,
1137
+ { enable_polling: true, polling_interval_in_seconds: 30 },
1138
+ mock_tracker,
1139
+ nil
1140
+ )
1141
+
1142
+ begin
1143
+ # Warning is expected because the initial fetch fails; capture stderr
1144
+ # so it doesn't pollute test output.
1145
+ expect { polling_provider.start_polling_for_definitions! }
1146
+ .to output(/\[Mixpanel\] Failed to fetch flag definitions/).to_stderr
1147
+
1148
+ polling_thread = polling_provider.instance_variable_get(:@polling_thread)
1149
+ expect(polling_thread).not_to be_nil
1150
+ expect(polling_thread).to be_alive
1151
+ ensure
1152
+ polling_provider.stop_polling_for_definitions!
1153
+ end
1154
+ end
1155
+ end
1156
+
1157
+ describe 'service account credentials' do
1158
+ it 'uses service account credentials for authentication' do
1159
+ credentials = Mixpanel::ServiceAccountCredentials.new('test-user', 'test-secret', 'test-project')
1160
+ flag = create_test_flag
1161
+
1162
+ stub_request(:get, endpoint_url_regex)
1163
+ .with(
1164
+ basic_auth: ['test-user', 'test-secret']
1165
+ )
1166
+ .to_return(
1167
+ status: 200,
1168
+ body: { code: 200, flags: [flag] }.to_json,
1169
+ headers: { 'Content-Type' => 'application/json' }
1170
+ )
1171
+
1172
+ credentials_provider = Mixpanel::Flags::LocalFlagsProvider.new(
1173
+ test_token,
1174
+ config,
1175
+ mock_tracker,
1176
+ mock_error_handler,
1177
+ credentials
1178
+ )
1179
+
1180
+ credentials_provider.start_polling_for_definitions!
1181
+ result = credentials_provider.get_variant_value('test_flag', 'fallback', test_context, report_exposure: false)
1182
+
1183
+ expect(result).not_to eq('fallback')
1184
+ credentials_provider.stop_polling_for_definitions!
1185
+ end
758
1186
  end
759
1187
  end
@@ -1,6 +1,8 @@
1
1
  require 'json'
2
+ require 'timeout'
2
3
  require 'mixpanel-ruby/flags/remote_flags_provider'
3
4
  require 'mixpanel-ruby/flags/types'
5
+ require 'mixpanel-ruby/credentials'
4
6
  require 'webmock/rspec'
5
7
 
6
8
  describe Mixpanel::Flags::RemoteFlagsProvider do
@@ -17,6 +19,7 @@ describe Mixpanel::Flags::RemoteFlagsProvider do
17
19
  config,
18
20
  mock_tracker,
19
21
  mock_error_handler
22
+ # credentials defaults to nil
20
23
  )
21
24
  end
22
25
 
@@ -116,6 +119,61 @@ describe Mixpanel::Flags::RemoteFlagsProvider do
116
119
  provider.get_variant_value('test_flag', 'control', test_context)
117
120
  end
118
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
+
119
177
  it 'does not track exposure event when report_exposure is false' do
120
178
  response = create_success_response({
121
179
  'test_flag' => {
@@ -257,6 +315,18 @@ describe Mixpanel::Flags::RemoteFlagsProvider do
257
315
 
258
316
  provider.get_variant('any-flag', fallback_variant, test_context)
259
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
260
330
  end
261
331
 
262
332
  describe '#is_enabled' do
@@ -438,4 +508,38 @@ describe Mixpanel::Flags::RemoteFlagsProvider do
438
508
  provider.send(:track_exposure_event, 'test_flag', variant, test_context)
439
509
  end
440
510
  end
511
+
512
+ describe 'service account credentials' do
513
+ it 'uses service account credentials for authentication' do
514
+ credentials = Mixpanel::ServiceAccountCredentials.new('test-user', 'test-secret', 'test-project')
515
+
516
+ response = create_success_response({
517
+ 'test_flag' => {
518
+ 'variant_key' => 'treatment',
519
+ 'variant_value' => 'treatment'
520
+ }
521
+ })
522
+
523
+ stub_request(:get, endpoint_url_regex)
524
+ .with(
525
+ basic_auth: ['test-user', 'test-secret']
526
+ )
527
+ .to_return(
528
+ status: 200,
529
+ body: response.to_json,
530
+ headers: { 'Content-Type' => 'application/json' }
531
+ )
532
+
533
+ credentials_provider = Mixpanel::Flags::RemoteFlagsProvider.new(
534
+ test_token,
535
+ config,
536
+ mock_tracker,
537
+ mock_error_handler,
538
+ credentials
539
+ )
540
+
541
+ result = credentials_provider.get_variant_value('test_flag', 'fallback', test_context, report_exposure: false)
542
+ expect(result).to eq('treatment')
543
+ end
544
+ end
441
545
  end
@@ -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
@@ -131,4 +131,22 @@ describe Mixpanel::Tracker do
131
131
  expect(expect).to eq(found)
132
132
  end
133
133
  end
134
+
135
+ describe 'service account credentials' do
136
+ it 'should pass credentials to flags providers when passed directly' do
137
+ credentials = Mixpanel::ServiceAccountCredentials.new('user', 'secret', 'project123')
138
+
139
+ tracker = Mixpanel::Tracker.new(
140
+ 'TEST TOKEN',
141
+ nil,
142
+ credentials: credentials,
143
+ local_flags_config: {},
144
+ remote_flags_config: {}
145
+ )
146
+
147
+ # Verify credentials were passed to providers by checking internal state
148
+ expect(tracker.local_flags.instance_variable_get(:@credentials)).to eq(credentials)
149
+ expect(tracker.remote_flags.instance_variable_get(:@credentials)).to eq(credentials)
150
+ end
151
+ end
134
152
  end