amplitude-experiment 1.3.1 → 1.5.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/amplitude-experiment.gemspec +2 -1
- data/lib/amplitude-experiment.rb +13 -1
- data/lib/experiment/cohort/cohort.rb +25 -0
- data/lib/experiment/cohort/cohort_download_api.rb +90 -0
- data/lib/experiment/cohort/cohort_loader.rb +39 -0
- data/lib/experiment/cohort/cohort_storage.rb +91 -0
- data/lib/experiment/cohort/cohort_sync_config.rb +27 -0
- data/lib/experiment/deployment/deployment_runner.rb +135 -0
- data/lib/experiment/error.rb +37 -0
- data/lib/experiment/{local/fetcher.rb → flag/flag_config_fetcher.rb} +20 -3
- data/lib/experiment/flag/flag_config_storage.rb +53 -0
- data/lib/experiment/local/assignment/assignment.rb +3 -1
- data/lib/experiment/local/assignment/assignment_service.rb +15 -14
- data/lib/experiment/local/client.rb +83 -39
- data/lib/experiment/local/config.rb +26 -2
- data/lib/experiment/local/evaluation/evaluation.rb +2 -2
- data/lib/experiment/local/evaluation/lib/linuxArm64/libevaluation_interop.so +0 -0
- data/lib/experiment/local/evaluation/lib/linuxArm64/libevaluation_interop_api.h +1 -1
- data/lib/experiment/local/evaluation/lib/linuxX64/libevaluation_interop.so +0 -0
- data/lib/experiment/local/evaluation/lib/linuxX64/libevaluation_interop_api.h +1 -1
- data/lib/experiment/local/evaluation/lib/macosArm64/libevaluation_interop.dylib +0 -0
- data/lib/experiment/local/evaluation/lib/macosArm64/libevaluation_interop_api.h +1 -1
- data/lib/experiment/local/evaluation/lib/macosX64/libevaluation_interop.dylib +0 -0
- data/lib/experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h +1 -1
- data/lib/experiment/remote/client.rb +2 -34
- data/lib/experiment/user.rb +53 -19
- data/lib/experiment/util/flag_config.rb +60 -0
- data/lib/experiment/util/poller.rb +24 -0
- data/lib/experiment/util/topological_sort.rb +39 -0
- data/lib/experiment/util/user.rb +41 -0
- data/lib/experiment/util/variant.rb +32 -0
- data/lib/experiment/variant.rb +3 -1
- data/lib/experiment/version.rb +1 -1
- metadata +31 -5
@@ -3,8 +3,7 @@ require 'logger'
|
|
3
3
|
require_relative '../../amplitude'
|
4
4
|
|
5
5
|
module AmplitudeExperiment
|
6
|
-
FLAG_TYPE_MUTUAL_EXCLUSION_GROUP = '
|
7
|
-
FLAG_TYPE_HOLDOUT_GROUP = 'holdout-group'.freeze
|
6
|
+
FLAG_TYPE_MUTUAL_EXCLUSION_GROUP = 'mutual-exclusion-group'.freeze
|
8
7
|
# Main client for fetching variant data.
|
9
8
|
class LocalEvaluationClient
|
10
9
|
# Creates a new Experiment Client instance.
|
@@ -24,11 +23,24 @@ module AmplitudeExperiment
|
|
24
23
|
else
|
25
24
|
Logger::INFO
|
26
25
|
end
|
27
|
-
@fetcher = LocalEvaluationFetcher.new(api_key, @logger, @config.server_url)
|
28
26
|
raise ArgumentError, 'Experiment API key is empty' if @api_key.nil? || @api_key.empty?
|
29
27
|
|
30
28
|
@assignment_service = nil
|
31
29
|
@assignment_service = AssignmentService.new(AmplitudeAnalytics::Amplitude.new(config.assignment_config.api_key, configuration: config.assignment_config), AssignmentFilter.new(config.assignment_config.cache_capacity)) if config&.assignment_config
|
30
|
+
|
31
|
+
@cohort_storage = InMemoryCohortStorage.new
|
32
|
+
@flag_config_storage = InMemoryFlagConfigStorage.new
|
33
|
+
@flag_config_fetcher = LocalEvaluationFetcher.new(@api_key, @logger, @config.server_url)
|
34
|
+
@cohort_loader = nil
|
35
|
+
unless @config.cohort_sync_config.nil?
|
36
|
+
@cohort_download_api = DirectCohortDownloadApi.new(@config.cohort_sync_config.api_key,
|
37
|
+
@config.cohort_sync_config.secret_key,
|
38
|
+
@config.cohort_sync_config.max_cohort_size,
|
39
|
+
@config.cohort_sync_config.cohort_server_url,
|
40
|
+
@logger)
|
41
|
+
@cohort_loader = CohortLoader.new(@cohort_download_api, @cohort_storage)
|
42
|
+
end
|
43
|
+
@deployment_runner = DeploymentRunner.new(@config, @flag_config_fetcher, @flag_config_storage, @cohort_storage, @logger, @cohort_loader)
|
32
44
|
end
|
33
45
|
|
34
46
|
# Locally evaluates flag variants for a user.
|
@@ -37,18 +49,37 @@ module AmplitudeExperiment
|
|
37
49
|
# @param [String[]] flag_keys The flags to evaluate with the user. If empty, all flags from the flag cache are evaluated
|
38
50
|
#
|
39
51
|
# @return [Hash[String, Variant]] The evaluated variants
|
52
|
+
# @deprecated Please use {evaluate_v2} instead
|
40
53
|
def evaluate(user, flag_keys = [])
|
41
|
-
|
42
|
-
|
43
|
-
|
54
|
+
variants = evaluate_v2(user, flag_keys)
|
55
|
+
AmplitudeExperiment.filter_default_variants(variants)
|
56
|
+
end
|
57
|
+
|
58
|
+
# Locally evaluates flag variants for a user.
|
59
|
+
# This function will only evaluate flags for the keys specified in the flag_keys argument. If flag_keys is
|
60
|
+
# missing or None, all flags are evaluated. This function differs from evaluate as it will return a default
|
61
|
+
# variant object if the flag was evaluated but the user was not assigned (i.e. off).
|
62
|
+
#
|
63
|
+
# @param [User] user The user to evaluate
|
64
|
+
# @param [String[]] flag_keys The flags to evaluate with the user, if empty all flags are evaluated
|
65
|
+
# @return [Hash[String, Variant]] The evaluated variants
|
66
|
+
def evaluate_v2(user, flag_keys = [])
|
67
|
+
flags = @flag_config_storage.flag_configs
|
44
68
|
return {} if flags.nil?
|
45
69
|
|
46
|
-
|
70
|
+
sorted_flags = AmplitudeExperiment.topological_sort(flags, flag_keys.to_set)
|
71
|
+
required_cohorts_in_storage(sorted_flags)
|
72
|
+
flags_json = sorted_flags.to_json
|
73
|
+
user = enrich_user_with_cohorts(user, flags) if @config.cohort_sync_config
|
74
|
+
context = AmplitudeExperiment.user_to_evaluation_context(user)
|
75
|
+
context_json = context.to_json
|
47
76
|
|
48
|
-
@logger.debug("[Experiment] Evaluate: User: #{
|
49
|
-
result = evaluation(
|
77
|
+
@logger.debug("[Experiment] Evaluate: User: #{context_json} - Rules: #{flags}") if @config.debug
|
78
|
+
result = evaluation(flags_json, context_json)
|
50
79
|
@logger.debug("[Experiment] evaluate - result: #{result}") if @config.debug
|
51
|
-
|
80
|
+
variants = AmplitudeExperiment.evaluation_variants_json_to_variants(result)
|
81
|
+
@assignment_service&.track(Assignment.new(user, variants))
|
82
|
+
variants
|
52
83
|
end
|
53
84
|
|
54
85
|
# Fetch initial flag configurations and start polling for updates.
|
@@ -57,49 +88,62 @@ module AmplitudeExperiment
|
|
57
88
|
return if @is_running
|
58
89
|
|
59
90
|
@logger.debug('[Experiment] poller - start') if @debug
|
60
|
-
|
91
|
+
@deployment_runner.start
|
61
92
|
end
|
62
93
|
|
63
94
|
# Stop polling for flag configurations. Close resource like connection pool with client
|
64
95
|
def stop
|
65
|
-
@poller_thread&.exit
|
66
96
|
@is_running = false
|
67
|
-
@
|
97
|
+
@deployment_runner.stop
|
68
98
|
end
|
69
99
|
|
70
100
|
private
|
71
101
|
|
72
|
-
def
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
102
|
+
def required_cohorts_in_storage(flag_configs)
|
103
|
+
stored_cohort_ids = @cohort_storage.cohort_ids
|
104
|
+
|
105
|
+
flag_configs.each do |flag|
|
106
|
+
flag_cohort_ids = AmplitudeExperiment.get_all_cohort_ids_from_flag(flag)
|
107
|
+
missing_cohorts = flag_cohort_ids - stored_cohort_ids
|
108
|
+
|
109
|
+
next unless missing_cohorts.any?
|
110
|
+
|
111
|
+
# Convert cohort IDs to a comma-separated string
|
112
|
+
cohort_ids_str = "[#{flag_cohort_ids.map(&:to_s).join(', ')}]"
|
113
|
+
missing_cohorts_str = "[#{missing_cohorts.map(&:to_s).join(', ')}]"
|
114
|
+
|
115
|
+
message = if @config.cohort_sync_config
|
116
|
+
"Evaluating flag #{flag['key']} dependent on cohorts #{cohort_ids_str} without #{missing_cohorts_str} in storage"
|
117
|
+
else
|
118
|
+
"Evaluating flag #{flag['key']} dependent on cohorts #{cohort_ids_str} without cohort syncing configured"
|
119
|
+
end
|
120
|
+
|
121
|
+
@logger.warn(message)
|
84
122
|
end
|
85
|
-
@assignment_service&.track(Assignment.new(user, assignments))
|
86
|
-
variants
|
87
123
|
end
|
88
124
|
|
89
|
-
def
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
end
|
96
|
-
rescue StandardError => e
|
97
|
-
@logger.error("[Experiment] Flag poller - error: #{e.message}")
|
125
|
+
def enrich_user_with_cohorts(user, flag_configs)
|
126
|
+
grouped_cohort_ids = AmplitudeExperiment.get_grouped_cohort_ids_from_flags(flag_configs)
|
127
|
+
|
128
|
+
if grouped_cohort_ids.key?(USER_GROUP_TYPE)
|
129
|
+
user_cohort_ids = grouped_cohort_ids[USER_GROUP_TYPE]
|
130
|
+
user.cohort_ids = Array(@cohort_storage.get_cohorts_for_user(user.user_id, user_cohort_ids)) if user_cohort_ids && user.user_id
|
98
131
|
end
|
99
|
-
|
100
|
-
|
101
|
-
|
132
|
+
|
133
|
+
user.groups&.each do |group_type, group_names|
|
134
|
+
group_name = group_names.first if group_names
|
135
|
+
next unless group_name
|
136
|
+
|
137
|
+
cohort_ids = grouped_cohort_ids[group_type] || []
|
138
|
+
next if cohort_ids.empty?
|
139
|
+
|
140
|
+
user.add_group_cohort_ids(
|
141
|
+
group_type,
|
142
|
+
group_name,
|
143
|
+
Array(@cohort_storage.get_cohorts_for_group(group_type, group_name, cohort_ids))
|
144
|
+
)
|
102
145
|
end
|
146
|
+
user
|
103
147
|
end
|
104
148
|
end
|
105
149
|
end
|
@@ -1,8 +1,14 @@
|
|
1
1
|
module AmplitudeExperiment
|
2
|
+
module ServerZone
|
3
|
+
US = 'US'.freeze
|
4
|
+
EU = 'EU'.freeze
|
5
|
+
end
|
6
|
+
|
2
7
|
# LocalEvaluationConfig
|
3
8
|
class LocalEvaluationConfig
|
4
9
|
# Default server url
|
5
10
|
DEFAULT_SERVER_URL = 'https://api.lab.amplitude.com'.freeze
|
11
|
+
EU_SERVER_URL = 'https://flag.lab.eu.amplitude.com'.freeze
|
6
12
|
|
7
13
|
# Set to true to log some extra information to the console.
|
8
14
|
# @return [Boolean] the value of debug
|
@@ -12,6 +18,10 @@ module AmplitudeExperiment
|
|
12
18
|
# @return [String] the value of server url
|
13
19
|
attr_accessor :server_url
|
14
20
|
|
21
|
+
# Location of the Amplitude data center to get flags and cohorts from, US or EU
|
22
|
+
# @return [String] the value of server zone
|
23
|
+
attr_accessor :server_zone
|
24
|
+
|
15
25
|
# The polling interval for flag configs.
|
16
26
|
# @return [long] the value of flag config polling interval in million seconds
|
17
27
|
attr_accessor :flag_config_polling_interval_millis
|
@@ -20,14 +30,28 @@ module AmplitudeExperiment
|
|
20
30
|
# @return [AssignmentConfig] the config instance
|
21
31
|
attr_accessor :assignment_config
|
22
32
|
|
33
|
+
# Configuration for downloading cohorts required for flag evaluation
|
34
|
+
# @return [CohortSyncConfig] the config instance
|
35
|
+
attr_accessor :cohort_sync_config
|
36
|
+
|
23
37
|
# @param [Boolean] debug Set to true to log some extra information to the console.
|
24
38
|
# @param [String] server_url The server endpoint from which to request variants.
|
39
|
+
# @param [String] server_zone Location of the Amplitude data center to get flags and cohorts from, US or EU
|
25
40
|
# @param [Hash] bootstrap The value of bootstrap.
|
26
41
|
# @param [long] flag_config_polling_interval_millis The value of flag config polling interval in million seconds.
|
27
|
-
|
28
|
-
|
42
|
+
# @param [AssignmentConfig] assignment_config Configuration for automatically tracking assignment events after an evaluation.
|
43
|
+
# @param [CohortSyncConfig] cohort_sync_config Configuration for downloading cohorts required for flag evaluation
|
44
|
+
def initialize(server_url: DEFAULT_SERVER_URL, server_zone: ServerZone::US, bootstrap: {},
|
45
|
+
flag_config_polling_interval_millis: 30_000, debug: false, assignment_config: nil,
|
46
|
+
cohort_sync_config: nil)
|
29
47
|
@debug = debug || false
|
30
48
|
@server_url = server_url
|
49
|
+
@server_zone = server_zone
|
50
|
+
@cohort_sync_config = cohort_sync_config
|
51
|
+
if server_url == DEFAULT_SERVER_URL && @server_zone == ServerZone::EU
|
52
|
+
@server_url = EU_SERVER_URL
|
53
|
+
@cohort_sync_config.cohort_server_url = EU_COHORT_SYNC_URL if @cohort_sync_config && @cohort_sync_config.cohort_server_url == DEFAULT_COHORT_SYNC_URL
|
54
|
+
end
|
31
55
|
@bootstrap = bootstrap
|
32
56
|
@flag_config_polling_interval_millis = flag_config_polling_interval_millis
|
33
57
|
@assignment_config = assignment_config
|
@@ -57,11 +57,11 @@ module EvaluationInterop
|
|
57
57
|
attach_function :libevaluation_interop_symbols, [], Libevaluation_interop_ExportedSymbols.by_ref
|
58
58
|
end
|
59
59
|
|
60
|
-
def evaluation(rule_json,
|
60
|
+
def evaluation(rule_json, context_json)
|
61
61
|
lib = EvaluationInterop.libevaluation_interop_symbols()
|
62
62
|
evaluate = lib[:kotlin][:root][:evaluate]
|
63
63
|
dispose = lib[:DisposeString]
|
64
|
-
result_raw = evaluate.call(rule_json,
|
64
|
+
result_raw = evaluate.call(rule_json, context_json)
|
65
65
|
result_json = result_raw.read_string
|
66
66
|
result = JSON.parse(result_json)
|
67
67
|
dispose.call(result_raw)
|
Binary file
|
@@ -99,7 +99,7 @@ typedef struct {
|
|
99
99
|
/* User functions. */
|
100
100
|
struct {
|
101
101
|
struct {
|
102
|
-
const char* (*evaluate)(const char*
|
102
|
+
const char* (*evaluate)(const char* flags, const char* context);
|
103
103
|
} root;
|
104
104
|
} kotlin;
|
105
105
|
} libevaluation_interop_ExportedSymbols;
|
Binary file
|
@@ -99,7 +99,7 @@ typedef struct {
|
|
99
99
|
/* User functions. */
|
100
100
|
struct {
|
101
101
|
struct {
|
102
|
-
const char* (*evaluate)(const char*
|
102
|
+
const char* (*evaluate)(const char* flags, const char* context);
|
103
103
|
} root;
|
104
104
|
} kotlin;
|
105
105
|
} libevaluation_interop_ExportedSymbols;
|
Binary file
|
@@ -99,7 +99,7 @@ typedef struct {
|
|
99
99
|
/* User functions. */
|
100
100
|
struct {
|
101
101
|
struct {
|
102
|
-
const char* (*evaluate)(const char*
|
102
|
+
const char* (*evaluate)(const char* flags, const char* context);
|
103
103
|
} root;
|
104
104
|
} kotlin;
|
105
105
|
} libevaluation_interop_ExportedSymbols;
|
Binary file
|
@@ -99,7 +99,7 @@ typedef struct {
|
|
99
99
|
/* User functions. */
|
100
100
|
struct {
|
101
101
|
struct {
|
102
|
-
const char* (*evaluate)(const char*
|
102
|
+
const char* (*evaluate)(const char* flags, const char* context);
|
103
103
|
} root;
|
104
104
|
} kotlin;
|
105
105
|
} libevaluation_interop_ExportedSymbols;
|
@@ -30,7 +30,7 @@ module AmplitudeExperiment
|
|
30
30
|
# @param [User] user
|
31
31
|
# @return [Hash] Variants Hash
|
32
32
|
def fetch(user)
|
33
|
-
filter_default_variants(fetch_internal(user))
|
33
|
+
AmplitudeExperiment.filter_default_variants(fetch_internal(user))
|
34
34
|
rescue StandardError => e
|
35
35
|
@logger.error("[Experiment] Failed to fetch variants: #{e.message}")
|
36
36
|
{}
|
@@ -144,30 +144,11 @@ module AmplitudeExperiment
|
|
144
144
|
raise FetchError.new(response.code.to_i, "Fetch error response: status=#{response.code} #{response.message}") if response.code != '200'
|
145
145
|
|
146
146
|
json = JSON.parse(response.body)
|
147
|
-
variants =
|
147
|
+
variants = AmplitudeExperiment.evaluation_variants_json_to_variants(json)
|
148
148
|
@logger.debug("[Experiment] Fetched variants: #{variants}")
|
149
149
|
variants
|
150
150
|
end
|
151
151
|
|
152
|
-
# Parse JSON response hash
|
153
|
-
#
|
154
|
-
# @param [Hash] json
|
155
|
-
# @return [Hash] Hash with String => Variant
|
156
|
-
def parse_json_variants(json)
|
157
|
-
variants = {}
|
158
|
-
json.each do |key, value|
|
159
|
-
variant_value = ''
|
160
|
-
if value.key?('value')
|
161
|
-
variant_value = value.fetch('value')
|
162
|
-
elsif value.key?('key')
|
163
|
-
# value was previously under the "key" field
|
164
|
-
variant_value = value.fetch('key')
|
165
|
-
end
|
166
|
-
variants.store(key, Variant.new(variant_value, value.fetch('payload', nil), value.fetch('key', nil), value.fetch('metadata', nil)))
|
167
|
-
end
|
168
|
-
variants
|
169
|
-
end
|
170
|
-
|
171
152
|
# @param [User] user
|
172
153
|
# @return [User, Hash] user with library context
|
173
154
|
def add_context(user)
|
@@ -181,18 +162,5 @@ module AmplitudeExperiment
|
|
181
162
|
|
182
163
|
true
|
183
164
|
end
|
184
|
-
|
185
|
-
def filter_default_variants(variants)
|
186
|
-
variants.each do |key, value|
|
187
|
-
default = value&.metadata&.fetch('default', nil)
|
188
|
-
deployed = value&.metadata&.fetch('deployed', nil)
|
189
|
-
default = false if default.nil?
|
190
|
-
deployed = true if deployed.nil?
|
191
|
-
variants.delete(key) if default || !deployed
|
192
|
-
end
|
193
|
-
variants
|
194
|
-
end
|
195
|
-
|
196
|
-
private :filter_default_variants
|
197
165
|
end
|
198
166
|
end
|
data/lib/experiment/user.rb
CHANGED
@@ -72,6 +72,22 @@ module AmplitudeExperiment
|
|
72
72
|
# @return [Hash, nil] the value of user properties
|
73
73
|
attr_accessor :user_properties
|
74
74
|
|
75
|
+
# Predefined field, must be manually provided
|
76
|
+
# @return [Hash, nil] the value of groups
|
77
|
+
attr_accessor :groups
|
78
|
+
|
79
|
+
# Predefined field, must be manually provided
|
80
|
+
# @return [Hash, nil] the value of group properties
|
81
|
+
attr_accessor :group_properties
|
82
|
+
|
83
|
+
# Cohort IDs for the user
|
84
|
+
# @return [Hash, nil] the value of cohort_ids
|
85
|
+
attr_accessor :cohort_ids
|
86
|
+
|
87
|
+
# Cohort IDs for the user's groups
|
88
|
+
# @return [Hash, nil] the value of group_cohort_ids
|
89
|
+
attr_accessor :group_cohort_ids
|
90
|
+
|
75
91
|
# @param [String, nil] device_id Device ID for associating with an identity in Amplitude
|
76
92
|
# @param [String, nil] user_id User ID for associating with an identity in Amplitude
|
77
93
|
# @param [String, nil] country Predefined field, must be manually provided
|
@@ -89,9 +105,12 @@ module AmplitudeExperiment
|
|
89
105
|
# @param [String, nil] carrier Predefined field, must be manually provided
|
90
106
|
# @param [String, nil] library Predefined field, auto populated, can be manually overridden
|
91
107
|
# @param [Hash, nil] user_properties Custom user properties
|
108
|
+
# @param [Hash, nil] groups List of groups the user belongs to
|
109
|
+
# @param [Hash, nil] group_properties Custom properties for groups
|
92
110
|
def initialize(device_id: nil, user_id: nil, country: nil, city: nil, region: nil, dma: nil, ip_address: nil, language: nil,
|
93
111
|
platform: nil, version: nil, os: nil, device_manufacturer: nil, device_brand: nil,
|
94
|
-
device_model: nil, carrier: nil, library: nil, user_properties: nil
|
112
|
+
device_model: nil, carrier: nil, library: nil, user_properties: nil, groups: nil, group_properties: nil,
|
113
|
+
cohort_ids: nil, group_cohort_ids: nil)
|
95
114
|
@device_id = device_id
|
96
115
|
@user_id = user_id
|
97
116
|
@country = country
|
@@ -109,30 +128,38 @@ module AmplitudeExperiment
|
|
109
128
|
@carrier = carrier
|
110
129
|
@library = library
|
111
130
|
@user_properties = user_properties
|
131
|
+
@groups = groups
|
132
|
+
@group_properties = group_properties
|
133
|
+
@cohort_ids = cohort_ids
|
134
|
+
@group_cohort_ids = group_cohort_ids
|
112
135
|
end
|
113
136
|
|
114
137
|
# Return User as Hash.
|
115
138
|
# @return [Hash] Hash object with user values
|
116
139
|
def as_json(_options = {})
|
117
140
|
{
|
118
|
-
device_id
|
119
|
-
user_id
|
120
|
-
country
|
121
|
-
city
|
122
|
-
region
|
123
|
-
dma
|
124
|
-
ip_address
|
125
|
-
language
|
126
|
-
platform
|
127
|
-
version
|
128
|
-
os
|
129
|
-
device_manufacturer
|
130
|
-
device_brand
|
131
|
-
device_model
|
132
|
-
carrier
|
133
|
-
library
|
134
|
-
user_properties
|
135
|
-
|
141
|
+
'device_id' => @device_id,
|
142
|
+
'user_id' => @user_id,
|
143
|
+
'country' => @country,
|
144
|
+
'city' => @city,
|
145
|
+
'region' => @region,
|
146
|
+
'dma' => @dma,
|
147
|
+
'ip_address' => @ip_address,
|
148
|
+
'language' => @language,
|
149
|
+
'platform' => @platform,
|
150
|
+
'version' => @version,
|
151
|
+
'os' => @os,
|
152
|
+
'device_manufacturer' => @device_manufacturer,
|
153
|
+
'device_brand' => @device_brand,
|
154
|
+
'device_model' => @device_model,
|
155
|
+
'carrier' => @carrier,
|
156
|
+
'library' => @library,
|
157
|
+
'user_properties' => @user_properties,
|
158
|
+
'groups' => @groups,
|
159
|
+
'group_properties' => @group_properties,
|
160
|
+
'cohort_ids' => @cohort_ids,
|
161
|
+
'group_cohort_ids' => @group_cohort_ids
|
162
|
+
}.compact
|
136
163
|
end
|
137
164
|
|
138
165
|
# Return user information as JSON string.
|
@@ -140,5 +167,12 @@ module AmplitudeExperiment
|
|
140
167
|
def to_json(*options)
|
141
168
|
as_json(*options).to_json(*options)
|
142
169
|
end
|
170
|
+
|
171
|
+
def add_group_cohort_ids(group_type, group_name, cohort_ids)
|
172
|
+
@group_cohort_ids ||= {}
|
173
|
+
|
174
|
+
group_names = @group_cohort_ids[group_type] ||= {}
|
175
|
+
group_names[group_name] = cohort_ids
|
176
|
+
end
|
143
177
|
end
|
144
178
|
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
module AmplitudeExperiment
|
2
|
+
def self.cohort_filter?(condition)
|
3
|
+
['set contains any', 'set does not contain any'].include?(condition['op']) &&
|
4
|
+
condition['selector'] &&
|
5
|
+
condition['selector'][-1] == 'cohort_ids'
|
6
|
+
end
|
7
|
+
|
8
|
+
def self.get_grouped_cohort_condition_ids(segment)
|
9
|
+
cohort_ids = {}
|
10
|
+
conditions = segment['conditions'] || []
|
11
|
+
conditions.each do |condition|
|
12
|
+
condition = condition[0]
|
13
|
+
next unless cohort_filter?(condition) && (condition['selector'][1].length > 2)
|
14
|
+
|
15
|
+
context_subtype = condition['selector'][1]
|
16
|
+
group_type =
|
17
|
+
if context_subtype == 'user'
|
18
|
+
USER_GROUP_TYPE
|
19
|
+
elsif condition['selector'].include?('groups')
|
20
|
+
condition['selector'][2]
|
21
|
+
else
|
22
|
+
next
|
23
|
+
end
|
24
|
+
cohort_ids[group_type] ||= Set.new
|
25
|
+
cohort_ids[group_type].merge(condition['values'])
|
26
|
+
end
|
27
|
+
cohort_ids
|
28
|
+
end
|
29
|
+
|
30
|
+
def self.get_grouped_cohort_ids_from_flag(flag)
|
31
|
+
cohort_ids = {}
|
32
|
+
segments = flag['segments'] || []
|
33
|
+
segments.each do |segment|
|
34
|
+
get_grouped_cohort_condition_ids(segment).each do |key, values|
|
35
|
+
cohort_ids[key] ||= Set.new
|
36
|
+
cohort_ids[key].merge(values)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
cohort_ids
|
40
|
+
end
|
41
|
+
|
42
|
+
def self.get_all_cohort_ids_from_flag(flag)
|
43
|
+
get_grouped_cohort_ids_from_flag(flag).values.reduce(Set.new) { |acc, set| acc.merge(set) }
|
44
|
+
end
|
45
|
+
|
46
|
+
def self.get_grouped_cohort_ids_from_flags(flags)
|
47
|
+
cohort_ids = {}
|
48
|
+
flags.each do |_, flag|
|
49
|
+
get_grouped_cohort_ids_from_flag(flag).each do |key, values|
|
50
|
+
cohort_ids[key] ||= Set.new
|
51
|
+
cohort_ids[key].merge(values)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
cohort_ids
|
55
|
+
end
|
56
|
+
|
57
|
+
def self.get_all_cohort_ids_from_flags(flags)
|
58
|
+
get_grouped_cohort_ids_from_flags(flags).values.reduce(Set.new) { |acc, set| acc.merge(set) }
|
59
|
+
end
|
60
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
module AmplitudeExperiment
|
2
|
+
# Poller
|
3
|
+
class Poller
|
4
|
+
def initialize(interval_seconds, callback)
|
5
|
+
@interval_seconds = interval_seconds
|
6
|
+
@callback = callback
|
7
|
+
end
|
8
|
+
|
9
|
+
def start
|
10
|
+
@running = true
|
11
|
+
@thread = Thread.new do
|
12
|
+
while @running
|
13
|
+
@callback.call
|
14
|
+
sleep(@interval_seconds)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def stop
|
20
|
+
@running = false
|
21
|
+
@thread&.join
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
module AmplitudeExperiment
|
2
|
+
def self.topological_sort(flags, keys = nil, ordered: false)
|
3
|
+
available = flags.dup
|
4
|
+
result = []
|
5
|
+
starting_keys = keys.nil? || keys.empty? ? flags.keys : keys
|
6
|
+
# Used for testing to ensure consistency.
|
7
|
+
starting_keys.sort! if ordered && (keys.nil? || keys.empty?)
|
8
|
+
|
9
|
+
starting_keys.each do |flag_key|
|
10
|
+
traversal = parent_traversal(flag_key, available, Set.new)
|
11
|
+
result.concat(traversal) unless traversal.nil?
|
12
|
+
end
|
13
|
+
result
|
14
|
+
end
|
15
|
+
|
16
|
+
def self.parent_traversal(flag_key, available, path)
|
17
|
+
flag = available[flag_key]
|
18
|
+
return nil if flag.nil?
|
19
|
+
|
20
|
+
dependencies = flag['dependencies']
|
21
|
+
if dependencies.nil? || dependencies.empty?
|
22
|
+
available.delete(flag_key)
|
23
|
+
return [flag]
|
24
|
+
end
|
25
|
+
|
26
|
+
path.add(flag_key)
|
27
|
+
result = []
|
28
|
+
dependencies.each do |parent_key|
|
29
|
+
raise CycleError, path if path.include?(parent_key)
|
30
|
+
|
31
|
+
traversal = parent_traversal(parent_key, available, path)
|
32
|
+
result.concat(traversal) unless traversal.nil?
|
33
|
+
end
|
34
|
+
result << flag
|
35
|
+
path.delete(flag_key)
|
36
|
+
available.delete(flag_key)
|
37
|
+
result
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
module AmplitudeExperiment
|
2
|
+
def self.user_to_evaluation_context(user)
|
3
|
+
user_groups = user.groups
|
4
|
+
user_group_properties = user.group_properties
|
5
|
+
user_group_cohort_ids = user.group_cohort_ids
|
6
|
+
user_hash = user.as_json.compact
|
7
|
+
user_hash.delete('groups')
|
8
|
+
user_hash.delete('group_properties')
|
9
|
+
user_hash.delete('group_cohort_ids')
|
10
|
+
|
11
|
+
context = user_hash.empty? ? {} : { 'user' => user_hash }
|
12
|
+
|
13
|
+
return context if user_groups.nil?
|
14
|
+
|
15
|
+
groups = {}
|
16
|
+
user_groups.each do |group_type, group_name|
|
17
|
+
group_name = group_name[0] if group_name.is_a?(Array) && !group_name.empty?
|
18
|
+
|
19
|
+
groups[group_type] = { 'group_name' => group_name }
|
20
|
+
|
21
|
+
if user_group_properties
|
22
|
+
group_properties_type = user_group_properties[group_type]
|
23
|
+
if group_properties_type.is_a?(Hash)
|
24
|
+
group_properties_name = group_properties_type[group_name]
|
25
|
+
groups[group_type]['group_properties'] = group_properties_name if group_properties_name.is_a?(Hash)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
next unless user_group_cohort_ids
|
30
|
+
|
31
|
+
group_cohort_ids_type = user_group_cohort_ids[group_type]
|
32
|
+
if group_cohort_ids_type.is_a?(Hash)
|
33
|
+
group_cohort_ids_name = group_cohort_ids_type[group_name]
|
34
|
+
groups[group_type]['cohort_ids'] = group_cohort_ids_name if group_cohort_ids_name.is_a?(Array)
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
context['groups'] = groups unless groups.empty?
|
39
|
+
context
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
require 'json'
|
2
|
+
module AmplitudeExperiment
|
3
|
+
def self.evaluation_variants_json_to_variants(variants_json)
|
4
|
+
variants = {}
|
5
|
+
variants_json.each do |key, value|
|
6
|
+
variants[key] = AmplitudeExperiment.evaluation_variant_json_to_variant(value)
|
7
|
+
end
|
8
|
+
variants
|
9
|
+
end
|
10
|
+
|
11
|
+
def self.evaluation_variant_json_to_variant(variant_json)
|
12
|
+
value = variant_json['value']
|
13
|
+
value = value.to_json if value && !value.is_a?(String)
|
14
|
+
Variant.new(
|
15
|
+
value: value,
|
16
|
+
key: variant_json['key'],
|
17
|
+
payload: variant_json['payload'],
|
18
|
+
metadata: variant_json['metadata']
|
19
|
+
)
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.filter_default_variants(variants)
|
23
|
+
variants.each do |key, value|
|
24
|
+
default = value&.metadata&.fetch('default', nil)
|
25
|
+
deployed = value&.metadata&.fetch('deployed', nil)
|
26
|
+
default = false if default.nil?
|
27
|
+
deployed = true if deployed.nil?
|
28
|
+
variants.delete(key) if default || !deployed
|
29
|
+
end
|
30
|
+
variants
|
31
|
+
end
|
32
|
+
end
|