amplitude-experiment 1.4.0 → 1.5.0

Sign up to get free protection for your applications and to get access to all the features.
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