mixpanel-ruby 3.1.0 → 3.2.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.
@@ -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([])
@@ -741,7 +772,8 @@ describe Mixpanel::Flags::LocalFlagsProvider do
741
772
  test_token,
742
773
  { enable_polling: true, polling_interval_in_seconds: 0.1 },
743
774
  mock_tracker,
744
- mock_error_handler
775
+ mock_error_handler,
776
+ nil # credentials
745
777
  )
746
778
 
747
779
  begin
@@ -755,5 +787,180 @@ describe Mixpanel::Flags::LocalFlagsProvider do
755
787
  polling_provider.stop_polling_for_definitions!
756
788
  end
757
789
  end
790
+
791
+ it 'shuts down promptly while the polling thread is waiting on the interval' do
792
+ flag = create_test_flag
793
+ stub_flag_definitions([flag])
794
+
795
+ polling_provider = Mixpanel::Flags::LocalFlagsProvider.new(
796
+ test_token,
797
+ # A long interval ensures the polling thread is parked on the wait when
798
+ # shutdown is requested — pre-fix, the join would have to ride out the
799
+ # full interval before the thread checked @stop_polling.
800
+ { enable_polling: true, polling_interval_in_seconds: 30 },
801
+ mock_tracker,
802
+ mock_error_handler
803
+ )
804
+
805
+ polling_provider.start_polling_for_definitions!
806
+ begin
807
+ # Wait until the polling thread is parked on the condition variable
808
+ # (status 'sleep') so the spec deterministically exercises the CV-wakeup
809
+ # path.
810
+ polling_thread = polling_provider.instance_variable_get(:@polling_thread)
811
+ Timeout.timeout(2) { sleep 0.01 until polling_thread.status == 'sleep' }
812
+
813
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
814
+ polling_provider.stop_polling_for_definitions!
815
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
816
+ expect(elapsed).to be < 1.0
817
+ ensure
818
+ polling_provider.stop_polling_for_definitions!
819
+ end
820
+ end
821
+
822
+ it 'shuts down promptly when stop races an in-flight fetch' do
823
+ flag = create_test_flag
824
+ fetch_count = 0
825
+ fetch_count_mutex = Mutex.new
826
+ second_fetch_started = Queue.new
827
+ second_fetch_release = Queue.new
828
+
829
+ stub_request(:get, endpoint_url_regex)
830
+ .to_return do |_request|
831
+ this_call = fetch_count_mutex.synchronize { fetch_count += 1 }
832
+ if this_call == 2
833
+ second_fetch_started << true
834
+ second_fetch_release.pop
835
+ end
836
+ {
837
+ status: 200,
838
+ body: { code: 200, flags: [flag] }.to_json,
839
+ headers: { 'Content-Type' => 'application/json' }
840
+ }
841
+ end
842
+
843
+ polling_provider = Mixpanel::Flags::LocalFlagsProvider.new(
844
+ test_token,
845
+ # 1 s interval — large enough that the lost-wakeup race (if reintroduced)
846
+ # would be detectable as ~1 s of extra teardown after the fetch finishes,
847
+ # but small enough that the second polling-thread fetch starts quickly.
848
+ { enable_polling: true, polling_interval_in_seconds: 1.0 },
849
+ mock_tracker,
850
+ mock_error_handler
851
+ )
852
+
853
+ polling_provider.start_polling_for_definitions!
854
+ begin
855
+ # Bounded wait: if a regression keeps the polling thread from reaching
856
+ # the second fetch, fail fast rather than hanging the suite.
857
+ Timeout.timeout(5) { second_fetch_started.pop }
858
+
859
+ # Trigger shutdown while the polling thread is mid-fetch (NOT on the
860
+ # CV), so the broadcast goes to no waiter. Run it in a thread so we can
861
+ # release the fetch afterwards.
862
+ stopper = Thread.new { polling_provider.stop_polling_for_definitions! }
863
+ # Wait until the stopper has set @stop_polling and broadcast, so
864
+ # releasing the fetch deterministically lands in the lost-wakeup window
865
+ # this spec targets.
866
+ Timeout.timeout(2) { sleep 0.01 until polling_provider.instance_variable_get(:@stop_polling) }
867
+
868
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
869
+ second_fetch_release << true
870
+ stopper.join
871
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
872
+
873
+ # With the predicate-inside-mutex check, the polling thread re-enters
874
+ # the mutex after fetch, sees @stop_polling = true, skips the wait, and
875
+ # breaks immediately. Without it, the thread would call wait(1.0 s),
876
+ # ride out the full interval, and only then break — elapsed would be
877
+ # ~1 s.
878
+ expect(elapsed).to be < 0.5
879
+ ensure
880
+ # Unblock any in-flight stub fetch in case we raised before releasing
881
+ # it normally — otherwise the polling thread stays blocked at
882
+ # second_fetch_release.pop and stop_polling_for_definitions!'s join
883
+ # would hang the suite.
884
+ second_fetch_release << true
885
+ polling_provider.stop_polling_for_definitions!
886
+ end
887
+ end
888
+
889
+ it 'stop arriving during the initial fetch wins; start does not spawn a thread' do
890
+ fetch_in_progress = Queue.new
891
+ fetch_release = Queue.new
892
+
893
+ stub_request(:get, endpoint_url_regex).to_return do |_req|
894
+ fetch_in_progress << true
895
+ fetch_release.pop
896
+ {
897
+ status: 200,
898
+ body: { code: 200, flags: [] }.to_json,
899
+ headers: { 'Content-Type' => 'application/json' }
900
+ }
901
+ end
902
+
903
+ polling_provider = Mixpanel::Flags::LocalFlagsProvider.new(
904
+ test_token,
905
+ { enable_polling: true, polling_interval_in_seconds: 30 },
906
+ mock_tracker,
907
+ mock_error_handler
908
+ )
909
+
910
+ starter = Thread.new { polling_provider.start_polling_for_definitions! }
911
+ begin
912
+ # Wait until start is blocked inside the initial fetch.
913
+ Timeout.timeout(5) { fetch_in_progress.pop }
914
+
915
+ # Stop arrives while start is mid-fetch (start has already cleared
916
+ # @stop_polling, so this is the bad case where stop's set must "win"
917
+ # despite happening between start's clear and start's spawn-decision).
918
+ polling_provider.stop_polling_for_definitions!
919
+
920
+ # Let start finish its fetch and reach the lifecycle check.
921
+ fetch_release << true
922
+ starter.join
923
+
924
+ # No polling thread should have been spawned — stop's postcondition
925
+ # (polling is off when stop returns) must hold even when start was
926
+ # mid-fetch at the time of the stop call.
927
+ expect(polling_provider.instance_variable_get(:@polling_thread)).to be_nil
928
+ ensure
929
+ fetch_release << true
930
+ starter&.join
931
+ polling_provider.stop_polling_for_definitions!
932
+ end
933
+ end
934
+ end
935
+
936
+ describe 'service account credentials' do
937
+ it 'uses service account credentials for authentication' do
938
+ credentials = Mixpanel::ServiceAccountCredentials.new('test-user', 'test-secret', 'test-project')
939
+ flag = create_test_flag
940
+
941
+ stub_request(:get, endpoint_url_regex)
942
+ .with(
943
+ basic_auth: ['test-user', 'test-secret']
944
+ )
945
+ .to_return(
946
+ status: 200,
947
+ body: { code: 200, flags: [flag] }.to_json,
948
+ headers: { 'Content-Type' => 'application/json' }
949
+ )
950
+
951
+ credentials_provider = Mixpanel::Flags::LocalFlagsProvider.new(
952
+ test_token,
953
+ config,
954
+ mock_tracker,
955
+ mock_error_handler,
956
+ credentials
957
+ )
958
+
959
+ credentials_provider.start_polling_for_definitions!
960
+ result = credentials_provider.get_variant_value('test_flag', 'fallback', test_context, report_exposure: false)
961
+
962
+ expect(result).not_to eq('fallback')
963
+ credentials_provider.stop_polling_for_definitions!
964
+ end
758
965
  end
759
966
  end
@@ -1,6 +1,7 @@
1
1
  require 'json'
2
2
  require 'mixpanel-ruby/flags/remote_flags_provider'
3
3
  require 'mixpanel-ruby/flags/types'
4
+ require 'mixpanel-ruby/credentials'
4
5
  require 'webmock/rspec'
5
6
 
6
7
  describe Mixpanel::Flags::RemoteFlagsProvider do
@@ -17,6 +18,7 @@ describe Mixpanel::Flags::RemoteFlagsProvider do
17
18
  config,
18
19
  mock_tracker,
19
20
  mock_error_handler
21
+ # credentials defaults to nil
20
22
  )
21
23
  end
22
24
 
@@ -438,4 +440,38 @@ describe Mixpanel::Flags::RemoteFlagsProvider do
438
440
  provider.send(:track_exposure_event, 'test_flag', variant, test_context)
439
441
  end
440
442
  end
443
+
444
+ describe 'service account credentials' do
445
+ it 'uses service account credentials for authentication' do
446
+ credentials = Mixpanel::ServiceAccountCredentials.new('test-user', 'test-secret', 'test-project')
447
+
448
+ response = create_success_response({
449
+ 'test_flag' => {
450
+ 'variant_key' => 'treatment',
451
+ 'variant_value' => 'treatment'
452
+ }
453
+ })
454
+
455
+ stub_request(:get, endpoint_url_regex)
456
+ .with(
457
+ basic_auth: ['test-user', 'test-secret']
458
+ )
459
+ .to_return(
460
+ status: 200,
461
+ body: response.to_json,
462
+ headers: { 'Content-Type' => 'application/json' }
463
+ )
464
+
465
+ credentials_provider = Mixpanel::Flags::RemoteFlagsProvider.new(
466
+ test_token,
467
+ config,
468
+ mock_tracker,
469
+ mock_error_handler,
470
+ credentials
471
+ )
472
+
473
+ result = credentials_provider.get_variant_value('test_flag', 'fallback', test_context, report_exposure: false)
474
+ expect(result).to eq('treatment')
475
+ end
476
+ end
441
477
  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
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.1.0
4
+ version: 3.2.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-04-09 00:00:00.000000000 Z
11
+ date: 2026-07-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: mutex_m
@@ -170,9 +170,16 @@ executables: []
170
170
  extensions: []
171
171
  extra_rdoc_files: []
172
172
  files:
173
+ - ".github/dependabot.yml"
174
+ - ".github/modules.json"
175
+ - ".github/scripts/generate-changelog.sh"
176
+ - ".github/workflows/pr-title-check.yml"
177
+ - ".github/workflows/prepare-release.yml"
178
+ - ".github/workflows/release-rubygems.yml"
173
179
  - ".github/workflows/ruby.yml"
174
180
  - ".gitignore"
175
181
  - ".rspec"
182
+ - CHANGELOG.md
176
183
  - Gemfile
177
184
  - LICENSE
178
185
  - Rakefile
@@ -184,6 +191,7 @@ files:
184
191
  - demo/simple_messages.rb
185
192
  - lib/mixpanel-ruby.rb
186
193
  - lib/mixpanel-ruby/consumer.rb
194
+ - lib/mixpanel-ruby/credentials.rb
187
195
  - lib/mixpanel-ruby/error.rb
188
196
  - lib/mixpanel-ruby/events.rb
189
197
  - lib/mixpanel-ruby/flags/flags_provider.rb
@@ -196,15 +204,19 @@ files:
196
204
  - lib/mixpanel-ruby/tracker.rb
197
205
  - lib/mixpanel-ruby/version.rb
198
206
  - mixpanel-ruby.gemspec
207
+ - openfeature-provider/CHANGELOG.md
199
208
  - openfeature-provider/Gemfile
200
209
  - openfeature-provider/README.md
201
210
  - openfeature-provider/RELEASE.md
202
211
  - openfeature-provider/lib/mixpanel/openfeature.rb
203
212
  - openfeature-provider/lib/mixpanel/openfeature/provider.rb
213
+ - openfeature-provider/lib/mixpanel/openfeature/version.rb
204
214
  - openfeature-provider/mixpanel-ruby-openfeature.gemspec
205
215
  - openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb
206
216
  - openfeature-provider/spec/spec_helper.rb
207
217
  - spec/mixpanel-ruby/consumer_spec.rb
218
+ - spec/mixpanel-ruby/credentials_security_spec.rb
219
+ - spec/mixpanel-ruby/credentials_spec.rb
208
220
  - spec/mixpanel-ruby/error_spec.rb
209
221
  - spec/mixpanel-ruby/events_spec.rb
210
222
  - spec/mixpanel-ruby/flags/local_flags_spec.rb