amplitude-experiment 1.4.0 → 1.6.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 +7 -6
- data/lib/amplitude-experiment.rb +16 -2
- 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 +23 -0
- data/lib/experiment/evaluation/evaluation.rb +311 -0
- data/lib/experiment/evaluation/flag.rb +123 -0
- data/lib/experiment/evaluation/murmur3.rb +104 -0
- data/lib/experiment/evaluation/select.rb +16 -0
- data/lib/experiment/evaluation/semantic_version.rb +52 -0
- data/lib/experiment/evaluation/topological_sort.rb +56 -0
- data/lib/experiment/{local/fetcher.rb → flag/flag_config_fetcher.rb} +7 -5
- data/lib/experiment/flag/flag_config_storage.rb +53 -0
- data/lib/experiment/local/client.rb +68 -29
- data/lib/experiment/local/config.rb +26 -2
- data/lib/experiment/persistent_http_client.rb +1 -1
- data/lib/experiment/remote/client.rb +8 -6
- data/lib/experiment/remote/config.rb +8 -1
- data/lib/experiment/user.rb +40 -20
- data/lib/experiment/util/flag_config.rb +60 -0
- data/lib/experiment/util/poller.rb +24 -0
- data/lib/experiment/util/user.rb +20 -12
- data/lib/experiment/version.rb +1 -1
- metadata +44 -25
- data/lib/experiment/local/evaluation/evaluation.rb +0 -76
- data/lib/experiment/local/evaluation/lib/linuxArm64/libevaluation_interop.so +0 -0
- data/lib/experiment/local/evaluation/lib/linuxArm64/libevaluation_interop_api.h +0 -110
- data/lib/experiment/local/evaluation/lib/linuxX64/libevaluation_interop.so +0 -0
- data/lib/experiment/local/evaluation/lib/linuxX64/libevaluation_interop_api.h +0 -110
- data/lib/experiment/local/evaluation/lib/macosArm64/libevaluation_interop.dylib +0 -0
- data/lib/experiment/local/evaluation/lib/macosArm64/libevaluation_interop_api.h +0 -110
- data/lib/experiment/local/evaluation/lib/macosX64/libevaluation_interop.dylib +0 -0
- data/lib/experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h +0 -110
- data/lib/experiment/util/topological_sort.rb +0 -39
@@ -0,0 +1,53 @@
|
|
1
|
+
module AmplitudeExperiment
|
2
|
+
# FlagConfigStorage
|
3
|
+
class FlagConfigStorage
|
4
|
+
def flag_config(key)
|
5
|
+
raise NotImplementedError
|
6
|
+
end
|
7
|
+
|
8
|
+
def flag_configs
|
9
|
+
raise NotImplementedError
|
10
|
+
end
|
11
|
+
|
12
|
+
def put_flag_config(flag_config)
|
13
|
+
raise NotImplementedError
|
14
|
+
end
|
15
|
+
|
16
|
+
def remove_if(&condition)
|
17
|
+
raise NotImplementedError
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
# InMemoryFlagConfigStorage
|
22
|
+
class InMemoryFlagConfigStorage < FlagConfigStorage
|
23
|
+
def initialize
|
24
|
+
super # Call the parent class's constructor with no arguments
|
25
|
+
@flag_configs = {}
|
26
|
+
@flag_configs_lock = Mutex.new
|
27
|
+
end
|
28
|
+
|
29
|
+
def flag_config(key)
|
30
|
+
@flag_configs_lock.synchronize do
|
31
|
+
@flag_configs[key]
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def flag_configs
|
36
|
+
@flag_configs_lock.synchronize do
|
37
|
+
@flag_configs.dup
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def put_flag_config(flag_config)
|
42
|
+
@flag_configs_lock.synchronize do
|
43
|
+
@flag_configs[flag_config.key] = flag_config
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def remove_if
|
48
|
+
@flag_configs_lock.synchronize do
|
49
|
+
@flag_configs.delete_if { |_key, value| yield(value) }
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
@@ -12,7 +12,6 @@ module AmplitudeExperiment
|
|
12
12
|
# @param [LocalEvaluationConfig] config The config object
|
13
13
|
|
14
14
|
def initialize(api_key, config = nil)
|
15
|
-
require 'experiment/local/evaluation/evaluation'
|
16
15
|
@api_key = api_key
|
17
16
|
@config = config || LocalEvaluationConfig.new
|
18
17
|
@flags = nil
|
@@ -23,11 +22,26 @@ module AmplitudeExperiment
|
|
23
22
|
else
|
24
23
|
Logger::INFO
|
25
24
|
end
|
26
|
-
@fetcher = LocalEvaluationFetcher.new(api_key, @logger, @config.server_url)
|
27
25
|
raise ArgumentError, 'Experiment API key is empty' if @api_key.nil? || @api_key.empty?
|
28
26
|
|
27
|
+
@engine = Evaluation::Engine.new
|
28
|
+
|
29
29
|
@assignment_service = nil
|
30
30
|
@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
|
31
|
+
|
32
|
+
@cohort_storage = InMemoryCohortStorage.new
|
33
|
+
@flag_config_storage = InMemoryFlagConfigStorage.new
|
34
|
+
@flag_config_fetcher = LocalEvaluationFetcher.new(@api_key, @logger, @config.server_url)
|
35
|
+
@cohort_loader = nil
|
36
|
+
unless @config.cohort_sync_config.nil?
|
37
|
+
@cohort_download_api = DirectCohortDownloadApi.new(@config.cohort_sync_config.api_key,
|
38
|
+
@config.cohort_sync_config.secret_key,
|
39
|
+
@config.cohort_sync_config.max_cohort_size,
|
40
|
+
@config.cohort_sync_config.cohort_server_url,
|
41
|
+
@logger)
|
42
|
+
@cohort_loader = CohortLoader.new(@cohort_download_api, @cohort_storage)
|
43
|
+
end
|
44
|
+
@deployment_runner = DeploymentRunner.new(@config, @flag_config_fetcher, @flag_config_storage, @cohort_storage, @logger, @cohort_loader)
|
31
45
|
end
|
32
46
|
|
33
47
|
# Locally evaluates flag variants for a user.
|
@@ -51,19 +65,16 @@ module AmplitudeExperiment
|
|
51
65
|
# @param [String[]] flag_keys The flags to evaluate with the user, if empty all flags are evaluated
|
52
66
|
# @return [Hash[String, Variant]] The evaluated variants
|
53
67
|
def evaluate_v2(user, flag_keys = [])
|
54
|
-
flags = @
|
55
|
-
@flags
|
56
|
-
end
|
68
|
+
flags = @flag_config_storage.flag_configs
|
57
69
|
return {} if flags.nil?
|
58
70
|
|
59
|
-
sorted_flags =
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
user_str = enriched_user.to_json
|
71
|
+
sorted_flags = TopologicalSort.sort(flags, flag_keys)
|
72
|
+
required_cohorts_in_storage(sorted_flags)
|
73
|
+
user = enrich_user_with_cohorts(user, flags) if @config.cohort_sync_config
|
74
|
+
context = AmplitudeExperiment.user_to_evaluation_context(user)
|
64
75
|
|
65
|
-
@logger.debug("[Experiment] Evaluate: User: #{
|
66
|
-
result =
|
76
|
+
@logger.debug("[Experiment] Evaluate: User: #{context} - Rules: #{flags}") if @config.debug
|
77
|
+
result = @engine.evaluate(context, sorted_flags)
|
67
78
|
@logger.debug("[Experiment] evaluate - result: #{result}") if @config.debug
|
68
79
|
variants = AmplitudeExperiment.evaluation_variants_json_to_variants(result)
|
69
80
|
@assignment_service&.track(Assignment.new(user, variants))
|
@@ -76,34 +87,62 @@ module AmplitudeExperiment
|
|
76
87
|
return if @is_running
|
77
88
|
|
78
89
|
@logger.debug('[Experiment] poller - start') if @debug
|
79
|
-
|
90
|
+
@deployment_runner.start
|
80
91
|
end
|
81
92
|
|
82
93
|
# Stop polling for flag configurations. Close resource like connection pool with client
|
83
94
|
def stop
|
84
|
-
@poller_thread&.exit
|
85
95
|
@is_running = false
|
86
|
-
@
|
96
|
+
@deployment_runner.stop
|
87
97
|
end
|
88
98
|
|
89
99
|
private
|
90
100
|
|
91
|
-
def
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
101
|
+
def required_cohorts_in_storage(flag_configs)
|
102
|
+
stored_cohort_ids = @cohort_storage.cohort_ids
|
103
|
+
|
104
|
+
flag_configs.each do |flag|
|
105
|
+
flag_cohort_ids = AmplitudeExperiment.get_all_cohort_ids_from_flag(flag)
|
106
|
+
missing_cohorts = flag_cohort_ids - stored_cohort_ids
|
107
|
+
|
108
|
+
next unless missing_cohorts.any?
|
109
|
+
|
110
|
+
# Convert cohort IDs to a comma-separated string
|
111
|
+
cohort_ids_str = "[#{flag_cohort_ids.map(&:to_s).join(', ')}]"
|
112
|
+
missing_cohorts_str = "[#{missing_cohorts.map(&:to_s).join(', ')}]"
|
113
|
+
|
114
|
+
message = if @config.cohort_sync_config
|
115
|
+
"Evaluating flag #{flag.key} dependent on cohorts #{cohort_ids_str} without #{missing_cohorts_str} in storage"
|
116
|
+
else
|
117
|
+
"Evaluating flag #{flag.key} dependent on cohorts #{cohort_ids_str} without cohort syncing configured"
|
118
|
+
end
|
119
|
+
|
120
|
+
@logger.warn(message)
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
def enrich_user_with_cohorts(user, flag_configs)
|
125
|
+
grouped_cohort_ids = AmplitudeExperiment.get_grouped_cohort_ids_from_flags(flag_configs)
|
126
|
+
|
127
|
+
if grouped_cohort_ids.key?(USER_GROUP_TYPE)
|
128
|
+
user_cohort_ids = grouped_cohort_ids[USER_GROUP_TYPE]
|
129
|
+
user.cohort_ids = Array(@cohort_storage.get_cohorts_for_user(user.user_id, user_cohort_ids)) if user_cohort_ids && user.user_id
|
102
130
|
end
|
103
|
-
|
104
|
-
|
105
|
-
|
131
|
+
|
132
|
+
user.groups&.each do |group_type, group_names|
|
133
|
+
group_name = group_names.first if group_names
|
134
|
+
next unless group_name
|
135
|
+
|
136
|
+
cohort_ids = grouped_cohort_ids[group_type] || []
|
137
|
+
next if cohort_ids.empty?
|
138
|
+
|
139
|
+
user.add_group_cohort_ids(
|
140
|
+
group_type,
|
141
|
+
group_name,
|
142
|
+
Array(@cohort_storage.get_cohorts_for_group(group_type, group_name, cohort_ids))
|
143
|
+
)
|
106
144
|
end
|
145
|
+
user
|
107
146
|
end
|
108
147
|
end
|
109
148
|
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
|
@@ -5,7 +5,7 @@ module AmplitudeExperiment
|
|
5
5
|
# WARNING: these connections are not safe for concurrent requests. Callers
|
6
6
|
# must synchronize requests per connection.
|
7
7
|
class PersistentHttpClient
|
8
|
-
DEFAULT_OPTIONS = { read_timeout: 80 }.freeze
|
8
|
+
DEFAULT_OPTIONS = { open_timeout: 60, read_timeout: 80 }.freeze
|
9
9
|
|
10
10
|
class << self
|
11
11
|
# url: URI / String
|
@@ -89,7 +89,7 @@ module AmplitudeExperiment
|
|
89
89
|
# @param [User] user
|
90
90
|
def fetch_internal(user)
|
91
91
|
@logger.debug("[Experiment] Fetching variants for user: #{user.as_json}")
|
92
|
-
do_fetch(user, @config.fetch_timeout_millis)
|
92
|
+
do_fetch(user, @config.connect_timeout_millis, @config.fetch_timeout_millis)
|
93
93
|
rescue StandardError => e
|
94
94
|
@logger.error("[Experiment] Fetch failed: #{e.message}")
|
95
95
|
if should_retry_fetch?(e)
|
@@ -112,7 +112,7 @@ module AmplitudeExperiment
|
|
112
112
|
@config.fetch_retries.times do
|
113
113
|
sleep(delay_millis.to_f / 1000.0)
|
114
114
|
begin
|
115
|
-
return do_fetch(user, @config.fetch_retry_timeout_millis)
|
115
|
+
return do_fetch(user, @config.connect_timeout_millis, @config.fetch_retry_timeout_millis)
|
116
116
|
rescue StandardError => e
|
117
117
|
@logger.error("[Experiment] Retry failed: #{e.message}")
|
118
118
|
err = e
|
@@ -123,16 +123,18 @@ module AmplitudeExperiment
|
|
123
123
|
end
|
124
124
|
|
125
125
|
# @param [User] user
|
126
|
-
# @param [Integer]
|
127
|
-
|
126
|
+
# @param [Integer] connect_timeout_millis
|
127
|
+
# @param [Integer] fetch_timeout_millis
|
128
|
+
def do_fetch(user, connect_timeout_millis, fetch_timeout_millis)
|
128
129
|
start_time = Time.now
|
129
130
|
user_context = add_context(user)
|
130
131
|
headers = {
|
131
132
|
'Authorization' => "Api-Key #{@api_key}",
|
132
133
|
'Content-Type' => 'application/json;charset=utf-8'
|
133
134
|
}
|
134
|
-
|
135
|
-
|
135
|
+
connect_timeout = connect_timeout_millis.to_f / 1000 if (connect_timeout_millis.to_f / 1000) > 0
|
136
|
+
read_timeout = fetch_timeout_millis.to_f / 1000 if (fetch_timeout_millis.to_f / 1000) > 0
|
137
|
+
http = PersistentHttpClient.get(@uri, { open_timeout: connect_timeout, read_timeout: read_timeout }, @api_key)
|
136
138
|
request = Net::HTTP::Post.new(@uri, headers)
|
137
139
|
request.body = user_context.to_json
|
138
140
|
@logger.warn("[Experiment] encoded user object length #{request.body.length} cannot be cached by CDN; must be < 8KB") if request.body.length > 8000
|
@@ -12,6 +12,10 @@ module AmplitudeExperiment
|
|
12
12
|
# @return [Boolean] the value of server url
|
13
13
|
attr_accessor :server_url
|
14
14
|
|
15
|
+
# The request connection open timeout, in milliseconds, used when fetching variants triggered by calling start() or setUser().
|
16
|
+
# @return [Integer] the value of connect_timeout_millis
|
17
|
+
attr_accessor :connect_timeout_millis
|
18
|
+
|
15
19
|
# The request timeout, in milliseconds, used when fetching variants triggered by calling start() or setUser().
|
16
20
|
# @return [Integer] the value of fetch_timeout_millis
|
17
21
|
attr_accessor :fetch_timeout_millis
|
@@ -40,6 +44,8 @@ module AmplitudeExperiment
|
|
40
44
|
|
41
45
|
# @param [Boolean] debug Set to true to log some extra information to the console.
|
42
46
|
# @param [String] server_url The server endpoint from which to request variants.
|
47
|
+
# @param [Integer] connect_timeout_millis The request connection open timeout, in milliseconds, used when
|
48
|
+
# fetching variants triggered by calling start() or setUser().
|
43
49
|
# @param [Integer] fetch_timeout_millis The request timeout, in milliseconds, used when fetching variants
|
44
50
|
# triggered by calling start() or setUser().
|
45
51
|
# @param [Integer] fetch_retries The number of retries to attempt before failing.
|
@@ -49,11 +55,12 @@ module AmplitudeExperiment
|
|
49
55
|
# greater than the max, the max is used for all subsequent retries.
|
50
56
|
# @param [Float] fetch_retry_backoff_scalar Scales the minimum backoff exponentially.
|
51
57
|
# @param [Integer] fetch_retry_timeout_millis The request timeout for retrying fetch requests.
|
52
|
-
def initialize(debug: false, server_url: DEFAULT_SERVER_URL, fetch_timeout_millis: 10_000, fetch_retries: 0,
|
58
|
+
def initialize(debug: false, server_url: DEFAULT_SERVER_URL, connect_timeout_millis: 60_000, fetch_timeout_millis: 10_000, fetch_retries: 0,
|
53
59
|
fetch_retry_backoff_min_millis: 500, fetch_retry_backoff_max_millis: 10_000,
|
54
60
|
fetch_retry_backoff_scalar: 1.5, fetch_retry_timeout_millis: 10_000)
|
55
61
|
@debug = debug
|
56
62
|
@server_url = server_url
|
63
|
+
@connect_timeout_millis = connect_timeout_millis
|
57
64
|
@fetch_timeout_millis = fetch_timeout_millis
|
58
65
|
@fetch_retries = fetch_retries
|
59
66
|
@fetch_retry_backoff_min_millis = fetch_retry_backoff_min_millis
|
data/lib/experiment/user.rb
CHANGED
@@ -80,6 +80,14 @@ module AmplitudeExperiment
|
|
80
80
|
# @return [Hash, nil] the value of group properties
|
81
81
|
attr_accessor :group_properties
|
82
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
|
+
|
83
91
|
# @param [String, nil] device_id Device ID for associating with an identity in Amplitude
|
84
92
|
# @param [String, nil] user_id User ID for associating with an identity in Amplitude
|
85
93
|
# @param [String, nil] country Predefined field, must be manually provided
|
@@ -101,7 +109,8 @@ module AmplitudeExperiment
|
|
101
109
|
# @param [Hash, nil] group_properties Custom properties for groups
|
102
110
|
def initialize(device_id: nil, user_id: nil, country: nil, city: nil, region: nil, dma: nil, ip_address: nil, language: nil,
|
103
111
|
platform: nil, version: nil, os: nil, device_manufacturer: nil, device_brand: nil,
|
104
|
-
device_model: nil, carrier: nil, library: nil, user_properties: nil, groups: nil, group_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)
|
105
114
|
@device_id = device_id
|
106
115
|
@user_id = user_id
|
107
116
|
@country = country
|
@@ -121,31 +130,35 @@ module AmplitudeExperiment
|
|
121
130
|
@user_properties = user_properties
|
122
131
|
@groups = groups
|
123
132
|
@group_properties = group_properties
|
133
|
+
@cohort_ids = cohort_ids
|
134
|
+
@group_cohort_ids = group_cohort_ids
|
124
135
|
end
|
125
136
|
|
126
137
|
# Return User as Hash.
|
127
138
|
# @return [Hash] Hash object with user values
|
128
139
|
def as_json(_options = {})
|
129
140
|
{
|
130
|
-
device_id
|
131
|
-
user_id
|
132
|
-
country
|
133
|
-
city
|
134
|
-
region
|
135
|
-
dma
|
136
|
-
ip_address
|
137
|
-
language
|
138
|
-
platform
|
139
|
-
version
|
140
|
-
os
|
141
|
-
device_manufacturer
|
142
|
-
device_brand
|
143
|
-
device_model
|
144
|
-
carrier
|
145
|
-
library
|
146
|
-
user_properties
|
147
|
-
groups
|
148
|
-
group_properties
|
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
|
149
162
|
}.compact
|
150
163
|
end
|
151
164
|
|
@@ -154,5 +167,12 @@ module AmplitudeExperiment
|
|
154
167
|
def to_json(*options)
|
155
168
|
as_json(*options).to_json(*options)
|
156
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
|
157
177
|
end
|
158
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
|
data/lib/experiment/util/user.rb
CHANGED
@@ -2,11 +2,13 @@ module AmplitudeExperiment
|
|
2
2
|
def self.user_to_evaluation_context(user)
|
3
3
|
user_groups = user.groups
|
4
4
|
user_group_properties = user.group_properties
|
5
|
+
user_group_cohort_ids = user.group_cohort_ids
|
5
6
|
user_hash = user.as_json.compact
|
6
|
-
user_hash.delete(
|
7
|
-
user_hash.delete(
|
7
|
+
user_hash.delete('groups')
|
8
|
+
user_hash.delete('group_properties')
|
9
|
+
user_hash.delete('group_cohort_ids')
|
8
10
|
|
9
|
-
context = user_hash.empty? ? {} : { user
|
11
|
+
context = user_hash.empty? ? {} : { 'user' => user_hash }
|
10
12
|
|
11
13
|
return context if user_groups.nil?
|
12
14
|
|
@@ -14,20 +16,26 @@ module AmplitudeExperiment
|
|
14
16
|
user_groups.each do |group_type, group_name|
|
15
17
|
group_name = group_name[0] if group_name.is_a?(Array) && !group_name.empty?
|
16
18
|
|
17
|
-
groups[group_type
|
19
|
+
groups[group_type] = { 'group_name' => group_name }
|
18
20
|
|
19
|
-
|
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
|
20
28
|
|
21
|
-
|
22
|
-
next if group_properties_type.nil? || !group_properties_type.is_a?(Hash)
|
29
|
+
next unless user_group_cohort_ids
|
23
30
|
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
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
|
28
36
|
end
|
29
37
|
|
30
|
-
context[
|
38
|
+
context['groups'] = groups unless groups.empty?
|
31
39
|
context
|
32
40
|
end
|
33
41
|
end
|
data/lib/experiment/version.rb
CHANGED