amplitude-experiment 1.4.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6df1a01ac720c72256be526321f9b30f93fcdde050ff6a0743c24bd53a64dd5b
4
- data.tar.gz: d10ebba30ae8a59b7964536909315059c8d096dc1b3a9d0e735c7242db400153
3
+ metadata.gz: c79062c9e6a6f0877449f49dab03aa2445cf31700ecb92010f88d5779759d73a
4
+ data.tar.gz: 1cf7d73b53cb125ecfaa6987bb4569d7416c5b02e194ef9f90be65c8da0b2fe6
5
5
  SHA512:
6
- metadata.gz: d623457be0fb4cc632f708365d53abbab2b7b647baf7ab37d18d6a05f21e321960e0632152ff2e60686d590e598cfa4333313cb6e31ad29868d2e4798be3e200
7
- data.tar.gz: e9800dd0da6e0edf6ee8c01bf289700e7f61963fc2e379ea55ddd54dda7c52c8b85f0185c07fcd2a3d2aa8b92391a0bda9828c794f1960d16e39f1e10ed15210
6
+ metadata.gz: 45e8a5540fc7a0e9a9213bdfe7c8828137d35aee0ab6d5dab50e79cac47bf858f11db626a545f131c7a5e48bf05b04b9b82944441d5c307d9562fd42973e5edb
7
+ data.tar.gz: ffebca0974025ed2e983b106468be3806eb724cb5f0a2723d0f2782b67005d679ca8cfec4edb1b5236578a855472838a1f20e81d7641e362301d35fc5bfec32c
@@ -24,10 +24,11 @@ Gem::Specification.new do |spec|
24
24
  spec.add_development_dependency 'rake', '~> 13.0'
25
25
  spec.add_development_dependency 'rdoc', '= 6.4'
26
26
  spec.add_development_dependency 'rspec', '~> 3.6'
27
- spec.add_development_dependency 'rubocop', '= 1.21'
27
+ spec.add_development_dependency 'rubocop', '= 1.22.3'
28
28
  spec.add_development_dependency 'simplecov', '~> 0.21'
29
29
  spec.add_development_dependency 'webmock', '~> 3.14'
30
30
  spec.add_development_dependency 'yard', '~> 0.9'
31
+ spec.add_development_dependency 'dotenv', '~> 2.8.1'
31
32
  spec.metadata['rubygems_mfa_required'] = 'false'
32
33
  spec.add_runtime_dependency 'ffi', '~> 1.15'
33
34
  end
@@ -8,7 +8,6 @@ require 'experiment/factory'
8
8
  require 'experiment/remote/client'
9
9
  require 'experiment/local/client'
10
10
  require 'experiment/local/config'
11
- require 'experiment/local/fetcher'
12
11
  require 'experiment/local/assignment/assignment'
13
12
  require 'experiment/local/assignment/assignment_filter'
14
13
  require 'experiment/local/assignment/assignment_service'
@@ -19,6 +18,16 @@ require 'experiment/util/topological_sort'
19
18
  require 'experiment/util/user'
20
19
  require 'experiment/util/variant'
21
20
  require 'experiment/error'
21
+ require 'experiment/util/flag_config'
22
+ require 'experiment/flag/flag_config_fetcher'
23
+ require 'experiment/flag/flag_config_storage'
24
+ require 'experiment/cohort/cohort_download_api'
25
+ require 'experiment/cohort/cohort'
26
+ require 'experiment/cohort/cohort_loader'
27
+ require 'experiment/cohort/cohort_storage'
28
+ require 'experiment/cohort/cohort_sync_config'
29
+ require 'experiment/deployment/deployment_runner'
30
+ require 'experiment/util/poller'
22
31
 
23
32
  # Amplitude Experiment Module
24
33
  module AmplitudeExperiment
@@ -0,0 +1,25 @@
1
+ module AmplitudeExperiment
2
+ USER_GROUP_TYPE = 'User'.freeze
3
+ # Cohort
4
+ class Cohort
5
+ attr_accessor :id, :last_modified, :size, :member_ids, :group_type
6
+
7
+ def initialize(id, last_modified, size, member_ids, group_type = USER_GROUP_TYPE)
8
+ @id = id
9
+ @last_modified = last_modified
10
+ @size = size
11
+ @member_ids = member_ids.to_set
12
+ @group_type = group_type
13
+ end
14
+
15
+ def ==(other)
16
+ return false unless other.is_a?(Cohort)
17
+
18
+ @id == other.id &&
19
+ @last_modified == other.last_modified &&
20
+ @size == other.size &&
21
+ @member_ids == other.member_ids &&
22
+ @group_type == other.group_type
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,90 @@
1
+ require 'base64'
2
+ require 'json'
3
+ require 'net/http'
4
+ require 'uri'
5
+ require 'set'
6
+
7
+ module AmplitudeExperiment
8
+ # CohortDownloadApi
9
+ class CohortDownloadApi
10
+ COHORT_REQUEST_TIMEOUT_MILLIS = 5000
11
+ COHORT_REQUEST_RETRY_DELAY_MILLIS = 100
12
+
13
+ def get_cohort(cohort_id, cohort = nil)
14
+ raise NotImplementedError
15
+ end
16
+ end
17
+
18
+ # DirectCohortDownloadApi
19
+ class DirectCohortDownloadApi < CohortDownloadApi
20
+ def initialize(api_key, secret_key, max_cohort_size, server_url, logger)
21
+ super()
22
+ @api_key = api_key
23
+ @secret_key = secret_key
24
+ @max_cohort_size = max_cohort_size
25
+ @server_url = server_url
26
+ @logger = logger
27
+ end
28
+
29
+ def get_cohort(cohort_id, cohort = nil)
30
+ @logger.debug("getCohortMembers(#{cohort_id}): start")
31
+ errors = 0
32
+
33
+ loop do
34
+ begin
35
+ last_modified = cohort.nil? ? nil : cohort.last_modified
36
+ response = get_cohort_members_request(cohort_id, last_modified)
37
+ @logger.debug("getCohortMembers(#{cohort_id}): status=#{response.code}")
38
+
39
+ case response.code.to_i
40
+ when 200
41
+ cohort_info = JSON.parse(response.body)
42
+ @logger.debug("getCohortMembers(#{cohort_id}): end - resultSize=#{cohort_info['size']}")
43
+ return Cohort.new(
44
+ cohort_info['cohortId'],
45
+ cohort_info['lastModified'],
46
+ cohort_info['size'],
47
+ cohort_info['memberIds'].to_set,
48
+ cohort_info['groupType']
49
+ )
50
+ when 204
51
+ @logger.debug("getCohortMembers(#{cohort_id}): Cohort not modified")
52
+ return nil
53
+ when 413
54
+ raise CohortTooLargeError.new(cohort_id, "Cohort exceeds max cohort size: #{response.code}")
55
+ else
56
+ raise HTTPErrorResponseError.new(response.code, cohort_id, "Unexpected response code: #{response.code}") if response.code.to_i != 202
57
+
58
+ end
59
+ rescue StandardError => e
60
+ errors += 1 unless response && e.is_a?(HTTPErrorResponseError) && response.code.to_i == 429
61
+ @logger.debug("getCohortMembers(#{cohort_id}): request-status error #{errors} - #{e}")
62
+ raise e if errors >= 3 || e.is_a?(CohortTooLargeError)
63
+ end
64
+
65
+ sleep(COHORT_REQUEST_RETRY_DELAY_MILLIS / 1000.0)
66
+ end
67
+ end
68
+
69
+ private
70
+
71
+ def get_cohort_members_request(cohort_id, last_modified)
72
+ headers = {
73
+ 'Authorization' => "Basic #{basic_auth}",
74
+ 'Content-Type' => 'application/json;charset=utf-8',
75
+ 'X-Amp-Exp-Library' => "experiment-ruby-server/#{VERSION}"
76
+ }
77
+ url = "#{@server_url}/sdk/v1/cohort/#{cohort_id}?maxCohortSize=#{@max_cohort_size}"
78
+ url += "&lastModified=#{last_modified}" if last_modified
79
+
80
+ request = Net::HTTP::Get.new(URI(url), headers)
81
+ http = PersistentHttpClient.get(@server_url, { read_timeout: COHORT_REQUEST_TIMEOUT_MILLIS }, basic_auth)
82
+ http.request(request)
83
+ end
84
+
85
+ def basic_auth
86
+ credentials = "#{@api_key}:#{@secret_key}"
87
+ Base64.strict_encode64(credentials)
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,39 @@
1
+ module AmplitudeExperiment
2
+ # CohortLoader
3
+ class CohortLoader
4
+ def initialize(cohort_download_api, cohort_storage)
5
+ @cohort_download_api = cohort_download_api
6
+ @cohort_storage = cohort_storage
7
+ @jobs = {}
8
+ @lock_jobs = Mutex.new
9
+ end
10
+
11
+ def load_cohort(cohort_id)
12
+ @lock_jobs.synchronize do
13
+ unless @jobs.key?(cohort_id)
14
+ future = Concurrent::Promises.future do
15
+ load_cohort_internal(cohort_id)
16
+ ensure
17
+ remove_job(cohort_id)
18
+ end
19
+ @jobs[cohort_id] = future
20
+ end
21
+ @jobs[cohort_id]
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def load_cohort_internal(cohort_id)
28
+ stored_cohort = @cohort_storage.cohort(cohort_id)
29
+ updated_cohort = @cohort_download_api.get_cohort(cohort_id, stored_cohort)
30
+ @cohort_storage.put_cohort(updated_cohort) unless updated_cohort.nil?
31
+ end
32
+
33
+ def remove_job(cohort_id)
34
+ @lock_jobs.synchronize do
35
+ @jobs.delete(cohort_id)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,91 @@
1
+ module AmplitudeExperiment
2
+ # CohortStorage
3
+ class CohortStorage
4
+ def cohort(cohort_id)
5
+ raise NotImplementedError
6
+ end
7
+
8
+ def cohorts
9
+ raise NotImplementedError
10
+ end
11
+
12
+ def get_cohorts_for_user(user_id, cohort_ids)
13
+ raise NotImplementedError
14
+ end
15
+
16
+ def get_cohorts_for_group(group_type, group_name, cohort_ids)
17
+ raise NotImplementedError
18
+ end
19
+
20
+ def put_cohort(cohort_description)
21
+ raise NotImplementedError
22
+ end
23
+
24
+ def delete_cohort(group_type, cohort_id)
25
+ raise NotImplementedError
26
+ end
27
+
28
+ def cohort_ids
29
+ raise NotImplementedError
30
+ end
31
+ end
32
+
33
+ class InMemoryCohortStorage < CohortStorage
34
+ def initialize
35
+ super
36
+ @lock = Mutex.new
37
+ @group_to_cohort_store = {}
38
+ @cohort_store = {}
39
+ end
40
+
41
+ def cohort(cohort_id)
42
+ @lock.synchronize do
43
+ @cohort_store[cohort_id]
44
+ end
45
+ end
46
+
47
+ def cohorts
48
+ @lock.synchronize do
49
+ @cohort_store.dup
50
+ end
51
+ end
52
+
53
+ def get_cohorts_for_user(user_id, cohort_ids)
54
+ get_cohorts_for_group(USER_GROUP_TYPE, user_id, cohort_ids)
55
+ end
56
+
57
+ def get_cohorts_for_group(group_type, group_name, cohort_ids)
58
+ result = Set.new
59
+ @lock.synchronize do
60
+ group_type_cohorts = @group_to_cohort_store[group_type] || Set.new
61
+ group_type_cohorts.each do |cohort_id|
62
+ members = @cohort_store[cohort_id]&.member_ids || Set.new
63
+ result.add(cohort_id) if cohort_ids.include?(cohort_id) && members.include?(group_name)
64
+ end
65
+ end
66
+ result
67
+ end
68
+
69
+ def put_cohort(cohort)
70
+ @lock.synchronize do
71
+ @group_to_cohort_store[cohort.group_type] ||= Set.new
72
+ @group_to_cohort_store[cohort.group_type].add(cohort.id)
73
+ @cohort_store[cohort.id] = cohort
74
+ end
75
+ end
76
+
77
+ def delete_cohort(group_type, cohort_id)
78
+ @lock.synchronize do
79
+ group_cohorts = @group_to_cohort_store[group_type] || Set.new
80
+ group_cohorts.delete(cohort_id)
81
+ @cohort_store.delete(cohort_id)
82
+ end
83
+ end
84
+
85
+ def cohort_ids
86
+ @lock.synchronize do
87
+ @cohort_store.keys.to_set
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,27 @@
1
+ module AmplitudeExperiment
2
+ DEFAULT_COHORT_SYNC_URL = 'https://cohort-v2.lab.amplitude.com'.freeze
3
+ EU_COHORT_SYNC_URL = 'https://cohort-v2.lab.eu.amplitude.com'.freeze
4
+
5
+ # Experiment Cohort Sync Configuration
6
+ class CohortSyncConfig
7
+ # This configuration is used to set up the cohort loader. The cohort loader is responsible for
8
+ # downloading cohorts from the server and storing them locally.
9
+ # Parameters:
10
+ # api_key (str): The project API Key
11
+ # secret_key (str): The project Secret Key
12
+ # max_cohort_size (int): The maximum cohort size that can be downloaded
13
+ # cohort_polling_interval_millis (int): The interval in milliseconds to poll for cohorts, the minimum value is 60000
14
+ # cohort_server_url (str): The server endpoint from which to request cohorts
15
+
16
+ attr_accessor :api_key, :secret_key, :max_cohort_size, :cohort_polling_interval_millis, :cohort_server_url
17
+
18
+ def initialize(api_key, secret_key, max_cohort_size: 2_147_483_647, cohort_polling_interval_millis: 60_000,
19
+ cohort_server_url: DEFAULT_COHORT_SYNC_URL)
20
+ @api_key = api_key
21
+ @secret_key = secret_key
22
+ @max_cohort_size = max_cohort_size
23
+ @cohort_polling_interval_millis = [cohort_polling_interval_millis, 60_000].max
24
+ @cohort_server_url = cohort_server_url
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,135 @@
1
+ require 'set'
2
+
3
+ module AmplitudeExperiment
4
+ # DeploymentRunner
5
+ class DeploymentRunner
6
+ def initialize(
7
+ config,
8
+ flag_config_fetcher,
9
+ flag_config_storage,
10
+ cohort_storage,
11
+ logger,
12
+ cohort_loader = nil
13
+ )
14
+ @config = config
15
+ @flag_config_fetcher = flag_config_fetcher
16
+ @flag_config_storage = flag_config_storage
17
+ @cohort_storage = cohort_storage
18
+ @cohort_loader = cohort_loader
19
+ @lock = Mutex.new
20
+ @logger = logger
21
+ @executor = Concurrent::ThreadPoolExecutor.new(
22
+ max_threads: 10,
23
+ name: 'DeploymentRunnerExecutor'
24
+ )
25
+ end
26
+
27
+ def start
28
+ @lock.synchronize do
29
+ update_flag_configs
30
+ @flag_poller = Poller.new(
31
+ @config.flag_config_polling_interval_millis / 1000.0,
32
+ method(:periodic_flag_update)
33
+ )
34
+ @flag_poller.start
35
+ if @config.cohort_sync_config
36
+ @cohort_poller = Poller.new(
37
+ @config.cohort_sync_config.cohort_polling_interval_millis / 1000.0,
38
+ method(:update_cohorts)
39
+ )
40
+ @cohort_poller.start
41
+ end
42
+ end
43
+ end
44
+
45
+ def stop
46
+ @flag_poller&.stop
47
+ @flag_poller = nil
48
+ @cohort_poller&.stop
49
+ @cohort_poller = nil
50
+ end
51
+
52
+ private
53
+
54
+ def periodic_flag_update
55
+ @logger.debug('Periodic flag update: start')
56
+ update_flag_configs
57
+ rescue StandardError => e
58
+ @logger.error("Error while updating flags: #{e}")
59
+ end
60
+
61
+ def update_flag_configs
62
+ flags = @flag_config_fetcher.fetch_v2
63
+ flag_configs = flags.each_with_object({}) { |flag, hash| hash[flag['key']] = flag }
64
+ flag_keys = flag_configs.values.map { |flag| flag['key'] }.to_set
65
+ @flag_config_storage.remove_if { |f| !flag_keys.include?(f['key']) }
66
+
67
+ unless @cohort_loader
68
+ flag_configs.each do |flag_key, flag_config|
69
+ @logger.debug("Putting non-cohort flag #{flag_key}")
70
+ @flag_config_storage.put_flag_config(flag_config)
71
+ end
72
+ return
73
+ end
74
+
75
+ new_cohort_ids = Set.new
76
+ flag_configs.each do |_, flag_config|
77
+ new_cohort_ids.merge(AmplitudeExperiment.get_all_cohort_ids_from_flag(flag_config))
78
+ end
79
+
80
+ existing_cohort_ids = @cohort_storage.cohort_ids
81
+ cohort_ids_to_download = new_cohort_ids - existing_cohort_ids
82
+
83
+ download_cohorts(cohort_ids_to_download)
84
+
85
+ updated_cohort_ids = @cohort_storage.cohort_ids
86
+
87
+ flag_configs.each do |flag_key, flag_config|
88
+ cohort_ids = AmplitudeExperiment.get_all_cohort_ids_from_flag(flag_config)
89
+ @logger.debug("Storing flag #{flag_key}")
90
+ @flag_config_storage.put_flag_config(flag_config)
91
+ missing_cohorts = cohort_ids - updated_cohort_ids
92
+
93
+ @logger.warn("Flag #{flag_key} - failed to load cohorts: #{missing_cohorts}") if missing_cohorts.any?
94
+ end
95
+
96
+ delete_unused_cohorts
97
+ @logger.debug("Refreshed #{flag_configs.size} flag configs.")
98
+ end
99
+
100
+ def download_cohorts(cohort_ids)
101
+ futures = cohort_ids.map do |cohort_id|
102
+ Concurrent::Promises.future_on(@executor) do
103
+ future = @cohort_loader.load_cohort(cohort_id)
104
+ future.value!
105
+ rescue StandardError => e
106
+ @logger.error("Failed to download cohort #{cohort_id}: #{e.message}")
107
+ nil
108
+ end
109
+ end
110
+
111
+ Concurrent::Promises.zip(*futures).value!
112
+ end
113
+
114
+ def update_cohorts
115
+ @logger.debug('Periodic cohort update: start')
116
+ cohort_ids = AmplitudeExperiment.get_all_cohort_ids_from_flags(@flag_config_storage.flag_configs)
117
+ download_cohorts(cohort_ids)
118
+ end
119
+
120
+ def delete_unused_cohorts
121
+ flag_cohort_ids = Set.new
122
+ @flag_config_storage.flag_configs.each do |_, flag|
123
+ flag_cohort_ids.merge(AmplitudeExperiment.get_all_cohort_ids_from_flag(flag))
124
+ end
125
+
126
+ storage_cohorts = @cohort_storage.cohorts
127
+ deleted_cohort_ids = storage_cohorts.keys.to_set - flag_cohort_ids
128
+
129
+ deleted_cohort_ids.each do |deleted_cohort_id|
130
+ deleted_cohort = storage_cohorts[deleted_cohort_id]
131
+ @cohort_storage.delete_cohort(deleted_cohort.group_type, deleted_cohort_id) if deleted_cohort
132
+ end
133
+ end
134
+ end
135
+ end
@@ -9,6 +9,29 @@ module AmplitudeExperiment
9
9
  end
10
10
  end
11
11
 
12
+ class CohortDownloadError < StandardError
13
+ attr_reader :cohort_id
14
+
15
+ def initialize(cohort_id, message)
16
+ super(message)
17
+ @cohort_id = cohort_id
18
+ end
19
+ end
20
+
21
+ # CohortTooLargeError
22
+ class CohortTooLargeError < CohortDownloadError
23
+ end
24
+
25
+ # HTTPErrorResponseError
26
+ class HTTPErrorResponseError < CohortDownloadError
27
+ attr_reader :status_code
28
+
29
+ def initialize(status_code, cohort_id, message)
30
+ super(cohort_id, message)
31
+ @status_code = status_code
32
+ end
33
+ end
34
+
12
35
  class CycleError < StandardError
13
36
  # Raised when topological sorting encounters a cycle between flag dependencies.
14
37
  attr_reader :path
@@ -9,7 +9,6 @@ module AmplitudeExperiment
9
9
  @api_key = api_key
10
10
  @server_url = server_url
11
11
  @logger = logger
12
- @http = PersistentHttpClient.get(server_url, { read_timeout: FLAG_CONFIG_TIMEOUT }, @api_key)
13
12
  end
14
13
 
15
14
  # Fetch local evaluation mode flag configs from the Experiment API server.
@@ -24,7 +23,8 @@ module AmplitudeExperiment
24
23
  'X-Amp-Exp-Library' => "experiment-ruby-server/#{VERSION}"
25
24
  }
26
25
  request = Net::HTTP::Get.new("#{@server_url}/sdk/v1/flags", headers)
27
- response = @http.request(request)
26
+ http = PersistentHttpClient.get(@server_url, { read_timeout: FLAG_CONFIG_TIMEOUT }, @api_key)
27
+ response = http.request(request)
28
28
  raise "flagConfigs - received error response: #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPOK)
29
29
 
30
30
  @logger.debug("[Experiment] Fetch flag configs: #{response.body}")
@@ -39,11 +39,12 @@ module AmplitudeExperiment
39
39
  'X-Amp-Exp-Library' => "experiment-ruby-server/#{VERSION}"
40
40
  }
41
41
  request = Net::HTTP::Get.new("#{@server_url}/sdk/v2/flags?v=0", headers)
42
- response = @http.request(request)
42
+ http = PersistentHttpClient.get(@server_url, { read_timeout: FLAG_CONFIG_TIMEOUT }, @api_key)
43
+ response = http.request(request)
43
44
  raise "flagConfigs - received error response: #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPOK)
44
45
 
45
46
  @logger.debug("[Experiment] Fetch flag configs: #{response.body}")
46
- response.body
47
+ JSON.parse(response.body)
47
48
  end
48
49
 
49
50
  # Fetch local evaluation mode flag configs from the Experiment API server.
@@ -58,7 +59,8 @@ module AmplitudeExperiment
58
59
  'X-Amp-Exp-Library' => "experiment-ruby-server/#{VERSION}"
59
60
  }
60
61
  request = Net::HTTP::Get.new("#{@server_url}/sdk/rules?eval_mode=local", headers)
61
- response = @http.request(request)
62
+ http = PersistentHttpClient.get(@server_url, { read_timeout: FLAG_CONFIG_TIMEOUT }, @api_key)
63
+ response = http.request(request)
62
64
  raise "flagConfigs - received error response: #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPOK)
63
65
 
64
66
  flag_configs = parse(response.body)
@@ -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
@@ -23,11 +23,24 @@ module AmplitudeExperiment
23
23
  else
24
24
  Logger::INFO
25
25
  end
26
- @fetcher = LocalEvaluationFetcher.new(api_key, @logger, @config.server_url)
27
26
  raise ArgumentError, 'Experiment API key is empty' if @api_key.nil? || @api_key.empty?
28
27
 
29
28
  @assignment_service = nil
30
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)
31
44
  end
32
45
 
33
46
  # Locally evaluates flag variants for a user.
@@ -51,19 +64,18 @@ module AmplitudeExperiment
51
64
  # @param [String[]] flag_keys The flags to evaluate with the user, if empty all flags are evaluated
52
65
  # @return [Hash[String, Variant]] The evaluated variants
53
66
  def evaluate_v2(user, flag_keys = [])
54
- flags = @flags_mutex.synchronize do
55
- @flags
56
- end
67
+ flags = @flag_config_storage.flag_configs
57
68
  return {} if flags.nil?
58
69
 
59
70
  sorted_flags = AmplitudeExperiment.topological_sort(flags, flag_keys.to_set)
71
+ required_cohorts_in_storage(sorted_flags)
60
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
61
76
 
62
- enriched_user = AmplitudeExperiment.user_to_evaluation_context(user)
63
- user_str = enriched_user.to_json
64
-
65
- @logger.debug("[Experiment] Evaluate: User: #{user_str} - Rules: #{flags}") if @config.debug
66
- result = evaluation(flags_json, user_str)
77
+ @logger.debug("[Experiment] Evaluate: User: #{context_json} - Rules: #{flags}") if @config.debug
78
+ result = evaluation(flags_json, context_json)
67
79
  @logger.debug("[Experiment] evaluate - result: #{result}") if @config.debug
68
80
  variants = AmplitudeExperiment.evaluation_variants_json_to_variants(result)
69
81
  @assignment_service&.track(Assignment.new(user, variants))
@@ -76,34 +88,62 @@ module AmplitudeExperiment
76
88
  return if @is_running
77
89
 
78
90
  @logger.debug('[Experiment] poller - start') if @debug
79
- run
91
+ @deployment_runner.start
80
92
  end
81
93
 
82
94
  # Stop polling for flag configurations. Close resource like connection pool with client
83
95
  def stop
84
- @poller_thread&.exit
85
96
  @is_running = false
86
- @poller_thread = nil
97
+ @deployment_runner.stop
87
98
  end
88
99
 
89
100
  private
90
101
 
91
- def run
92
- @is_running = true
93
- begin
94
- flags = @fetcher.fetch_v2
95
- flags_obj = JSON.parse(flags)
96
- flags_map = flags_obj.each_with_object({}) { |flag, hash| hash[flag['key']] = flag }
97
- @flags_mutex.synchronize do
98
- @flags = flags_map
99
- end
100
- rescue StandardError => e
101
- @logger.error("[Experiment] Flag poller - error: #{e.message}")
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)
122
+ end
123
+ end
124
+
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
102
131
  end
103
- @poller_thread = Thread.new do
104
- sleep(@config.flag_config_polling_interval_millis / 1000.to_f)
105
- 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
+ )
106
145
  end
146
+ user
107
147
  end
108
148
  end
109
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
@@ -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: @device_id,
131
- user_id: @user_id,
132
- country: @country,
133
- city: @city,
134
- region: @region,
135
- dma: @dma,
136
- ip_address: @ip_address,
137
- language: @language,
138
- platform: @platform,
139
- version: @version,
140
- os: @os,
141
- device_manufacturer: @device_manufacturer,
142
- device_brand: @device_brand,
143
- device_model: @device_model,
144
- carrier: @carrier,
145
- library: @library,
146
- user_properties: @user_properties,
147
- groups: @groups,
148
- group_properties: @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
@@ -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(:groups)
7
- user_hash.delete(:group_properties)
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: user_hash }
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.to_sym] = { group_name: group_name }
19
+ groups[group_type] = { 'group_name' => group_name }
18
20
 
19
- next if user_group_properties.nil?
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
- group_properties_type = user_group_properties[group_type.to_sym]
22
- next if group_properties_type.nil? || !group_properties_type.is_a?(Hash)
29
+ next unless user_group_cohort_ids
23
30
 
24
- group_properties_name = group_properties_type[group_name.to_sym]
25
- next if group_properties_name.nil? || !group_properties_name.is_a?(Hash)
26
-
27
- groups[group_type.to_sym][:group_properties] = group_properties_name
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[:groups] = groups unless groups.empty?
38
+ context['groups'] = groups unless groups.empty?
31
39
  context
32
40
  end
33
41
  end
@@ -1,3 +1,3 @@
1
1
  module AmplitudeExperiment
2
- VERSION = '1.4.0'.freeze
2
+ VERSION = '1.5.0'.freeze
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: amplitude-experiment
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.0
4
+ version: 1.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Amplitude
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-07-29 00:00:00.000000000 Z
11
+ date: 2024-08-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: concurrent-ruby
@@ -86,14 +86,14 @@ dependencies:
86
86
  requirements:
87
87
  - - '='
88
88
  - !ruby/object:Gem::Version
89
- version: '1.21'
89
+ version: 1.22.3
90
90
  type: :development
91
91
  prerelease: false
92
92
  version_requirements: !ruby/object:Gem::Requirement
93
93
  requirements:
94
94
  - - '='
95
95
  - !ruby/object:Gem::Version
96
- version: '1.21'
96
+ version: 1.22.3
97
97
  - !ruby/object:Gem::Dependency
98
98
  name: simplecov
99
99
  requirement: !ruby/object:Gem::Requirement
@@ -136,6 +136,20 @@ dependencies:
136
136
  - - "~>"
137
137
  - !ruby/object:Gem::Version
138
138
  version: '0.9'
139
+ - !ruby/object:Gem::Dependency
140
+ name: dotenv
141
+ requirement: !ruby/object:Gem::Requirement
142
+ requirements:
143
+ - - "~>"
144
+ - !ruby/object:Gem::Version
145
+ version: 2.8.1
146
+ type: :development
147
+ prerelease: false
148
+ version_requirements: !ruby/object:Gem::Requirement
149
+ requirements:
150
+ - - "~>"
151
+ - !ruby/object:Gem::Version
152
+ version: 2.8.1
139
153
  - !ruby/object:Gem::Dependency
140
154
  name: ffi
141
155
  requirement: !ruby/object:Gem::Requirement
@@ -175,9 +189,17 @@ files:
175
189
  - lib/amplitude/timeline.rb
176
190
  - lib/amplitude/utils.rb
177
191
  - lib/amplitude/workers.rb
192
+ - lib/experiment/cohort/cohort.rb
193
+ - lib/experiment/cohort/cohort_download_api.rb
194
+ - lib/experiment/cohort/cohort_loader.rb
195
+ - lib/experiment/cohort/cohort_storage.rb
196
+ - lib/experiment/cohort/cohort_sync_config.rb
178
197
  - lib/experiment/cookie.rb
198
+ - lib/experiment/deployment/deployment_runner.rb
179
199
  - lib/experiment/error.rb
180
200
  - lib/experiment/factory.rb
201
+ - lib/experiment/flag/flag_config_fetcher.rb
202
+ - lib/experiment/flag/flag_config_storage.rb
181
203
  - lib/experiment/local/assignment/assignment.rb
182
204
  - lib/experiment/local/assignment/assignment_config.rb
183
205
  - lib/experiment/local/assignment/assignment_filter.rb
@@ -193,13 +215,14 @@ files:
193
215
  - lib/experiment/local/evaluation/lib/macosArm64/libevaluation_interop_api.h
194
216
  - lib/experiment/local/evaluation/lib/macosX64/libevaluation_interop.dylib
195
217
  - lib/experiment/local/evaluation/lib/macosX64/libevaluation_interop_api.h
196
- - lib/experiment/local/fetcher.rb
197
218
  - lib/experiment/persistent_http_client.rb
198
219
  - lib/experiment/remote/client.rb
199
220
  - lib/experiment/remote/config.rb
200
221
  - lib/experiment/user.rb
222
+ - lib/experiment/util/flag_config.rb
201
223
  - lib/experiment/util/hash.rb
202
224
  - lib/experiment/util/lru_cache.rb
225
+ - lib/experiment/util/poller.rb
203
226
  - lib/experiment/util/topological_sort.rb
204
227
  - lib/experiment/util/user.rb
205
228
  - lib/experiment/util/variant.rb