mixpanel-ruby 2.3.0 → 3.1.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 +66 -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 +115 -0
- data/lib/mixpanel-ruby/flags/local_flags_provider.rb +300 -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/openfeature-provider/Gemfile +7 -0
- data/openfeature-provider/README.md +286 -0
- data/openfeature-provider/RELEASE.md +52 -0
- data/openfeature-provider/lib/mixpanel/openfeature/provider.rb +170 -0
- data/openfeature-provider/lib/mixpanel/openfeature.rb +3 -0
- data/openfeature-provider/mixpanel-ruby-openfeature.gemspec +23 -0
- data/openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb +606 -0
- data/openfeature-provider/spec/spec_helper.rb +23 -0
- 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 +125 -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: 6d3a5c579488b2cbe1c3b2c6e489070a0f438dca91b8e5f91d863709c517c978
|
|
4
|
+
data.tar.gz: '089600f43e3c29ef4f83d129dd1f8f58682ae041b1ffe776fad3ff3036140a5e'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 81511ab17dfe9e72ffeb81a97752d82a581297dc002aa722ee50e2d12a5faa835f7dc198ea1be5b2b62cec7cb117fd8906177b427d8e8e9eddfab21f24c5d930
|
|
7
|
+
data.tar.gz: 97bac85d2cb9edd8dc4655f3657a42ff4a1c56fcb0f6fd983da8bb5b44baec1c684a63afd16f5612a02c545e53dd84d07853416f8aab79a0ddd00ac88dfbe2dd
|
|
@@ -0,0 +1,66 @@
|
|
|
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # 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@75cd11691c0faa626561e295848008c8a7dddffe # v5
|
|
35
|
+
with:
|
|
36
|
+
token: ${{ secrets.CODECOV_TOKEN }}
|
|
37
|
+
slug: mixpanel/mixpanel-ruby
|
|
38
|
+
|
|
39
|
+
test-openfeature:
|
|
40
|
+
|
|
41
|
+
runs-on: ubuntu-latest
|
|
42
|
+
strategy:
|
|
43
|
+
matrix:
|
|
44
|
+
ruby-version:
|
|
45
|
+
- '3.1'
|
|
46
|
+
- '3.2'
|
|
47
|
+
- '3.3'
|
|
48
|
+
- '3.4'
|
|
49
|
+
|
|
50
|
+
steps:
|
|
51
|
+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
|
52
|
+
- name: Set up Ruby
|
|
53
|
+
uses: ruby/setup-ruby@d697be2f83c6234b20877c3b5eac7a7f342f0d0c # v1.269.0
|
|
54
|
+
with:
|
|
55
|
+
ruby-version: ${{ matrix.ruby-version }}
|
|
56
|
+
- name: Install dependencies
|
|
57
|
+
run: cd openfeature-provider && bundle install
|
|
58
|
+
- name: Run OpenFeature provider tests
|
|
59
|
+
run: cd openfeature-provider && bundle exec rspec
|
|
60
|
+
- name: Upload coverage reports to Codecov
|
|
61
|
+
uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5
|
|
62
|
+
with:
|
|
63
|
+
token: ${{ secrets.CODECOV_TOKEN }}
|
|
64
|
+
slug: mixpanel/mixpanel-ruby
|
|
65
|
+
flags: openfeature
|
|
66
|
+
directory: openfeature-provider
|
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,115 @@
|
|
|
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
|
+
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
64
|
+
raise ConnectionError.new("Request timeout: #{e.message}")
|
|
65
|
+
rescue StandardError => e
|
|
66
|
+
raise ConnectionError.new("Network error: #{e.message}")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
unless response.code == '200'
|
|
70
|
+
raise ServerError.new("HTTP #{response.code}: #{response.body}")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
begin
|
|
74
|
+
JSON.parse(response.body)
|
|
75
|
+
rescue JSON::ParserError => e
|
|
76
|
+
raise ServerError.new("Invalid JSON response: #{e.message}")
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def shutdown; end
|
|
81
|
+
|
|
82
|
+
# Track exposure event to Mixpanel
|
|
83
|
+
# @param flag_key [String] Feature flag key
|
|
84
|
+
# @param selected_variant [SelectedVariant] The selected variant
|
|
85
|
+
# @param context [Hash] User context (must include 'distinct_id')
|
|
86
|
+
# @param latency_ms [Integer, nil] Optional latency in milliseconds
|
|
87
|
+
def track_exposure_event(flag_key, selected_variant, context, latency_ms = nil)
|
|
88
|
+
distinct_id = context['distinct_id'] || context[:distinct_id]
|
|
89
|
+
|
|
90
|
+
unless distinct_id
|
|
91
|
+
return
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
properties = {
|
|
95
|
+
'distinct_id' => distinct_id,
|
|
96
|
+
'Experiment name' => flag_key,
|
|
97
|
+
'Variant name' => selected_variant.variant_key,
|
|
98
|
+
'$experiment_type' => 'feature_flag',
|
|
99
|
+
'Flag evaluation mode' => @evaluation_mode
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
properties['Variant fetch latency (ms)'] = latency_ms if latency_ms
|
|
103
|
+
properties['$experiment_id'] = selected_variant.experiment_id if selected_variant.experiment_id
|
|
104
|
+
properties['$is_experiment_active'] = selected_variant.is_experiment_active unless selected_variant.is_experiment_active.nil?
|
|
105
|
+
properties['$is_qa_tester'] = selected_variant.is_qa_tester unless selected_variant.is_qa_tester.nil?
|
|
106
|
+
|
|
107
|
+
begin
|
|
108
|
+
@tracker_callback.call(distinct_id, Utils::EXPOSURE_EVENT, properties)
|
|
109
|
+
rescue MixpanelError => e
|
|
110
|
+
@error_handler.handle(e)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,300 @@
|
|
|
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
|
+
def shutdown
|
|
68
|
+
stop_polling_for_definitions!
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Check if flag is enabled (for boolean flags)
|
|
72
|
+
# @param flag_key [String] Feature flag key
|
|
73
|
+
# @param context [Hash] Evaluation context (must include 'distinct_id')
|
|
74
|
+
# @return [Boolean]
|
|
75
|
+
def is_enabled?(flag_key, context)
|
|
76
|
+
value = get_variant_value(flag_key, false, context)
|
|
77
|
+
value == true
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Get variant value for a flag
|
|
81
|
+
# @param flag_key [String] Feature flag key
|
|
82
|
+
# @param fallback_value [Object] Fallback value if not in rollout
|
|
83
|
+
# @param context [Hash] Evaluation context
|
|
84
|
+
# @param report_exposure [Boolean] Whether to track exposure event
|
|
85
|
+
# @return [Object] The variant value
|
|
86
|
+
def get_variant_value(flag_key, fallback_value, context, report_exposure: true)
|
|
87
|
+
result = get_variant(
|
|
88
|
+
flag_key,
|
|
89
|
+
SelectedVariant.new(variant_value: fallback_value),
|
|
90
|
+
context,
|
|
91
|
+
report_exposure: report_exposure
|
|
92
|
+
)
|
|
93
|
+
result.variant_value
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Get complete variant information
|
|
97
|
+
# @param flag_key [String] Feature flag key
|
|
98
|
+
# @param fallback_variant [SelectedVariant] Fallback variant
|
|
99
|
+
# @param context [Hash] Evaluation context
|
|
100
|
+
# @param report_exposure [Boolean] Whether to track exposure event
|
|
101
|
+
# @return [SelectedVariant]
|
|
102
|
+
def get_variant(flag_key, fallback_variant, context, report_exposure: true)
|
|
103
|
+
flag = @flag_definitions[flag_key]
|
|
104
|
+
|
|
105
|
+
return fallback_variant unless flag
|
|
106
|
+
|
|
107
|
+
context_key = flag['context']
|
|
108
|
+
unless context.key?(context_key) || context.key?(context_key.to_sym)
|
|
109
|
+
return fallback_variant
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
context_value = context[context_key] || context[context_key.to_sym]
|
|
113
|
+
|
|
114
|
+
selected_variant = get_variant_override_for_test_user(flag, context)
|
|
115
|
+
|
|
116
|
+
unless selected_variant
|
|
117
|
+
rollout = get_assigned_rollout(flag, context_value, context)
|
|
118
|
+
selected_variant = get_assigned_variant(flag, context_value, flag_key, rollout) if rollout
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
return fallback_variant unless selected_variant
|
|
122
|
+
|
|
123
|
+
track_exposure_event(flag_key, selected_variant, context) if report_exposure
|
|
124
|
+
selected_variant
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Get all variants for user context
|
|
128
|
+
# Exposure events NOT tracked automatically
|
|
129
|
+
# @param context [Hash] Evaluation context
|
|
130
|
+
# @return [Hash] Map of flag_key => SelectedVariant
|
|
131
|
+
def get_all_variants(context)
|
|
132
|
+
variants = {}
|
|
133
|
+
|
|
134
|
+
@flag_definitions.each_key do |flag_key|
|
|
135
|
+
variant = get_variant(flag_key, nil, context, report_exposure: false)
|
|
136
|
+
variants[flag_key] = variant if variant
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
variants
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
def fetch_flag_definitions
|
|
145
|
+
response = call_flags_endpoint
|
|
146
|
+
|
|
147
|
+
new_definitions = (response['flags'] || []).each_with_object({}) do |flag_data, definitions|
|
|
148
|
+
definitions[flag_data['key']] = flag_data
|
|
149
|
+
end
|
|
150
|
+
@flag_definitions = new_definitions
|
|
151
|
+
|
|
152
|
+
response
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def get_variant_override_for_test_user(flag, context)
|
|
156
|
+
test_users = flag.dig('ruleset', 'test', 'users')
|
|
157
|
+
return nil unless test_users
|
|
158
|
+
|
|
159
|
+
distinct_id = context['distinct_id'] || context[:distinct_id]
|
|
160
|
+
return nil unless distinct_id
|
|
161
|
+
|
|
162
|
+
variant_key = test_users[distinct_id.to_s]
|
|
163
|
+
return nil unless variant_key
|
|
164
|
+
|
|
165
|
+
variant = get_matching_variant(variant_key, flag)
|
|
166
|
+
if variant
|
|
167
|
+
variant.is_qa_tester = true
|
|
168
|
+
end
|
|
169
|
+
variant
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def get_matching_variant(variant_key, flag)
|
|
173
|
+
variants = flag.dig('ruleset', 'variants')
|
|
174
|
+
return nil unless variants
|
|
175
|
+
|
|
176
|
+
variants.each do |v|
|
|
177
|
+
if variant_key.downcase == v['key'].downcase
|
|
178
|
+
return SelectedVariant.new(
|
|
179
|
+
variant_key: v['key'],
|
|
180
|
+
variant_value: v['value'],
|
|
181
|
+
experiment_id: flag['experiment_id'],
|
|
182
|
+
is_experiment_active: flag['is_experiment_active']
|
|
183
|
+
)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
nil
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def get_assigned_rollout(flag, context_value, context)
|
|
190
|
+
rollouts = flag.dig('ruleset', 'rollout')
|
|
191
|
+
return nil unless rollouts
|
|
192
|
+
|
|
193
|
+
rollouts.each_with_index do |rollout, index|
|
|
194
|
+
salt = if flag['hash_salt']
|
|
195
|
+
"#{flag['key']}#{flag['hash_salt']}#{index}"
|
|
196
|
+
else
|
|
197
|
+
"#{flag['key']}rollout"
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
rollout_hash = Utils.normalized_hash(context_value.to_s, salt)
|
|
201
|
+
|
|
202
|
+
if rollout_hash < rollout['rollout_percentage'] &&
|
|
203
|
+
is_runtime_evaluation_satisfied?(rollout, context)
|
|
204
|
+
return rollout
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
nil
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def get_assigned_variant(flag, context_value, flag_key, rollout)
|
|
212
|
+
if rollout['variant_override']
|
|
213
|
+
variant = get_matching_variant(rollout['variant_override']['key'], flag)
|
|
214
|
+
if variant
|
|
215
|
+
variant.is_qa_tester = false
|
|
216
|
+
return variant
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
stored_salt = flag['hash_salt'] || ''
|
|
221
|
+
salt = "#{flag_key}#{stored_salt}variant"
|
|
222
|
+
variant_hash = Utils.normalized_hash(context_value.to_s, salt)
|
|
223
|
+
|
|
224
|
+
variants = flag['ruleset']['variants'].map(&:dup)
|
|
225
|
+
if rollout['variant_splits']
|
|
226
|
+
variants.each do |v|
|
|
227
|
+
v['split'] = rollout['variant_splits'][v['key']] if rollout['variant_splits'].key?(v['key'])
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
selected = variants.first
|
|
232
|
+
cumulative = 0.0
|
|
233
|
+
variants.each do |v|
|
|
234
|
+
selected = v
|
|
235
|
+
cumulative += (v['split'] || 0.0)
|
|
236
|
+
break if variant_hash < cumulative
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
SelectedVariant.new(
|
|
240
|
+
variant_key: selected['key'],
|
|
241
|
+
variant_value: selected['value'],
|
|
242
|
+
experiment_id: flag['experiment_id'],
|
|
243
|
+
is_experiment_active: flag['is_experiment_active'],
|
|
244
|
+
is_qa_tester: false
|
|
245
|
+
)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def lowercase_keys_and_values(val)
|
|
249
|
+
case val
|
|
250
|
+
when String
|
|
251
|
+
val.downcase
|
|
252
|
+
when Array
|
|
253
|
+
val.map { |item| lowercase_keys_and_values(item) }
|
|
254
|
+
when Hash
|
|
255
|
+
val.transform_keys { |k| k.is_a?(String) ? k.downcase : k }
|
|
256
|
+
.transform_values { |v| lowercase_keys_and_values(v) }
|
|
257
|
+
else
|
|
258
|
+
val
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def lowercase_only_leaf_nodes(val)
|
|
263
|
+
case val
|
|
264
|
+
when String
|
|
265
|
+
val.downcase
|
|
266
|
+
when Array
|
|
267
|
+
val.map { |item| lowercase_only_leaf_nodes(item) }
|
|
268
|
+
when Hash
|
|
269
|
+
val.transform_values { |v| lowercase_only_leaf_nodes(v) }
|
|
270
|
+
else
|
|
271
|
+
val
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def get_runtime_parameters(context)
|
|
276
|
+
custom_props = context['custom_properties'] || context[:custom_properties]
|
|
277
|
+
return nil unless custom_props && custom_props.is_a?(Hash)
|
|
278
|
+
|
|
279
|
+
lowercase_keys_and_values(custom_props)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def is_runtime_evaluation_satisfied?(rollout, context)
|
|
283
|
+
runtime_rule = rollout['runtime_evaluation_rule']
|
|
284
|
+
return true unless runtime_rule
|
|
285
|
+
|
|
286
|
+
parameters = get_runtime_parameters(context)
|
|
287
|
+
return false unless parameters
|
|
288
|
+
|
|
289
|
+
begin
|
|
290
|
+
rule = lowercase_only_leaf_nodes(runtime_rule)
|
|
291
|
+
result = JsonLogic.apply(rule, parameters)
|
|
292
|
+
!!result
|
|
293
|
+
rescue StandardError => e
|
|
294
|
+
@error_handler.handle(e) if @error_handler
|
|
295
|
+
false
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
end
|