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.
- checksums.yaml +4 -4
- data/.github/dependabot.yml +14 -0
- data/.github/modules.json +18 -0
- data/.github/scripts/generate-changelog.sh +87 -0
- data/.github/workflows/pr-title-check.yml +51 -0
- data/.github/workflows/prepare-release.yml +189 -0
- data/.github/workflows/release-rubygems.yml +217 -0
- data/CHANGELOG.md +39 -0
- data/Readme.rdoc +65 -0
- data/lib/mixpanel-ruby/consumer.rb +54 -6
- data/lib/mixpanel-ruby/credentials.rb +26 -0
- data/lib/mixpanel-ruby/events.rb +69 -11
- data/lib/mixpanel-ruby/flags/flags_provider.rb +57 -6
- data/lib/mixpanel-ruby/flags/local_flags_provider.rb +115 -27
- data/lib/mixpanel-ruby/flags/remote_flags_provider.rb +22 -7
- data/lib/mixpanel-ruby/flags/types.rb +88 -9
- data/lib/mixpanel-ruby/tracker.rb +39 -9
- data/lib/mixpanel-ruby/version.rb +1 -1
- data/lib/mixpanel-ruby.rb +1 -0
- data/openfeature-provider/CHANGELOG.md +5 -0
- data/openfeature-provider/README.md +30 -0
- data/openfeature-provider/lib/mixpanel/openfeature/provider.rb +30 -4
- data/openfeature-provider/lib/mixpanel/openfeature/version.rb +7 -0
- data/openfeature-provider/lib/mixpanel/openfeature.rb +1 -0
- data/openfeature-provider/mixpanel-ruby-openfeature.gemspec +3 -1
- data/openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb +74 -3
- data/spec/mixpanel-ruby/consumer_spec.rb +81 -0
- data/spec/mixpanel-ruby/credentials_security_spec.rb +108 -0
- data/spec/mixpanel-ruby/credentials_spec.rb +54 -0
- data/spec/mixpanel-ruby/events_spec.rb +152 -0
- data/spec/mixpanel-ruby/flags/local_flags_spec.rb +430 -2
- data/spec/mixpanel-ruby/flags/remote_flags_spec.rb +104 -0
- data/spec/mixpanel-ruby/flags/types_spec.rb +55 -0
- data/spec/mixpanel-ruby/tracker_spec.rb +18 -0
- 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
|