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.
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