mixpanel-ruby 2.3.0 → 3.0.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/workflows/ruby.yml +37 -0
- data/Readme.rdoc +8 -2
- data/demo/flags/local_flags.rb +25 -0
- data/demo/flags/remote_flags.rb +18 -0
- data/lib/mixpanel-ruby/events.rb +2 -2
- data/lib/mixpanel-ruby/flags/flags_provider.rb +111 -0
- data/lib/mixpanel-ruby/flags/local_flags_provider.rb +303 -0
- data/lib/mixpanel-ruby/flags/remote_flags_provider.rb +134 -0
- data/lib/mixpanel-ruby/flags/types.rb +35 -0
- data/lib/mixpanel-ruby/flags/utils.rb +65 -0
- data/lib/mixpanel-ruby/groups.rb +1 -1
- data/lib/mixpanel-ruby/people.rb +1 -1
- data/lib/mixpanel-ruby/tracker.rb +32 -2
- data/lib/mixpanel-ruby/version.rb +1 -1
- data/lib/mixpanel-ruby.rb +5 -0
- data/mixpanel-ruby.gemspec +10 -3
- data/spec/mixpanel-ruby/events_spec.rb +2 -2
- data/spec/mixpanel-ruby/flags/local_flags_spec.rb +759 -0
- data/spec/mixpanel-ruby/flags/remote_flags_spec.rb +441 -0
- data/spec/mixpanel-ruby/flags/utils_spec.rb +110 -0
- data/spec/mixpanel-ruby/groups_spec.rb +10 -10
- data/spec/mixpanel-ruby/tracker_spec.rb +5 -5
- data/spec/spec_helper.rb +14 -0
- metadata +117 -9
- data/.travis.yml +0 -8
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 99f6ea359c1b7a0f4248035f2ebe9a13380ee647b20722f73f49f45b3bb62ea3
|
|
4
|
+
data.tar.gz: c1dc8bf0e37d90a397654b263059458291eff144987b87bc3a9dcc153649c42e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cee44f99c3d4000ee9eb64a0353abcee02bec2a422d0a27c9cda160c8bb50db3f6abe72c550730633c719a51eb06f92dbba5e38cc17f7266a82bf8561488005e
|
|
7
|
+
data.tar.gz: db60ba774c9e32e2bb4199d46a3c192a40d35d3aaef84fdb87c0e29a7a66f76b4f4dbd0c6e2f28d5d7145844fadb91b9ccefb4bd82d2d9e2f287153182fcf176
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
name: Ruby
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [ "master" ]
|
|
6
|
+
pull_request: {}
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
test:
|
|
13
|
+
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
strategy:
|
|
16
|
+
matrix:
|
|
17
|
+
ruby-version:
|
|
18
|
+
- '3.0'
|
|
19
|
+
- '3.1'
|
|
20
|
+
- '3.2'
|
|
21
|
+
- '3.3'
|
|
22
|
+
- '3.4'
|
|
23
|
+
|
|
24
|
+
steps:
|
|
25
|
+
- uses: actions/checkout@v6
|
|
26
|
+
- name: Set up Ruby
|
|
27
|
+
uses: ruby/setup-ruby@d697be2f83c6234b20877c3b5eac7a7f342f0d0c # v1.269.0
|
|
28
|
+
with:
|
|
29
|
+
ruby-version: ${{ matrix.ruby-version }}
|
|
30
|
+
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
|
|
31
|
+
- name: Run tests
|
|
32
|
+
run: bundle exec rake
|
|
33
|
+
- name: Upload coverage reports to Codecov
|
|
34
|
+
uses: codecov/codecov-action@v5
|
|
35
|
+
with:
|
|
36
|
+
token: ${{ secrets.CODECOV_TOKEN }}
|
|
37
|
+
slug: mixpanel/mixpanel-ruby
|
data/Readme.rdoc
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
{<img src="https://travis-ci.org/mixpanel/mixpanel-ruby.svg?branch=master" alt="Build Status" />}[https://travis-ci.org/mixpanel/mixpanel-ruby]
|
|
2
|
-
|
|
3
1
|
= mixpanel-ruby: The official Mixpanel Ruby library
|
|
4
2
|
|
|
5
3
|
mixpanel-ruby is a library for tracking events and sending \Mixpanel profile
|
|
@@ -49,6 +47,14 @@ In particular, for Rails apps, the following projects are currently actively mai
|
|
|
49
47
|
|
|
50
48
|
== Changes
|
|
51
49
|
|
|
50
|
+
== 2.3.1
|
|
51
|
+
* Convert timestamps to milliseconds and update Ruby compatibility
|
|
52
|
+
|
|
53
|
+
== 2.3.0
|
|
54
|
+
* Clear submitted slices during BufferedConsumer#flush
|
|
55
|
+
* Groups analytics support
|
|
56
|
+
* use millisecond precision for time properties
|
|
57
|
+
|
|
52
58
|
== 2.2.2
|
|
53
59
|
* Add Group Analytics support with Mixpanel::Groups
|
|
54
60
|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
require 'mixpanel-ruby'
|
|
2
|
+
|
|
3
|
+
# Configuration
|
|
4
|
+
PROJECT_TOKEN = ""
|
|
5
|
+
FLAG_KEY = "sample-flag"
|
|
6
|
+
FLAG_FALLBACK_VARIANT = "control"
|
|
7
|
+
USER_CONTEXT = { "distinct_id" => "ruby-demo-user" }
|
|
8
|
+
API_HOST = "api.mixpanel.com"
|
|
9
|
+
SHOULD_POLL_CONTINUOUSLY = true
|
|
10
|
+
POLLING_INTERVAL_SECONDS = 15
|
|
11
|
+
|
|
12
|
+
local_config = {
|
|
13
|
+
api_host: API_HOST,
|
|
14
|
+
enable_polling: SHOULD_POLL_CONTINUOUSLY,
|
|
15
|
+
polling_interval_in_seconds: POLLING_INTERVAL_SECONDS
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
tracker = Mixpanel::Tracker.new(PROJECT_TOKEN, local_flags_config: local_config)
|
|
19
|
+
|
|
20
|
+
tracker.local_flags.start_polling_for_definitions!
|
|
21
|
+
|
|
22
|
+
variant_value = tracker.local_flags.get_variant_value(FLAG_KEY, FLAG_FALLBACK_VARIANT, USER_CONTEXT)
|
|
23
|
+
puts "Variant value: #{variant_value}"
|
|
24
|
+
|
|
25
|
+
tracker.local_flags.stop_polling_for_definitions!
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
require 'mixpanel-ruby'
|
|
2
|
+
|
|
3
|
+
# Configuration
|
|
4
|
+
PROJECT_TOKEN = ""
|
|
5
|
+
FLAG_KEY = "sample-flag"
|
|
6
|
+
FLAG_FALLBACK_VARIANT = "control"
|
|
7
|
+
USER_CONTEXT = { "distinct_id" => "ruby-demo-user" }
|
|
8
|
+
API_HOST = "api.mixpanel.com"
|
|
9
|
+
|
|
10
|
+
remote_config = {
|
|
11
|
+
api_host: API_HOST
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
tracker = Mixpanel::Tracker.new(PROJECT_TOKEN, remote_flags_config: remote_config)
|
|
15
|
+
|
|
16
|
+
variant_value = tracker.remote_flags.get_variant_value(FLAG_KEY, FLAG_FALLBACK_VARIANT, USER_CONTEXT)
|
|
17
|
+
puts "Variant value: #{variant_value}"
|
|
18
|
+
|
data/lib/mixpanel-ruby/events.rb
CHANGED
|
@@ -55,7 +55,7 @@ module Mixpanel
|
|
|
55
55
|
properties = {
|
|
56
56
|
'distinct_id' => distinct_id,
|
|
57
57
|
'token' => @token,
|
|
58
|
-
'time' => Time.now.to_f,
|
|
58
|
+
'time' => (Time.now.to_f * 1000).to_i,
|
|
59
59
|
'mp_lib' => 'ruby',
|
|
60
60
|
'$lib_version' => Mixpanel::VERSION,
|
|
61
61
|
}.merge(properties)
|
|
@@ -103,7 +103,7 @@ module Mixpanel
|
|
|
103
103
|
properties = {
|
|
104
104
|
'distinct_id' => distinct_id,
|
|
105
105
|
'token' => @token,
|
|
106
|
-
'time' => Time.now.to_f,
|
|
106
|
+
'time' => (Time.now.to_f * 1000).to_i,
|
|
107
107
|
'mp_lib' => 'ruby',
|
|
108
108
|
'$lib_version' => Mixpanel::VERSION,
|
|
109
109
|
}.merge(properties)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
require 'net/https'
|
|
2
|
+
require 'json'
|
|
3
|
+
require 'uri'
|
|
4
|
+
require 'mixpanel-ruby/version'
|
|
5
|
+
require 'mixpanel-ruby/error'
|
|
6
|
+
require 'mixpanel-ruby/flags/utils'
|
|
7
|
+
require 'mixpanel-ruby/flags/types'
|
|
8
|
+
|
|
9
|
+
module Mixpanel
|
|
10
|
+
module Flags
|
|
11
|
+
|
|
12
|
+
# Base class for feature flags providers
|
|
13
|
+
# Provides common HTTP handling and exposure event tracking
|
|
14
|
+
class FlagsProvider
|
|
15
|
+
# @param provider_config [Hash] Configuration with :token, :api_host, :request_timeout_in_seconds
|
|
16
|
+
# @param endpoint [String] API endpoint path (e.g., '/flags' or '/flags/definitions')
|
|
17
|
+
# @param tracker_callback [Proc] Function used to track events (bound tracker.track method)
|
|
18
|
+
# @param evaluation_mode [String] The feature flag evaluation mode. This is either 'local' or 'remote'
|
|
19
|
+
# @param error_handler [Mixpanel::ErrorHandler] Error handler instance
|
|
20
|
+
def initialize(provider_config, endpoint, tracker_callback, evaluation_mode, error_handler)
|
|
21
|
+
@provider_config = provider_config
|
|
22
|
+
@endpoint = endpoint
|
|
23
|
+
@tracker_callback = tracker_callback
|
|
24
|
+
@evaluation_mode = evaluation_mode
|
|
25
|
+
@error_handler = error_handler
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Make HTTP request to flags API endpoint
|
|
29
|
+
# @param additional_params [Hash, nil] Additional query parameters
|
|
30
|
+
# @return [Hash] Parsed JSON response
|
|
31
|
+
# @raise [Mixpanel::ConnectionError] on network errors
|
|
32
|
+
# @raise [Mixpanel::ServerError] on HTTP errors
|
|
33
|
+
def call_flags_endpoint(additional_params = nil)
|
|
34
|
+
common_params = Utils.prepare_common_query_params(
|
|
35
|
+
@provider_config[:token],
|
|
36
|
+
Mixpanel::VERSION
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
params = common_params.merge(additional_params || {})
|
|
40
|
+
query_string = URI.encode_www_form(params)
|
|
41
|
+
|
|
42
|
+
uri = URI::HTTPS.build(
|
|
43
|
+
host: @provider_config[:api_host],
|
|
44
|
+
path: @endpoint,
|
|
45
|
+
query: query_string
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
49
|
+
|
|
50
|
+
http.use_ssl = true
|
|
51
|
+
http.open_timeout = @provider_config[:request_timeout_in_seconds]
|
|
52
|
+
http.read_timeout = @provider_config[:request_timeout_in_seconds]
|
|
53
|
+
|
|
54
|
+
request = Net::HTTP::Get.new(uri.request_uri)
|
|
55
|
+
|
|
56
|
+
request.basic_auth(@provider_config[:token], '')
|
|
57
|
+
|
|
58
|
+
request['Content-Type'] = 'application/json'
|
|
59
|
+
request['traceparent'] = Utils.generate_traceparent()
|
|
60
|
+
|
|
61
|
+
begin
|
|
62
|
+
response = http.request(request)
|
|
63
|
+
|
|
64
|
+
unless response.code.to_i == 200
|
|
65
|
+
raise ServerError.new("HTTP #{response.code}: #{response.body}")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
JSON.parse(response.body)
|
|
69
|
+
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
70
|
+
raise ConnectionError.new("Request timeout: #{e.message}")
|
|
71
|
+
rescue JSON::ParserError => e
|
|
72
|
+
raise ServerError.new("Invalid JSON response: #{e.message}")
|
|
73
|
+
rescue StandardError => e
|
|
74
|
+
raise ConnectionError.new("Network error: #{e.message}")
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Track exposure event to Mixpanel
|
|
79
|
+
# @param flag_key [String] Feature flag key
|
|
80
|
+
# @param selected_variant [SelectedVariant] The selected variant
|
|
81
|
+
# @param context [Hash] User context (must include 'distinct_id')
|
|
82
|
+
# @param latency_ms [Integer, nil] Optional latency in milliseconds
|
|
83
|
+
def track_exposure_event(flag_key, selected_variant, context, latency_ms = nil)
|
|
84
|
+
distinct_id = context['distinct_id'] || context[:distinct_id]
|
|
85
|
+
|
|
86
|
+
unless distinct_id
|
|
87
|
+
return
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
properties = {
|
|
91
|
+
'distinct_id' => distinct_id,
|
|
92
|
+
'Experiment name' => flag_key,
|
|
93
|
+
'Variant name' => selected_variant.variant_key,
|
|
94
|
+
'$experiment_type' => 'feature_flag',
|
|
95
|
+
'Flag evaluation mode' => @evaluation_mode
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
properties['Variant fetch latency (ms)'] = latency_ms if latency_ms
|
|
99
|
+
properties['$experiment_id'] = selected_variant.experiment_id if selected_variant.experiment_id
|
|
100
|
+
properties['$is_experiment_active'] = selected_variant.is_experiment_active unless selected_variant.is_experiment_active.nil?
|
|
101
|
+
properties['$is_qa_tester'] = selected_variant.is_qa_tester unless selected_variant.is_qa_tester.nil?
|
|
102
|
+
|
|
103
|
+
begin
|
|
104
|
+
@tracker_callback.call(distinct_id, Utils::EXPOSURE_EVENT, properties)
|
|
105
|
+
rescue MixpanelError => e
|
|
106
|
+
@error_handler.handle(e)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
require 'thread'
|
|
2
|
+
require 'json_logic'
|
|
3
|
+
require 'mixpanel-ruby/flags/flags_provider'
|
|
4
|
+
|
|
5
|
+
module Mixpanel
|
|
6
|
+
module Flags
|
|
7
|
+
# Local feature flags provider
|
|
8
|
+
# Evaluates flags client-side with cached flag definitions
|
|
9
|
+
class LocalFlagsProvider < FlagsProvider
|
|
10
|
+
DEFAULT_CONFIG = {
|
|
11
|
+
api_host: 'api.mixpanel.com',
|
|
12
|
+
request_timeout_in_seconds: 10,
|
|
13
|
+
enable_polling: true,
|
|
14
|
+
polling_interval_in_seconds: 60
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
# @param token [String] Mixpanel project token
|
|
18
|
+
# @param config [Hash] Local flags configuration
|
|
19
|
+
# @param tracker_callback [Proc] Callback to track events
|
|
20
|
+
# @param error_handler [Mixpanel::ErrorHandler] Error handler
|
|
21
|
+
def initialize(token, config, tracker_callback, error_handler)
|
|
22
|
+
@config = DEFAULT_CONFIG.merge(config || {})
|
|
23
|
+
|
|
24
|
+
provider_config = {
|
|
25
|
+
token: token,
|
|
26
|
+
api_host: @config[:api_host],
|
|
27
|
+
request_timeout_in_seconds: @config[:request_timeout_in_seconds]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
super(provider_config, '/flags/definitions', tracker_callback, 'local', error_handler)
|
|
31
|
+
|
|
32
|
+
@flag_definitions = {}
|
|
33
|
+
@polling_thread = nil
|
|
34
|
+
@stop_polling = false
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Start polling for flag definitions
|
|
38
|
+
# Fetches immediately, then at regular intervals if polling enabled
|
|
39
|
+
def start_polling_for_definitions!
|
|
40
|
+
fetch_flag_definitions
|
|
41
|
+
|
|
42
|
+
if @config[:enable_polling] && !@polling_thread
|
|
43
|
+
@stop_polling = false
|
|
44
|
+
@polling_thread = Thread.new do
|
|
45
|
+
loop do
|
|
46
|
+
sleep @config[:polling_interval_in_seconds]
|
|
47
|
+
break if @stop_polling
|
|
48
|
+
|
|
49
|
+
begin
|
|
50
|
+
fetch_flag_definitions
|
|
51
|
+
rescue StandardError => e
|
|
52
|
+
@error_handler.handle(e) if @error_handler
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
rescue StandardError => e
|
|
58
|
+
@error_handler.handle(e) if @error_handler
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def stop_polling_for_definitions!
|
|
62
|
+
@stop_polling = true
|
|
63
|
+
@polling_thread&.join
|
|
64
|
+
@polling_thread = nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Check if flag is enabled (for boolean flags)
|
|
68
|
+
# @param flag_key [String] Feature flag key
|
|
69
|
+
# @param context [Hash] Evaluation context (must include 'distinct_id')
|
|
70
|
+
# @return [Boolean]
|
|
71
|
+
def is_enabled?(flag_key, context)
|
|
72
|
+
value = get_variant_value(flag_key, false, context)
|
|
73
|
+
value == true
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Get variant value for a flag
|
|
77
|
+
# @param flag_key [String] Feature flag key
|
|
78
|
+
# @param fallback_value [Object] Fallback value if not in rollout
|
|
79
|
+
# @param context [Hash] Evaluation context
|
|
80
|
+
# @param report_exposure [Boolean] Whether to track exposure event
|
|
81
|
+
# @return [Object] The variant value
|
|
82
|
+
def get_variant_value(flag_key, fallback_value, context, report_exposure: true)
|
|
83
|
+
result = get_variant(
|
|
84
|
+
flag_key,
|
|
85
|
+
SelectedVariant.new(variant_value: fallback_value),
|
|
86
|
+
context,
|
|
87
|
+
report_exposure: report_exposure
|
|
88
|
+
)
|
|
89
|
+
result.variant_value
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Get complete variant information
|
|
93
|
+
# @param flag_key [String] Feature flag key
|
|
94
|
+
# @param fallback_variant [SelectedVariant] Fallback variant
|
|
95
|
+
# @param context [Hash] Evaluation context
|
|
96
|
+
# @param report_exposure [Boolean] Whether to track exposure event
|
|
97
|
+
# @return [SelectedVariant]
|
|
98
|
+
def get_variant(flag_key, fallback_variant, context, report_exposure: true)
|
|
99
|
+
flag = @flag_definitions[flag_key]
|
|
100
|
+
|
|
101
|
+
return fallback_variant unless flag
|
|
102
|
+
|
|
103
|
+
context_key = flag['context']
|
|
104
|
+
unless context.key?(context_key) || context.key?(context_key.to_sym)
|
|
105
|
+
return fallback_variant
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
context_value = context[context_key] || context[context_key.to_sym]
|
|
109
|
+
|
|
110
|
+
selected_variant = nil
|
|
111
|
+
|
|
112
|
+
test_variant = get_variant_override_for_test_user(flag, context)
|
|
113
|
+
if test_variant
|
|
114
|
+
selected_variant = test_variant
|
|
115
|
+
else
|
|
116
|
+
rollout = get_assigned_rollout(flag, context_value, context)
|
|
117
|
+
if rollout
|
|
118
|
+
selected_variant = get_assigned_variant(flag, context_value, flag_key, rollout)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
if selected_variant
|
|
123
|
+
track_exposure_event(flag_key, selected_variant, context) if report_exposure
|
|
124
|
+
return selected_variant
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
fallback_variant
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Get all variants for user context
|
|
131
|
+
# Exposure events NOT tracked automatically
|
|
132
|
+
# @param context [Hash] Evaluation context
|
|
133
|
+
# @return [Hash] Map of flag_key => SelectedVariant
|
|
134
|
+
def get_all_variants(context)
|
|
135
|
+
variants = {}
|
|
136
|
+
|
|
137
|
+
@flag_definitions.each_key do |flag_key|
|
|
138
|
+
variant = get_variant(flag_key, nil, context, report_exposure: false)
|
|
139
|
+
variants[flag_key] = variant if variant
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
variants
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
def fetch_flag_definitions
|
|
148
|
+
response = call_flags_endpoint
|
|
149
|
+
|
|
150
|
+
new_definitions = {}
|
|
151
|
+
(response['flags'] || []).each do |flag_data|
|
|
152
|
+
new_definitions[flag_data['key']] = flag_data
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
@flag_definitions = new_definitions
|
|
156
|
+
|
|
157
|
+
response
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def get_variant_override_for_test_user(flag, context)
|
|
161
|
+
test_users = flag.dig('ruleset', 'test', 'users')
|
|
162
|
+
return nil unless test_users
|
|
163
|
+
|
|
164
|
+
distinct_id = context['distinct_id'] || context[:distinct_id]
|
|
165
|
+
return nil unless distinct_id
|
|
166
|
+
|
|
167
|
+
variant_key = test_users[distinct_id.to_s]
|
|
168
|
+
return nil unless variant_key
|
|
169
|
+
|
|
170
|
+
variant = get_matching_variant(variant_key, flag)
|
|
171
|
+
if variant
|
|
172
|
+
variant.is_qa_tester = true
|
|
173
|
+
end
|
|
174
|
+
variant
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def get_matching_variant(variant_key, flag)
|
|
178
|
+
return nil unless flag['ruleset'] && flag['ruleset']['variants']
|
|
179
|
+
|
|
180
|
+
flag['ruleset']['variants'].each do |v|
|
|
181
|
+
if variant_key.downcase == v['key'].downcase
|
|
182
|
+
return SelectedVariant.new(
|
|
183
|
+
variant_key: v['key'],
|
|
184
|
+
variant_value: v['value'],
|
|
185
|
+
experiment_id: flag['experiment_id'],
|
|
186
|
+
is_experiment_active: flag['is_experiment_active']
|
|
187
|
+
)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
nil
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def get_assigned_rollout(flag, context_value, context)
|
|
194
|
+
return nil unless flag['ruleset'] && flag['ruleset']['rollout']
|
|
195
|
+
|
|
196
|
+
flag['ruleset']['rollout'].each_with_index do |rollout, index|
|
|
197
|
+
salt = if flag['hash_salt']
|
|
198
|
+
"#{flag['key']}#{flag['hash_salt']}#{index}"
|
|
199
|
+
else
|
|
200
|
+
"#{flag['key']}rollout"
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
rollout_hash = Utils.normalized_hash(context_value.to_s, salt)
|
|
204
|
+
|
|
205
|
+
if rollout_hash < rollout['rollout_percentage'] &&
|
|
206
|
+
is_runtime_evaluation_satisfied?(rollout, context)
|
|
207
|
+
return rollout
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
nil
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def get_assigned_variant(flag, context_value, flag_key, rollout)
|
|
215
|
+
if rollout['variant_override']
|
|
216
|
+
variant = get_matching_variant(rollout['variant_override']['key'], flag)
|
|
217
|
+
if variant
|
|
218
|
+
variant.is_qa_tester = false
|
|
219
|
+
return variant
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
stored_salt = flag['hash_salt'] || ''
|
|
224
|
+
salt = "#{flag_key}#{stored_salt}variant"
|
|
225
|
+
variant_hash = Utils.normalized_hash(context_value.to_s, salt)
|
|
226
|
+
|
|
227
|
+
variants = flag['ruleset']['variants'].map { |v| v.dup }
|
|
228
|
+
if rollout['variant_splits']
|
|
229
|
+
variants.each do |v|
|
|
230
|
+
v['split'] = rollout['variant_splits'][v['key']] if rollout['variant_splits'].key?(v['key'])
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
selected = variants.first
|
|
235
|
+
cumulative = 0.0
|
|
236
|
+
variants.each do |v|
|
|
237
|
+
selected = v
|
|
238
|
+
cumulative += (v['split'] || 0.0)
|
|
239
|
+
break if variant_hash < cumulative
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
SelectedVariant.new(
|
|
243
|
+
variant_key: selected['key'],
|
|
244
|
+
variant_value: selected['value'],
|
|
245
|
+
experiment_id: flag['experiment_id'],
|
|
246
|
+
is_experiment_active: flag['is_experiment_active'],
|
|
247
|
+
is_qa_tester: false
|
|
248
|
+
)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def lowercase_keys_and_values(val)
|
|
252
|
+
case val
|
|
253
|
+
when String
|
|
254
|
+
val.downcase
|
|
255
|
+
when Array
|
|
256
|
+
val.map { |item| lowercase_keys_and_values(item) }
|
|
257
|
+
when Hash
|
|
258
|
+
val.transform_keys { |k| k.is_a?(String) ? k.downcase : k }
|
|
259
|
+
.transform_values { |v| lowercase_keys_and_values(v) }
|
|
260
|
+
else
|
|
261
|
+
val
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def lowercase_only_leaf_nodes(val)
|
|
266
|
+
case val
|
|
267
|
+
when String
|
|
268
|
+
val.downcase
|
|
269
|
+
when Array
|
|
270
|
+
val.map { |item| lowercase_only_leaf_nodes(item) }
|
|
271
|
+
when Hash
|
|
272
|
+
val.transform_values { |v| lowercase_only_leaf_nodes(v) }
|
|
273
|
+
else
|
|
274
|
+
val
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def get_runtime_parameters(context)
|
|
279
|
+
custom_props = context['custom_properties'] || context[:custom_properties]
|
|
280
|
+
return nil unless custom_props && custom_props.is_a?(Hash)
|
|
281
|
+
|
|
282
|
+
lowercase_keys_and_values(custom_props)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def is_runtime_evaluation_satisfied?(rollout, context)
|
|
286
|
+
runtime_rule = rollout['runtime_evaluation_rule']
|
|
287
|
+
return true unless runtime_rule
|
|
288
|
+
|
|
289
|
+
parameters = get_runtime_parameters(context)
|
|
290
|
+
return false unless parameters
|
|
291
|
+
|
|
292
|
+
begin
|
|
293
|
+
rule = lowercase_only_leaf_nodes(runtime_rule)
|
|
294
|
+
result = JsonLogic.apply(rule, parameters)
|
|
295
|
+
!!result
|
|
296
|
+
rescue StandardError => e
|
|
297
|
+
@error_handler.handle(e) if @error_handler
|
|
298
|
+
false
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
require 'mixpanel-ruby/flags/flags_provider'
|
|
2
|
+
|
|
3
|
+
module Mixpanel
|
|
4
|
+
module Flags
|
|
5
|
+
# Remote feature flags provider
|
|
6
|
+
# Evaluates flags on the server-side via HTTP API calls
|
|
7
|
+
class RemoteFlagsProvider < FlagsProvider
|
|
8
|
+
DEFAULT_CONFIG = {
|
|
9
|
+
api_host: 'api.mixpanel.com',
|
|
10
|
+
request_timeout_in_seconds: 10
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
# @param token [String] Mixpanel project token
|
|
14
|
+
# @param config [Hash] Remote flags configuration
|
|
15
|
+
# @param tracker_callback [Proc] Callback to track events
|
|
16
|
+
# @param error_handler [Mixpanel::ErrorHandler] Error handler
|
|
17
|
+
def initialize(token, config, tracker_callback, error_handler)
|
|
18
|
+
merged_config = DEFAULT_CONFIG.merge(config || {})
|
|
19
|
+
|
|
20
|
+
provider_config = {
|
|
21
|
+
token: token,
|
|
22
|
+
api_host: merged_config[:api_host],
|
|
23
|
+
request_timeout_in_seconds: merged_config[:request_timeout_in_seconds]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
super(provider_config, '/flags', tracker_callback, 'remote', error_handler)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Get variant value for a flag
|
|
30
|
+
# @param flag_key [String] Feature flag key
|
|
31
|
+
# @param fallback_value [Object] Fallback value
|
|
32
|
+
# @param context [Hash] Evaluation context
|
|
33
|
+
# @param report_exposure [Boolean] Whether to track exposure
|
|
34
|
+
# @return [Object] Variant value
|
|
35
|
+
def get_variant_value(flag_key, fallback_value, context, report_exposure: true)
|
|
36
|
+
selected_variant = get_variant(
|
|
37
|
+
flag_key,
|
|
38
|
+
SelectedVariant.new(variant_value: fallback_value),
|
|
39
|
+
context,
|
|
40
|
+
report_exposure: report_exposure
|
|
41
|
+
)
|
|
42
|
+
selected_variant.variant_value
|
|
43
|
+
rescue MixpanelError => e
|
|
44
|
+
@error_handler.handle(e)
|
|
45
|
+
fallback_value
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Get complete variant information
|
|
49
|
+
# @param flag_key [String] Feature flag key
|
|
50
|
+
# @param fallback_variant [SelectedVariant] Fallback variant
|
|
51
|
+
# @param context [Hash] Evaluation context
|
|
52
|
+
# @param report_exposure [Boolean] Whether to track exposure
|
|
53
|
+
# @return [SelectedVariant]
|
|
54
|
+
def get_variant(flag_key, fallback_variant, context, report_exposure: true)
|
|
55
|
+
start_time = Time.now
|
|
56
|
+
response = fetch_flags(context, flag_key)
|
|
57
|
+
latency_ms = ((Time.now - start_time) * 1000).to_i
|
|
58
|
+
|
|
59
|
+
flags = response['flags'] || {}
|
|
60
|
+
selected_variant_data = flags[flag_key]
|
|
61
|
+
|
|
62
|
+
return fallback_variant unless selected_variant_data
|
|
63
|
+
|
|
64
|
+
selected_variant = SelectedVariant.new(
|
|
65
|
+
variant_key: selected_variant_data['variant_key'],
|
|
66
|
+
variant_value: selected_variant_data['variant_value'],
|
|
67
|
+
experiment_id: selected_variant_data['experiment_id'],
|
|
68
|
+
is_experiment_active: selected_variant_data['is_experiment_active']
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
track_exposure_event(flag_key, selected_variant, context, latency_ms) if report_exposure
|
|
72
|
+
|
|
73
|
+
return selected_variant
|
|
74
|
+
rescue MixpanelError => e
|
|
75
|
+
@error_handler.handle(e)
|
|
76
|
+
return fallback_variant
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Check if flag is enabled (for boolean flags)
|
|
80
|
+
# This method is intended only for flags defined as Mixpanel Feature Gates (boolean flags)
|
|
81
|
+
# This checks that the variant value of a selected variant is concretely the boolean 'true'
|
|
82
|
+
# It does not coerce other truthy values.
|
|
83
|
+
# @param flag_key [String] Feature flag key
|
|
84
|
+
# @param context [Hash] Evaluation context
|
|
85
|
+
# @return [Boolean]
|
|
86
|
+
def is_enabled?(flag_key, context)
|
|
87
|
+
value = get_variant_value(flag_key, false, context)
|
|
88
|
+
value == true
|
|
89
|
+
rescue MixpanelError => e
|
|
90
|
+
@error_handler.handle(e)
|
|
91
|
+
false
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Get all variants for user context
|
|
95
|
+
# Exposure events NOT tracked automatically
|
|
96
|
+
# @param context [Hash] Evaluation context
|
|
97
|
+
# @return [Hash, nil] Map of flag_key => SelectedVariant, or nil on error
|
|
98
|
+
def get_all_variants(context)
|
|
99
|
+
response = fetch_flags(context)
|
|
100
|
+
|
|
101
|
+
variants = {}
|
|
102
|
+
(response['flags'] || {}).each do |flag_key, variant_data|
|
|
103
|
+
variants[flag_key] = SelectedVariant.new(
|
|
104
|
+
variant_key: variant_data['variant_key'],
|
|
105
|
+
variant_value: variant_data['variant_value'],
|
|
106
|
+
experiment_id: variant_data['experiment_id'],
|
|
107
|
+
is_experiment_active: variant_data['is_experiment_active']
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
variants
|
|
112
|
+
rescue MixpanelError => e
|
|
113
|
+
@error_handler.handle(e)
|
|
114
|
+
nil
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
# Fetch flags from remote API
|
|
120
|
+
# @param context [Hash] Evaluation context
|
|
121
|
+
# @param flag_key [String, nil] Optional specific flag key
|
|
122
|
+
# @return [Hash] API response
|
|
123
|
+
def fetch_flags(context, flag_key = nil)
|
|
124
|
+
additional_params = {
|
|
125
|
+
'context' => JSON.generate(context)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
additional_params['flag_key'] = flag_key if flag_key
|
|
129
|
+
|
|
130
|
+
call_flags_endpoint(additional_params)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|