amplitude-experiment 1.3.1 → 1.5.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/amplitude-experiment.gemspec +2 -1
  3. data/lib/amplitude-experiment.rb +13 -1
  4. data/lib/experiment/cohort/cohort.rb +25 -0
  5. data/lib/experiment/cohort/cohort_download_api.rb +90 -0
  6. data/lib/experiment/cohort/cohort_loader.rb +39 -0
  7. data/lib/experiment/cohort/cohort_storage.rb +91 -0
  8. data/lib/experiment/cohort/cohort_sync_config.rb +27 -0
  9. data/lib/experiment/deployment/deployment_runner.rb +135 -0
  10. data/lib/experiment/error.rb +37 -0
  11. data/lib/experiment/{local/fetcher.rb → flag/flag_config_fetcher.rb} +20 -3
  12. data/lib/experiment/flag/flag_config_storage.rb +53 -0
  13. data/lib/experiment/local/assignment/assignment.rb +3 -1
  14. data/lib/experiment/local/assignment/assignment_service.rb +15 -14
  15. data/lib/experiment/local/client.rb +83 -39
  16. data/lib/experiment/local/config.rb +26 -2
  17. data/lib/experiment/local/evaluation/evaluation.rb +2 -2
  18. data/lib/experiment/local/evaluation/lib/linuxArm64/libevaluation_interop.so +0 -0
  19. data/lib/experiment/local/evaluation/lib/linuxArm64/libevaluation_interop_api.h +1 -1
  20. data/lib/experiment/local/evaluation/lib/linuxX64/libevaluation_interop.so +0 -0
  21. data/lib/experiment/local/evaluation/lib/linuxX64/libevaluation_interop_api.h +1 -1
  22. data/lib/experiment/local/evaluation/lib/macosArm64/libevaluation_interop.dylib +0 -0
  23. data/lib/experiment/local/evaluation/lib/macosArm64/libevaluation_interop_api.h +1 -1
  24. data/lib/experiment/local/evaluation/lib/macosX64/libevaluation_interop.dylib +0 -0
  25. data/lib/experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h +1 -1
  26. data/lib/experiment/remote/client.rb +2 -34
  27. data/lib/experiment/user.rb +53 -19
  28. data/lib/experiment/util/flag_config.rb +60 -0
  29. data/lib/experiment/util/poller.rb +24 -0
  30. data/lib/experiment/util/topological_sort.rb +39 -0
  31. data/lib/experiment/util/user.rb +41 -0
  32. data/lib/experiment/util/variant.rb +32 -0
  33. data/lib/experiment/variant.rb +3 -1
  34. data/lib/experiment/version.rb +1 -1
  35. 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 = 'mutual_exclusion_group'.freeze
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
- flags = @flags_mutex.synchronize do
42
- @flags
43
- end
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
- user_str = user.to_json
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: #{user_str} - Rules: #{flags}") if @config.debug
49
- result = evaluation(flags, user_str)
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
- parse_results(result, flag_keys, user)
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
- run
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
- @poller_thread = nil
97
+ @deployment_runner.stop
68
98
  end
69
99
 
70
100
  private
71
101
 
72
- def parse_results(result, flag_keys, user)
73
- variants = {}
74
- assignments = {}
75
- result.each do |key, value|
76
- included = flag_keys.empty? || flag_keys.include?(key)
77
- if !value['isDefaultVariant'] && included
78
- variant_key = value['variant']['key']
79
- variant_payload = value['variant']['payload']
80
- variants.store(key, Variant.new(variant_key, variant_payload))
81
- end
82
-
83
- assignments[key] = value if included || value['type'] == FLAG_TYPE_MUTUAL_EXCLUSION_GROUP || value['type'] == FLAG_TYPE_HOLDOUT_GROUP
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 run
90
- @is_running = true
91
- begin
92
- flags = @fetcher.fetch_v1
93
- @flags_mutex.synchronize do
94
- @flags = flags
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
- @poller_thread = Thread.new do
100
- sleep(@config.flag_config_polling_interval_millis / 1000.to_f)
101
- run
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
- def initialize(server_url: DEFAULT_SERVER_URL, bootstrap: {},
28
- flag_config_polling_interval_millis: 30_000, debug: false, assignment_config: nil)
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, user_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, user_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)
@@ -99,7 +99,7 @@ typedef struct {
99
99
  /* User functions. */
100
100
  struct {
101
101
  struct {
102
- const char* (*evaluate)(const char* rules, const char* user);
102
+ const char* (*evaluate)(const char* flags, const char* context);
103
103
  } root;
104
104
  } kotlin;
105
105
  } libevaluation_interop_ExportedSymbols;
@@ -99,7 +99,7 @@ typedef struct {
99
99
  /* User functions. */
100
100
  struct {
101
101
  struct {
102
- const char* (*evaluate)(const char* rules, const char* user);
102
+ const char* (*evaluate)(const char* flags, const char* context);
103
103
  } root;
104
104
  } kotlin;
105
105
  } libevaluation_interop_ExportedSymbols;
@@ -99,7 +99,7 @@ typedef struct {
99
99
  /* User functions. */
100
100
  struct {
101
101
  struct {
102
- const char* (*evaluate)(const char* rules, const char* user);
102
+ const char* (*evaluate)(const char* flags, const char* context);
103
103
  } root;
104
104
  } kotlin;
105
105
  } libevaluation_interop_ExportedSymbols;
@@ -99,7 +99,7 @@ typedef struct {
99
99
  /* User functions. */
100
100
  struct {
101
101
  struct {
102
- const char* (*evaluate)(const char* rules, const char* user);
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 = parse_json_variants(json)
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
@@ -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: @device_id,
119
- user_id: @user_id,
120
- country: @country,
121
- city: @city,
122
- region: @region,
123
- dma: @dma,
124
- ip_address: @ip_address,
125
- language: @language,
126
- platform: @platform,
127
- version: @version,
128
- os: @os,
129
- device_manufacturer: @device_manufacturer,
130
- device_brand: @device_brand,
131
- device_model: @device_model,
132
- carrier: @carrier,
133
- library: @library,
134
- user_properties: @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