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
|
@@ -7,20 +7,24 @@ module Mixpanel
|
|
|
7
7
|
class RemoteFlagsProvider < FlagsProvider
|
|
8
8
|
DEFAULT_CONFIG = {
|
|
9
9
|
api_host: 'api.mixpanel.com',
|
|
10
|
-
request_timeout_in_seconds: 10
|
|
10
|
+
request_timeout_in_seconds: 10,
|
|
11
|
+
exposure_executor: nil
|
|
11
12
|
}.freeze
|
|
12
13
|
|
|
13
14
|
# @param token [String] Mixpanel project token
|
|
14
15
|
# @param config [Hash] Remote flags configuration
|
|
15
16
|
# @param tracker_callback [Proc] Callback to track events
|
|
16
17
|
# @param error_handler [Mixpanel::ErrorHandler] Error handler
|
|
17
|
-
|
|
18
|
+
# @param credentials [ServiceAccountCredentials, nil] Optional service account credentials
|
|
19
|
+
def initialize(token, config, tracker_callback, error_handler, credentials = nil)
|
|
18
20
|
merged_config = DEFAULT_CONFIG.merge(config || {})
|
|
19
21
|
|
|
20
22
|
provider_config = {
|
|
21
23
|
token: token,
|
|
22
24
|
api_host: merged_config[:api_host],
|
|
23
|
-
request_timeout_in_seconds: merged_config[:request_timeout_in_seconds]
|
|
25
|
+
request_timeout_in_seconds: merged_config[:request_timeout_in_seconds],
|
|
26
|
+
exposure_executor: merged_config[:exposure_executor],
|
|
27
|
+
credentials: credentials
|
|
24
28
|
}
|
|
25
29
|
|
|
26
30
|
super(provider_config, '/flags', tracker_callback, 'remote', error_handler)
|
|
@@ -59,13 +63,18 @@ module Mixpanel
|
|
|
59
63
|
flags = response['flags'] || {}
|
|
60
64
|
selected_variant_data = flags[flag_key]
|
|
61
65
|
|
|
62
|
-
|
|
66
|
+
# The /flags endpoint only returns variants the user is enrolled in,
|
|
67
|
+
# so a missing key could mean the flag doesn't exist OR the user
|
|
68
|
+
# isn't in any rollout. The remote SDK can't tell them apart without
|
|
69
|
+
# server-side help — surface as FLAG_NOT_FOUND for now.
|
|
70
|
+
return fallback_variant.as_fallback(FallbackReason.flag_not_found) unless selected_variant_data
|
|
63
71
|
|
|
64
72
|
selected_variant = SelectedVariant.new(
|
|
65
73
|
variant_key: selected_variant_data['variant_key'],
|
|
66
74
|
variant_value: selected_variant_data['variant_value'],
|
|
67
75
|
experiment_id: selected_variant_data['experiment_id'],
|
|
68
|
-
is_experiment_active: selected_variant_data['is_experiment_active']
|
|
76
|
+
is_experiment_active: selected_variant_data['is_experiment_active'],
|
|
77
|
+
variant_source: VariantSource::REMOTE
|
|
69
78
|
)
|
|
70
79
|
|
|
71
80
|
track_exposure_event(flag_key, selected_variant, context, latency_ms) if report_exposure
|
|
@@ -73,7 +82,12 @@ module Mixpanel
|
|
|
73
82
|
return selected_variant
|
|
74
83
|
rescue MixpanelError => e
|
|
75
84
|
@error_handler.handle(e)
|
|
76
|
-
|
|
85
|
+
# Attach the backend's message so the OpenFeature wrapper can forward
|
|
86
|
+
# it into ResolutionDetails#error_message — without this the caller
|
|
87
|
+
# sees a bare GENERAL error and has to dig through logs to find out
|
|
88
|
+
# the backend rejected the request (e.g. "distinct_id must be
|
|
89
|
+
# provided in evalContext as a string"). SDK-83.
|
|
90
|
+
return fallback_variant.as_fallback(FallbackReason.backend_error(e.message))
|
|
77
91
|
end
|
|
78
92
|
|
|
79
93
|
# Check if flag is enabled (for boolean flags)
|
|
@@ -104,7 +118,8 @@ module Mixpanel
|
|
|
104
118
|
variant_key: variant_data['variant_key'],
|
|
105
119
|
variant_value: variant_data['variant_value'],
|
|
106
120
|
experiment_id: variant_data['experiment_id'],
|
|
107
|
-
is_experiment_active: variant_data['is_experiment_active']
|
|
121
|
+
is_experiment_active: variant_data['is_experiment_active'],
|
|
122
|
+
variant_source: VariantSource::REMOTE
|
|
108
123
|
)
|
|
109
124
|
end
|
|
110
125
|
|
|
@@ -1,35 +1,114 @@
|
|
|
1
1
|
module Mixpanel
|
|
2
2
|
module Flags
|
|
3
|
+
# Where a SelectedVariant came from. Set by the providers on every returned
|
|
4
|
+
# variant — coarse-grained (local / remote / fallback). For the specific
|
|
5
|
+
# reason behind a fallback, see {FallbackReason}.
|
|
6
|
+
module VariantSource
|
|
7
|
+
LOCAL = 'local'.freeze
|
|
8
|
+
REMOTE = 'remote'.freeze
|
|
9
|
+
FALLBACK = 'fallback'.freeze
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Why the SDK returned the developer fallback. Only meaningful when
|
|
13
|
+
# SelectedVariant#variant_source == VariantSource::FALLBACK.
|
|
14
|
+
#
|
|
15
|
+
# `kind` is the discriminator (matches the PHP constant set). `message`
|
|
16
|
+
# is set on the reasons that carry useful detail (BACKEND_ERROR with the
|
|
17
|
+
# backend's response, MISSING_CONTEXT_KEY with the missing attribute);
|
|
18
|
+
# nil otherwise. The OpenFeature wrapper dispatches on kind and forwards
|
|
19
|
+
# message into ResolutionDetails#error_message.
|
|
20
|
+
class FallbackReason
|
|
21
|
+
KINDS = %i[flag_not_found missing_context_key no_rollout_match backend_error].freeze
|
|
22
|
+
|
|
23
|
+
attr_reader :kind, :message
|
|
24
|
+
|
|
25
|
+
def initialize(kind, message: nil)
|
|
26
|
+
raise ArgumentError, "Unknown FallbackReason kind: #{kind.inspect}" unless KINDS.include?(kind)
|
|
27
|
+
|
|
28
|
+
@kind = kind
|
|
29
|
+
@message = message
|
|
30
|
+
freeze
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def ==(other)
|
|
34
|
+
other.is_a?(FallbackReason) && other.kind == @kind && other.message == @message
|
|
35
|
+
end
|
|
36
|
+
alias_method :eql?, :==
|
|
37
|
+
|
|
38
|
+
def hash
|
|
39
|
+
[self.class, @kind, @message].hash
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def to_h
|
|
43
|
+
{ kind: @kind, message: @message }.compact
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Factory methods. Reasons without meaningful detail return a frozen
|
|
47
|
+
# singleton; reasons with detail allocate per call.
|
|
48
|
+
def self.flag_not_found; FLAG_NOT_FOUND; end
|
|
49
|
+
def self.no_rollout_match; NO_ROLLOUT_MATCH; end
|
|
50
|
+
def self.missing_context_key(key = nil); new(:missing_context_key, message: key); end
|
|
51
|
+
def self.backend_error(message); new(:backend_error, message: message); end
|
|
52
|
+
|
|
53
|
+
FLAG_NOT_FOUND = new(:flag_not_found)
|
|
54
|
+
NO_ROLLOUT_MATCH = new(:no_rollout_match)
|
|
55
|
+
end
|
|
56
|
+
|
|
3
57
|
# Selected variant returned from flag evaluation
|
|
4
58
|
class SelectedVariant
|
|
5
59
|
attr_accessor :variant_key, :variant_value, :experiment_id,
|
|
6
|
-
:is_experiment_active, :is_qa_tester
|
|
60
|
+
:is_experiment_active, :is_qa_tester,
|
|
61
|
+
:variant_source, :fallback_reason
|
|
7
62
|
|
|
8
|
-
# @param variant_key [String, nil] The variant key
|
|
9
|
-
# @param variant_value [Object] The variant value (any type)
|
|
10
|
-
# @param experiment_id [String, nil] Associated experiment ID
|
|
11
|
-
# @param is_experiment_active [Boolean, nil] Whether experiment is active
|
|
12
|
-
# @param is_qa_tester [Boolean, nil] Whether user is a QA tester
|
|
13
63
|
def initialize(variant_key: nil, variant_value: nil, experiment_id: nil,
|
|
14
|
-
is_experiment_active: nil, is_qa_tester: nil
|
|
64
|
+
is_experiment_active: nil, is_qa_tester: nil,
|
|
65
|
+
variant_source: nil, fallback_reason: nil)
|
|
15
66
|
@variant_key = variant_key
|
|
16
67
|
@variant_value = variant_value
|
|
17
68
|
@experiment_id = experiment_id
|
|
18
69
|
@is_experiment_active = is_experiment_active
|
|
19
70
|
@is_qa_tester = is_qa_tester
|
|
71
|
+
@variant_source = variant_source
|
|
72
|
+
@fallback_reason = fallback_reason
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Return a copy of this variant tagged with the given source. Clears
|
|
76
|
+
# fallback_reason — use {#as_fallback} when returning a fallback.
|
|
77
|
+
def with_source(source)
|
|
78
|
+
copy_with(variant_source: source, fallback_reason: nil)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Return a copy tagged as a fallback with the given reason.
|
|
82
|
+
def as_fallback(reason)
|
|
83
|
+
copy_with(variant_source: VariantSource::FALLBACK, fallback_reason: reason)
|
|
20
84
|
end
|
|
21
85
|
|
|
22
86
|
# Convert to hash representation
|
|
23
|
-
# @return [Hash]
|
|
24
87
|
def to_h
|
|
25
88
|
{
|
|
26
89
|
variant_key: @variant_key,
|
|
27
90
|
variant_value: @variant_value,
|
|
28
91
|
experiment_id: @experiment_id,
|
|
29
92
|
is_experiment_active: @is_experiment_active,
|
|
30
|
-
is_qa_tester: @is_qa_tester
|
|
93
|
+
is_qa_tester: @is_qa_tester,
|
|
94
|
+
variant_source: @variant_source,
|
|
95
|
+
fallback_reason: @fallback_reason&.to_h
|
|
31
96
|
}.compact
|
|
32
97
|
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def copy_with(variant_source: @variant_source, fallback_reason: @fallback_reason)
|
|
102
|
+
SelectedVariant.new(
|
|
103
|
+
variant_key: @variant_key,
|
|
104
|
+
variant_value: @variant_value,
|
|
105
|
+
experiment_id: @experiment_id,
|
|
106
|
+
is_experiment_active: @is_experiment_active,
|
|
107
|
+
is_qa_tester: @is_qa_tester,
|
|
108
|
+
variant_source: variant_source,
|
|
109
|
+
fallback_reason: fallback_reason
|
|
110
|
+
)
|
|
111
|
+
end
|
|
33
112
|
end
|
|
34
113
|
end
|
|
35
114
|
end
|
|
@@ -62,9 +62,14 @@ module Mixpanel
|
|
|
62
62
|
# If a block is provided, it is passed a type (one of :event or :profile_update)
|
|
63
63
|
# and a string message. This same format is accepted by Mixpanel::Consumer#send!
|
|
64
64
|
# and Mixpanel::BufferedConsumer#send!
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
65
|
+
#
|
|
66
|
+
# Optional parameters:
|
|
67
|
+
# - credentials: ServiceAccountCredentials for authentication (used for import and feature flags)
|
|
68
|
+
# - local_flags_config: Configuration hash for local feature flags
|
|
69
|
+
# - remote_flags_config: Configuration hash for remote feature flags
|
|
70
|
+
def initialize(token, error_handler=nil, credentials: nil, local_flags_config: nil, remote_flags_config: nil, &block)
|
|
71
|
+
super(token, error_handler, credentials: credentials, &block)
|
|
72
|
+
|
|
68
73
|
@people = People.new(token, error_handler, &block)
|
|
69
74
|
@groups = Groups.new(token, error_handler, &block)
|
|
70
75
|
|
|
@@ -74,7 +79,8 @@ module Mixpanel
|
|
|
74
79
|
token,
|
|
75
80
|
local_flags_config,
|
|
76
81
|
method(:track), # Pass bound method as callback
|
|
77
|
-
error_handler || ErrorHandler.new
|
|
82
|
+
error_handler || ErrorHandler.new,
|
|
83
|
+
credentials
|
|
78
84
|
)
|
|
79
85
|
end
|
|
80
86
|
|
|
@@ -84,7 +90,8 @@ module Mixpanel
|
|
|
84
90
|
token,
|
|
85
91
|
remote_flags_config,
|
|
86
92
|
method(:track), # Pass bound method as callback
|
|
87
|
-
error_handler || ErrorHandler.new
|
|
93
|
+
error_handler || ErrorHandler.new,
|
|
94
|
+
credentials
|
|
88
95
|
)
|
|
89
96
|
end
|
|
90
97
|
end
|
|
@@ -122,14 +129,15 @@ module Mixpanel
|
|
|
122
129
|
#
|
|
123
130
|
# tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN)
|
|
124
131
|
#
|
|
125
|
-
# #
|
|
132
|
+
# # Using deprecated API key (still supported)
|
|
126
133
|
# tracker.import("API_KEY", "12345", "Credit Card Declined", {
|
|
127
134
|
# 'time' => 1310111365
|
|
128
135
|
# })
|
|
129
136
|
#
|
|
130
|
-
# #
|
|
131
|
-
#
|
|
132
|
-
# tracker.
|
|
137
|
+
# # Using service account credentials (recommended)
|
|
138
|
+
# credentials = Mixpanel::ServiceAccountCredentials.new(username, secret, project_id)
|
|
139
|
+
# tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN, credentials: credentials)
|
|
140
|
+
# tracker.import(nil, "12345", "Welcome Email Sent", {
|
|
133
141
|
# 'Email Template' => 'Pretty Pink Welcome',
|
|
134
142
|
# 'User Sign-up Cohort' => 'July 2013',
|
|
135
143
|
# 'time' => 1310111365
|
|
@@ -140,6 +148,28 @@ module Mixpanel
|
|
|
140
148
|
super
|
|
141
149
|
end
|
|
142
150
|
|
|
151
|
+
# Import an event using service account credentials from the constructor.
|
|
152
|
+
# This is the recommended method for importing historical events with service accounts.
|
|
153
|
+
#
|
|
154
|
+
# Service account credentials must be provided when creating the Tracker.
|
|
155
|
+
# This method provides a cleaner API than import() as it doesn't require
|
|
156
|
+
# passing nil as the first parameter.
|
|
157
|
+
#
|
|
158
|
+
# credentials = Mixpanel::ServiceAccountCredentials.new(username, secret, project_id)
|
|
159
|
+
# tracker = Mixpanel::Tracker.new(YOUR_MIXPANEL_TOKEN, credentials: credentials)
|
|
160
|
+
#
|
|
161
|
+
# # Import a historical event
|
|
162
|
+
# tracker.import_events('user123', 'Past Event', {
|
|
163
|
+
# 'Email Template' => 'Welcome Email',
|
|
164
|
+
# 'time' => 1369353600
|
|
165
|
+
# })
|
|
166
|
+
#
|
|
167
|
+
def import_events(distinct_id, event, properties={}, ip=nil)
|
|
168
|
+
# This is here strictly to allow rdoc to include the relevant
|
|
169
|
+
# documentation
|
|
170
|
+
super
|
|
171
|
+
end
|
|
172
|
+
|
|
143
173
|
# Creates a distinct_id alias. \Events and updates with an alias
|
|
144
174
|
# will be considered by mixpanel to have the same source, and
|
|
145
175
|
# refer to the same profile.
|
data/lib/mixpanel-ruby.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
require 'mixpanel-ruby/consumer.rb'
|
|
2
2
|
require 'mixpanel-ruby/tracker.rb'
|
|
3
3
|
require 'mixpanel-ruby/version.rb'
|
|
4
|
+
require 'mixpanel-ruby/credentials.rb'
|
|
4
5
|
require 'mixpanel-ruby/flags/utils.rb'
|
|
5
6
|
require 'mixpanel-ruby/flags/types.rb'
|
|
6
7
|
require 'mixpanel-ruby/flags/flags_provider.rb'
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# mixpanel-ruby-openfeature
|
|
2
2
|
|
|
3
|
+
##### _May 13, 2026_ - [openfeature/v0.1.0](https://github.com/mixpanel/mixpanel-ruby/releases/tag/openfeature/v0.1.0)
|
|
4
|
+
|
|
3
5
|
[](https://rubygems.org/gems/mixpanel-ruby-openfeature)
|
|
4
6
|
[](https://openfeature.dev/)
|
|
5
7
|
[](https://github.com/mixpanel/mixpanel-ruby/blob/master/LICENSE)
|
|
@@ -199,6 +201,34 @@ When you are done using the provider, shut it down to stop any background pollin
|
|
|
199
201
|
provider.shutdown
|
|
200
202
|
```
|
|
201
203
|
|
|
204
|
+
## Async Exposure Tracking
|
|
205
|
+
|
|
206
|
+
By default, every flag evaluation tracks an exposure event inline — the `/track` HTTP round trip happens on the calling thread before `fetch_*_value` returns. For latency-sensitive code paths, pass an `:exposure_executor` so exposure tracking runs off-thread. The executor is duck-typed — anything that responds to `#post(&block)` works:
|
|
207
|
+
|
|
208
|
+
```ruby
|
|
209
|
+
# With concurrent-ruby (recommended)
|
|
210
|
+
require 'concurrent'
|
|
211
|
+
exposure_executor = Concurrent::FixedThreadPool.new(1)
|
|
212
|
+
|
|
213
|
+
provider = Mixpanel::OpenFeature::Provider.from_local(
|
|
214
|
+
'YOUR_PROJECT_TOKEN',
|
|
215
|
+
{ exposure_executor: exposure_executor },
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
# Without concurrent-ruby (minimal wrapper)
|
|
219
|
+
class ThreadPerCall
|
|
220
|
+
def post(&block) = Thread.new(&block)
|
|
221
|
+
end
|
|
222
|
+
provider = Mixpanel::OpenFeature::Provider.from_local(
|
|
223
|
+
'YOUR_PROJECT_TOKEN',
|
|
224
|
+
{ exposure_executor: ThreadPerCall.new },
|
|
225
|
+
)
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Available on both local and remote flag configs. Defaults to `nil` (inline behavior); existing setups are unaffected.
|
|
229
|
+
|
|
230
|
+
> **Executor lifecycle:** `provider.shutdown` stops the flags provider's own resources (polling) but does NOT shut down a user-supplied executor. Own the executor's lifecycle explicitly — e.g. `at_exit { exposure_executor.shutdown; exposure_executor.wait_for_termination(5) }` for `Concurrent::FixedThreadPool`. Skipping this can leave background threads alive at process exit and delay shutdown.
|
|
231
|
+
|
|
202
232
|
## Error Handling
|
|
203
233
|
|
|
204
234
|
The provider uses OpenFeature's standard error codes to indicate issues during flag evaluation:
|
|
@@ -69,16 +69,41 @@ module Mixpanel
|
|
|
69
69
|
|
|
70
70
|
begin
|
|
71
71
|
result = @flags_provider.get_variant(flag_key, fallback, context, report_exposure: true)
|
|
72
|
-
rescue StandardError
|
|
73
|
-
return error_result(default_value, ::OpenFeature::SDK::Provider::ErrorCode::GENERAL)
|
|
72
|
+
rescue StandardError => e
|
|
73
|
+
return error_result(default_value, ::OpenFeature::SDK::Provider::ErrorCode::GENERAL, e.message)
|
|
74
74
|
end
|
|
75
75
|
|
|
76
|
-
|
|
76
|
+
# variant_source distinguishes local / remote / fallback. When fallback,
|
|
77
|
+
# fallback_reason carries the specific reason (PHP-aligned constants)
|
|
78
|
+
# so we can map each to the spec-correct OpenFeature response instead
|
|
79
|
+
# of collapsing every fallback to FLAG_NOT_FOUND. SDK-83: BACKEND_ERROR
|
|
80
|
+
# also carries the backend message, forwarded as error_message.
|
|
81
|
+
case result.fallback_reason&.kind
|
|
82
|
+
when :flag_not_found
|
|
77
83
|
return ::OpenFeature::SDK::Provider::ResolutionDetails.new(
|
|
78
84
|
value: default_value,
|
|
79
85
|
error_code: ::OpenFeature::SDK::Provider::ErrorCode::FLAG_NOT_FOUND,
|
|
80
86
|
reason: ::OpenFeature::SDK::Provider::Reason::DEFAULT
|
|
81
87
|
)
|
|
88
|
+
when :missing_context_key
|
|
89
|
+
return error_result(
|
|
90
|
+
default_value,
|
|
91
|
+
::OpenFeature::SDK::Provider::ErrorCode::TARGETING_KEY_MISSING,
|
|
92
|
+
result.fallback_reason.message
|
|
93
|
+
)
|
|
94
|
+
when :no_rollout_match
|
|
95
|
+
# Flag exists, user just didn't match any rollout — per the
|
|
96
|
+
# OpenFeature spec this is `reason: DEFAULT` with no error.
|
|
97
|
+
return ::OpenFeature::SDK::Provider::ResolutionDetails.new(
|
|
98
|
+
value: default_value,
|
|
99
|
+
reason: ::OpenFeature::SDK::Provider::Reason::DEFAULT
|
|
100
|
+
)
|
|
101
|
+
when :backend_error
|
|
102
|
+
return error_result(
|
|
103
|
+
default_value,
|
|
104
|
+
::OpenFeature::SDK::Provider::ErrorCode::GENERAL,
|
|
105
|
+
result.fallback_reason.message
|
|
106
|
+
)
|
|
82
107
|
end
|
|
83
108
|
|
|
84
109
|
value = result.variant_value
|
|
@@ -158,10 +183,11 @@ module Mixpanel
|
|
|
158
183
|
end
|
|
159
184
|
end
|
|
160
185
|
|
|
161
|
-
def error_result(default_value, error_code)
|
|
186
|
+
def error_result(default_value, error_code, error_message = nil)
|
|
162
187
|
::OpenFeature::SDK::Provider::ResolutionDetails.new(
|
|
163
188
|
value: default_value,
|
|
164
189
|
error_code: error_code,
|
|
190
|
+
error_message: error_message,
|
|
165
191
|
reason: ::OpenFeature::SDK::Provider::Reason::ERROR
|
|
166
192
|
)
|
|
167
193
|
end
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require File.join(File.dirname(__FILE__), 'lib/mixpanel/openfeature/version.rb')
|
|
4
|
+
|
|
3
5
|
Gem::Specification.new do |spec|
|
|
4
6
|
spec.name = 'mixpanel-ruby-openfeature'
|
|
5
|
-
spec.version =
|
|
7
|
+
spec.version = Mixpanel::OpenFeature::VERSION
|
|
6
8
|
spec.authors = ['Mixpanel']
|
|
7
9
|
spec.email = 'support@mixpanel.com'
|
|
8
10
|
spec.summary = 'OpenFeature provider for Mixpanel feature flags'
|
|
@@ -75,15 +75,25 @@ RSpec.describe Mixpanel::OpenFeature::Provider do
|
|
|
75
75
|
def setup_flag(flag_key, value, variant_key: 'variant-key')
|
|
76
76
|
allow(mock_flags).to receive(:get_variant) do |key, fallback, _ctx, **_kwargs|
|
|
77
77
|
if key == flag_key
|
|
78
|
-
Mixpanel::Flags::SelectedVariant.new(
|
|
78
|
+
Mixpanel::Flags::SelectedVariant.new(
|
|
79
|
+
variant_key: variant_key,
|
|
80
|
+
variant_value: value,
|
|
81
|
+
variant_source: Mixpanel::Flags::VariantSource::LOCAL
|
|
82
|
+
)
|
|
79
83
|
else
|
|
80
|
-
fallback
|
|
84
|
+
fallback.as_fallback(Mixpanel::Flags::FallbackReason.flag_not_found)
|
|
81
85
|
end
|
|
82
86
|
end
|
|
83
87
|
end
|
|
84
88
|
|
|
89
|
+
def setup_fallback(reason)
|
|
90
|
+
allow(mock_flags).to receive(:get_variant) do |_key, fallback, _ctx, **_kwargs|
|
|
91
|
+
fallback.as_fallback(reason)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
85
95
|
def setup_flag_not_found
|
|
86
|
-
|
|
96
|
+
setup_fallback(Mixpanel::Flags::FallbackReason.flag_not_found)
|
|
87
97
|
end
|
|
88
98
|
|
|
89
99
|
# --- Metadata ---
|
|
@@ -305,6 +315,67 @@ RSpec.describe Mixpanel::OpenFeature::Provider do
|
|
|
305
315
|
end
|
|
306
316
|
end
|
|
307
317
|
|
|
318
|
+
# --- NO_ROLLOUT_MATCH (flag exists, no rollout matched) ---
|
|
319
|
+
|
|
320
|
+
describe 'no rollout match' do
|
|
321
|
+
before { setup_fallback(Mixpanel::Flags::FallbackReason.no_rollout_match) }
|
|
322
|
+
|
|
323
|
+
it 'returns DEFAULT reason without an error code' do
|
|
324
|
+
result = provider.fetch_boolean_value(flag_key: 'flag', default_value: true)
|
|
325
|
+
expect(result.value).to be true
|
|
326
|
+
expect(result.reason).to eq('DEFAULT')
|
|
327
|
+
expect(result.error_code).to be_nil
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
it 'works for strings' do
|
|
331
|
+
result = provider.fetch_string_value(flag_key: 'flag', default_value: 'default')
|
|
332
|
+
expect(result.value).to eq('default')
|
|
333
|
+
expect(result.reason).to eq('DEFAULT')
|
|
334
|
+
expect(result.error_code).to be_nil
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# --- MISSING_CONTEXT_KEY ---
|
|
339
|
+
|
|
340
|
+
describe 'missing context key' do
|
|
341
|
+
before { setup_fallback(Mixpanel::Flags::FallbackReason.missing_context_key('distinct_id')) }
|
|
342
|
+
|
|
343
|
+
it 'returns TARGETING_KEY_MISSING for boolean' do
|
|
344
|
+
result = provider.fetch_boolean_value(flag_key: 'flag', default_value: false)
|
|
345
|
+
expect(result.value).to be false
|
|
346
|
+
expect(result.error_code).to eq('TARGETING_KEY_MISSING')
|
|
347
|
+
expect(result.reason).to eq('ERROR')
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
it 'returns TARGETING_KEY_MISSING for string and forwards the missing key as error_message' do
|
|
351
|
+
result = provider.fetch_string_value(flag_key: 'flag', default_value: 'default')
|
|
352
|
+
expect(result.value).to eq('default')
|
|
353
|
+
expect(result.error_code).to eq('TARGETING_KEY_MISSING')
|
|
354
|
+
expect(result.error_message).to eq('distinct_id')
|
|
355
|
+
expect(result.reason).to eq('ERROR')
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# --- BACKEND_ERROR (SDK-83: forwards the backend's response message) ---
|
|
360
|
+
|
|
361
|
+
describe 'backend error' do
|
|
362
|
+
before do
|
|
363
|
+
setup_fallback(
|
|
364
|
+
Mixpanel::Flags::FallbackReason.backend_error(
|
|
365
|
+
'HTTP 400: distinct_id must be provided in evalContext as a string'
|
|
366
|
+
)
|
|
367
|
+
)
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
it 'maps to GENERAL and forwards the backend message as error_message' do
|
|
371
|
+
result = provider.fetch_string_value(flag_key: 'flag', default_value: 'default')
|
|
372
|
+
expect(result.value).to eq('default')
|
|
373
|
+
expect(result.error_code).to eq('GENERAL')
|
|
374
|
+
expect(result.reason).to eq('ERROR')
|
|
375
|
+
expect(result.error_message).to include('distinct_id must be provided')
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
|
|
308
379
|
# --- PROVIDER_NOT_READY ---
|
|
309
380
|
|
|
310
381
|
describe 'provider not ready' do
|
|
@@ -3,6 +3,7 @@ require 'spec_helper'
|
|
|
3
3
|
require 'webmock'
|
|
4
4
|
|
|
5
5
|
require 'mixpanel-ruby/consumer'
|
|
6
|
+
require 'mixpanel-ruby/credentials'
|
|
6
7
|
|
|
7
8
|
describe Mixpanel::Consumer do
|
|
8
9
|
before { WebMock.reset! }
|
|
@@ -95,6 +96,56 @@ describe Mixpanel::Consumer do
|
|
|
95
96
|
it_behaves_like 'consumer'
|
|
96
97
|
end
|
|
97
98
|
|
|
99
|
+
context 'service account credentials' do
|
|
100
|
+
it 'should send a request to api.mixpanel.com/import with service account credentials' do
|
|
101
|
+
stub_request(:any, 'https://api.mixpanel.com/import?project_id=test-project-123').to_return({:body => '{"status": 1, "error": null}'})
|
|
102
|
+
credentials = Mixpanel::ServiceAccountCredentials.new('test-user', 'test-secret', 'test-project-123')
|
|
103
|
+
consumer = Mixpanel::Consumer.new(nil, nil, nil, nil, credentials: credentials)
|
|
104
|
+
|
|
105
|
+
consumer.send!(:import, {'data' => 'TEST EVENT MESSAGE'}.to_json)
|
|
106
|
+
|
|
107
|
+
# Should use Basic Auth header with username:secret
|
|
108
|
+
# Should add project_id as query parameter
|
|
109
|
+
# Should NOT include credentials in POST body or message
|
|
110
|
+
expect(WebMock).to have_requested(:post, 'https://api.mixpanel.com/import?project_id=test-project-123').
|
|
111
|
+
with(
|
|
112
|
+
:body => {
|
|
113
|
+
'data' => 'IlRFU1QgRVZFTlQgTUVTU0FHRSI=',
|
|
114
|
+
'verbose' => '1'
|
|
115
|
+
},
|
|
116
|
+
:headers => {
|
|
117
|
+
'Authorization' => 'Basic ' + Base64.strict_encode64('test-user:test-secret')
|
|
118
|
+
}
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
it 'should raise ArgumentError when credentials is not ServiceAccountCredentials' do
|
|
123
|
+
consumer = Mixpanel::Consumer.new
|
|
124
|
+
|
|
125
|
+
expect {
|
|
126
|
+
consumer.request('https://api.mixpanel.com/import', {'data' => 'test', 'verbose' => '1'}, credentials: 'invalid', type: :import)
|
|
127
|
+
}.to raise_error(ArgumentError, /credentials must be ServiceAccountCredentials, got String/)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
it 'should not include api_key when credentials are present' do
|
|
131
|
+
stub_request(:any, 'https://api.mixpanel.com/import?project_id=test-project').to_return({:body => '{"status": 1, "error": null}'})
|
|
132
|
+
credentials = Mixpanel::ServiceAccountCredentials.new('user', 'secret', 'test-project')
|
|
133
|
+
consumer = Mixpanel::Consumer.new(nil, nil, nil, nil, credentials: credentials)
|
|
134
|
+
|
|
135
|
+
# Message includes api_key but it should be ignored when credentials are present
|
|
136
|
+
consumer.send!(:import, {'data' => 'TEST EVENT MESSAGE', 'api_key' => 'SHOULD_BE_IGNORED'}.to_json)
|
|
137
|
+
|
|
138
|
+
# api_key should NOT be in the request body (only data and verbose)
|
|
139
|
+
expect(WebMock).to have_requested(:post, 'https://api.mixpanel.com/import?project_id=test-project').
|
|
140
|
+
with(
|
|
141
|
+
:body => {
|
|
142
|
+
'data' => 'IlRFU1QgRVZFTlQgTUVTU0FHRSI=',
|
|
143
|
+
'verbose' => '1'
|
|
144
|
+
}
|
|
145
|
+
)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
98
149
|
end
|
|
99
150
|
|
|
100
151
|
describe Mixpanel::BufferedConsumer do
|
|
@@ -205,4 +256,34 @@ describe Mixpanel::BufferedConsumer do
|
|
|
205
256
|
end
|
|
206
257
|
end
|
|
207
258
|
|
|
259
|
+
context 'with service account credentials' do
|
|
260
|
+
it 'should pass credentials to consumer when using BufferedConsumer' do
|
|
261
|
+
stub_request(:any, 'https://api.mixpanel.com/import?project_id=buffered-project').to_return({:body => '{"status": 1, "error": null}'})
|
|
262
|
+
credentials = Mixpanel::ServiceAccountCredentials.new('buffered-user', 'buffered-secret', 'buffered-project')
|
|
263
|
+
consumer = Mixpanel::BufferedConsumer.new(nil, nil, nil, 2, credentials: credentials)
|
|
264
|
+
|
|
265
|
+
# Import messages are not buffered - they're sent immediately
|
|
266
|
+
consumer.send!(:import, {'data' => 'EVENT 1'}.to_json)
|
|
267
|
+
|
|
268
|
+
# Verify credentials were used in the request
|
|
269
|
+
expect(WebMock).to have_requested(:post, 'https://api.mixpanel.com/import?project_id=buffered-project').
|
|
270
|
+
with(
|
|
271
|
+
:headers => {
|
|
272
|
+
'Authorization' => 'Basic ' + Base64.strict_encode64('buffered-user:buffered-secret')
|
|
273
|
+
}
|
|
274
|
+
).once
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
describe 'Connection error handling' do
|
|
281
|
+
it 'should raise ConnectionError when network error occurs' do
|
|
282
|
+
stub_request(:any, 'https://api.mixpanel.com/track').to_raise(StandardError.new('Network timeout'))
|
|
283
|
+
consumer = Mixpanel::Consumer.new
|
|
284
|
+
|
|
285
|
+
expect {
|
|
286
|
+
consumer.send!(:event, {'data' => 'TEST EVENT MESSAGE'}.to_json)
|
|
287
|
+
}.to raise_error(Mixpanel::ConnectionError, /Could not connect to Mixpanel, with error "Network timeout"/)
|
|
288
|
+
end
|
|
208
289
|
end
|