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 +4 -4
- data/amplitude-experiment.gemspec +2 -1
- data/lib/amplitude-experiment.rb +10 -1
- data/lib/experiment/cohort/cohort.rb +25 -0
- data/lib/experiment/cohort/cohort_download_api.rb +90 -0
- data/lib/experiment/cohort/cohort_loader.rb +39 -0
- data/lib/experiment/cohort/cohort_storage.rb +91 -0
- data/lib/experiment/cohort/cohort_sync_config.rb +27 -0
- data/lib/experiment/deployment/deployment_runner.rb +135 -0
- data/lib/experiment/error.rb +23 -0
- data/lib/experiment/{local/fetcher.rb → flag/flag_config_fetcher.rb} +7 -5
- data/lib/experiment/flag/flag_config_storage.rb +53 -0
- data/lib/experiment/local/client.rb +66 -26
- data/lib/experiment/local/config.rb +26 -2
- data/lib/experiment/user.rb +40 -20
- data/lib/experiment/util/flag_config.rb +60 -0
- data/lib/experiment/util/poller.rb +24 -0
- data/lib/experiment/util/user.rb +20 -12
- data/lib/experiment/version.rb +1 -1
- metadata +28 -5
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: c79062c9e6a6f0877449f49dab03aa2445cf31700ecb92010f88d5779759d73a
|
4
|
+
data.tar.gz: 1cf7d73b53cb125ecfaa6987bb4569d7416c5b02e194ef9f90be65c8da0b2fe6
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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.
|
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
|
data/lib/amplitude-experiment.rb
CHANGED
@@ -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
|
data/lib/experiment/error.rb
CHANGED
@@ -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
|
-
|
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
|
-
|
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
|
-
|
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 = @
|
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
|
-
|
63
|
-
|
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
|
-
|
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
|
-
@
|
97
|
+
@deployment_runner.stop
|
87
98
|
end
|
88
99
|
|
89
100
|
private
|
90
101
|
|
91
|
-
def
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
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
|
-
|
104
|
-
|
105
|
-
|
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
|
-
|
28
|
-
|
42
|
+
# @param [AssignmentConfig] assignment_config Configuration for automatically tracking assignment events after an evaluation.
|
43
|
+
# @param [CohortSyncConfig] cohort_sync_config Configuration for downloading cohorts required for flag evaluation
|
44
|
+
def initialize(server_url: DEFAULT_SERVER_URL, server_zone: ServerZone::US, bootstrap: {},
|
45
|
+
flag_config_polling_interval_millis: 30_000, debug: false, assignment_config: nil,
|
46
|
+
cohort_sync_config: nil)
|
29
47
|
@debug = debug || false
|
30
48
|
@server_url = server_url
|
49
|
+
@server_zone = server_zone
|
50
|
+
@cohort_sync_config = cohort_sync_config
|
51
|
+
if server_url == DEFAULT_SERVER_URL && @server_zone == ServerZone::EU
|
52
|
+
@server_url = EU_SERVER_URL
|
53
|
+
@cohort_sync_config.cohort_server_url = EU_COHORT_SYNC_URL if @cohort_sync_config && @cohort_sync_config.cohort_server_url == DEFAULT_COHORT_SYNC_URL
|
54
|
+
end
|
31
55
|
@bootstrap = bootstrap
|
32
56
|
@flag_config_polling_interval_millis = flag_config_polling_interval_millis
|
33
57
|
@assignment_config = assignment_config
|
data/lib/experiment/user.rb
CHANGED
@@ -80,6 +80,14 @@ module AmplitudeExperiment
|
|
80
80
|
# @return [Hash, nil] the value of group properties
|
81
81
|
attr_accessor :group_properties
|
82
82
|
|
83
|
+
# Cohort IDs for the user
|
84
|
+
# @return [Hash, nil] the value of cohort_ids
|
85
|
+
attr_accessor :cohort_ids
|
86
|
+
|
87
|
+
# Cohort IDs for the user's groups
|
88
|
+
# @return [Hash, nil] the value of group_cohort_ids
|
89
|
+
attr_accessor :group_cohort_ids
|
90
|
+
|
83
91
|
# @param [String, nil] device_id Device ID for associating with an identity in Amplitude
|
84
92
|
# @param [String, nil] user_id User ID for associating with an identity in Amplitude
|
85
93
|
# @param [String, nil] country Predefined field, must be manually provided
|
@@ -101,7 +109,8 @@ module AmplitudeExperiment
|
|
101
109
|
# @param [Hash, nil] group_properties Custom properties for groups
|
102
110
|
def initialize(device_id: nil, user_id: nil, country: nil, city: nil, region: nil, dma: nil, ip_address: nil, language: nil,
|
103
111
|
platform: nil, version: nil, os: nil, device_manufacturer: nil, device_brand: nil,
|
104
|
-
device_model: nil, carrier: nil, library: nil, user_properties: nil, groups: nil, group_properties: nil
|
112
|
+
device_model: nil, carrier: nil, library: nil, user_properties: nil, groups: nil, group_properties: nil,
|
113
|
+
cohort_ids: nil, group_cohort_ids: nil)
|
105
114
|
@device_id = device_id
|
106
115
|
@user_id = user_id
|
107
116
|
@country = country
|
@@ -121,31 +130,35 @@ module AmplitudeExperiment
|
|
121
130
|
@user_properties = user_properties
|
122
131
|
@groups = groups
|
123
132
|
@group_properties = group_properties
|
133
|
+
@cohort_ids = cohort_ids
|
134
|
+
@group_cohort_ids = group_cohort_ids
|
124
135
|
end
|
125
136
|
|
126
137
|
# Return User as Hash.
|
127
138
|
# @return [Hash] Hash object with user values
|
128
139
|
def as_json(_options = {})
|
129
140
|
{
|
130
|
-
device_id
|
131
|
-
user_id
|
132
|
-
country
|
133
|
-
city
|
134
|
-
region
|
135
|
-
dma
|
136
|
-
ip_address
|
137
|
-
language
|
138
|
-
platform
|
139
|
-
version
|
140
|
-
os
|
141
|
-
device_manufacturer
|
142
|
-
device_brand
|
143
|
-
device_model
|
144
|
-
carrier
|
145
|
-
library
|
146
|
-
user_properties
|
147
|
-
groups
|
148
|
-
group_properties
|
141
|
+
'device_id' => @device_id,
|
142
|
+
'user_id' => @user_id,
|
143
|
+
'country' => @country,
|
144
|
+
'city' => @city,
|
145
|
+
'region' => @region,
|
146
|
+
'dma' => @dma,
|
147
|
+
'ip_address' => @ip_address,
|
148
|
+
'language' => @language,
|
149
|
+
'platform' => @platform,
|
150
|
+
'version' => @version,
|
151
|
+
'os' => @os,
|
152
|
+
'device_manufacturer' => @device_manufacturer,
|
153
|
+
'device_brand' => @device_brand,
|
154
|
+
'device_model' => @device_model,
|
155
|
+
'carrier' => @carrier,
|
156
|
+
'library' => @library,
|
157
|
+
'user_properties' => @user_properties,
|
158
|
+
'groups' => @groups,
|
159
|
+
'group_properties' => @group_properties,
|
160
|
+
'cohort_ids' => @cohort_ids,
|
161
|
+
'group_cohort_ids' => @group_cohort_ids
|
149
162
|
}.compact
|
150
163
|
end
|
151
164
|
|
@@ -154,5 +167,12 @@ module AmplitudeExperiment
|
|
154
167
|
def to_json(*options)
|
155
168
|
as_json(*options).to_json(*options)
|
156
169
|
end
|
170
|
+
|
171
|
+
def add_group_cohort_ids(group_type, group_name, cohort_ids)
|
172
|
+
@group_cohort_ids ||= {}
|
173
|
+
|
174
|
+
group_names = @group_cohort_ids[group_type] ||= {}
|
175
|
+
group_names[group_name] = cohort_ids
|
176
|
+
end
|
157
177
|
end
|
158
178
|
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
module AmplitudeExperiment
|
2
|
+
def self.cohort_filter?(condition)
|
3
|
+
['set contains any', 'set does not contain any'].include?(condition['op']) &&
|
4
|
+
condition['selector'] &&
|
5
|
+
condition['selector'][-1] == 'cohort_ids'
|
6
|
+
end
|
7
|
+
|
8
|
+
def self.get_grouped_cohort_condition_ids(segment)
|
9
|
+
cohort_ids = {}
|
10
|
+
conditions = segment['conditions'] || []
|
11
|
+
conditions.each do |condition|
|
12
|
+
condition = condition[0]
|
13
|
+
next unless cohort_filter?(condition) && (condition['selector'][1].length > 2)
|
14
|
+
|
15
|
+
context_subtype = condition['selector'][1]
|
16
|
+
group_type =
|
17
|
+
if context_subtype == 'user'
|
18
|
+
USER_GROUP_TYPE
|
19
|
+
elsif condition['selector'].include?('groups')
|
20
|
+
condition['selector'][2]
|
21
|
+
else
|
22
|
+
next
|
23
|
+
end
|
24
|
+
cohort_ids[group_type] ||= Set.new
|
25
|
+
cohort_ids[group_type].merge(condition['values'])
|
26
|
+
end
|
27
|
+
cohort_ids
|
28
|
+
end
|
29
|
+
|
30
|
+
def self.get_grouped_cohort_ids_from_flag(flag)
|
31
|
+
cohort_ids = {}
|
32
|
+
segments = flag['segments'] || []
|
33
|
+
segments.each do |segment|
|
34
|
+
get_grouped_cohort_condition_ids(segment).each do |key, values|
|
35
|
+
cohort_ids[key] ||= Set.new
|
36
|
+
cohort_ids[key].merge(values)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
cohort_ids
|
40
|
+
end
|
41
|
+
|
42
|
+
def self.get_all_cohort_ids_from_flag(flag)
|
43
|
+
get_grouped_cohort_ids_from_flag(flag).values.reduce(Set.new) { |acc, set| acc.merge(set) }
|
44
|
+
end
|
45
|
+
|
46
|
+
def self.get_grouped_cohort_ids_from_flags(flags)
|
47
|
+
cohort_ids = {}
|
48
|
+
flags.each do |_, flag|
|
49
|
+
get_grouped_cohort_ids_from_flag(flag).each do |key, values|
|
50
|
+
cohort_ids[key] ||= Set.new
|
51
|
+
cohort_ids[key].merge(values)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
cohort_ids
|
55
|
+
end
|
56
|
+
|
57
|
+
def self.get_all_cohort_ids_from_flags(flags)
|
58
|
+
get_grouped_cohort_ids_from_flags(flags).values.reduce(Set.new) { |acc, set| acc.merge(set) }
|
59
|
+
end
|
60
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
module AmplitudeExperiment
|
2
|
+
# Poller
|
3
|
+
class Poller
|
4
|
+
def initialize(interval_seconds, callback)
|
5
|
+
@interval_seconds = interval_seconds
|
6
|
+
@callback = callback
|
7
|
+
end
|
8
|
+
|
9
|
+
def start
|
10
|
+
@running = true
|
11
|
+
@thread = Thread.new do
|
12
|
+
while @running
|
13
|
+
@callback.call
|
14
|
+
sleep(@interval_seconds)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def stop
|
20
|
+
@running = false
|
21
|
+
@thread&.join
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
data/lib/experiment/util/user.rb
CHANGED
@@ -2,11 +2,13 @@ module AmplitudeExperiment
|
|
2
2
|
def self.user_to_evaluation_context(user)
|
3
3
|
user_groups = user.groups
|
4
4
|
user_group_properties = user.group_properties
|
5
|
+
user_group_cohort_ids = user.group_cohort_ids
|
5
6
|
user_hash = user.as_json.compact
|
6
|
-
user_hash.delete(
|
7
|
-
user_hash.delete(
|
7
|
+
user_hash.delete('groups')
|
8
|
+
user_hash.delete('group_properties')
|
9
|
+
user_hash.delete('group_cohort_ids')
|
8
10
|
|
9
|
-
context = user_hash.empty? ? {} : { user
|
11
|
+
context = user_hash.empty? ? {} : { 'user' => user_hash }
|
10
12
|
|
11
13
|
return context if user_groups.nil?
|
12
14
|
|
@@ -14,20 +16,26 @@ module AmplitudeExperiment
|
|
14
16
|
user_groups.each do |group_type, group_name|
|
15
17
|
group_name = group_name[0] if group_name.is_a?(Array) && !group_name.empty?
|
16
18
|
|
17
|
-
groups[group_type
|
19
|
+
groups[group_type] = { 'group_name' => group_name }
|
18
20
|
|
19
|
-
|
21
|
+
if user_group_properties
|
22
|
+
group_properties_type = user_group_properties[group_type]
|
23
|
+
if group_properties_type.is_a?(Hash)
|
24
|
+
group_properties_name = group_properties_type[group_name]
|
25
|
+
groups[group_type]['group_properties'] = group_properties_name if group_properties_name.is_a?(Hash)
|
26
|
+
end
|
27
|
+
end
|
20
28
|
|
21
|
-
|
22
|
-
next if group_properties_type.nil? || !group_properties_type.is_a?(Hash)
|
29
|
+
next unless user_group_cohort_ids
|
23
30
|
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
31
|
+
group_cohort_ids_type = user_group_cohort_ids[group_type]
|
32
|
+
if group_cohort_ids_type.is_a?(Hash)
|
33
|
+
group_cohort_ids_name = group_cohort_ids_type[group_name]
|
34
|
+
groups[group_type]['cohort_ids'] = group_cohort_ids_name if group_cohort_ids_name.is_a?(Array)
|
35
|
+
end
|
28
36
|
end
|
29
37
|
|
30
|
-
context[
|
38
|
+
context['groups'] = groups unless groups.empty?
|
31
39
|
context
|
32
40
|
end
|
33
41
|
end
|
data/lib/experiment/version.rb
CHANGED
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
|
+
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-
|
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:
|
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:
|
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
|