amplitude-experiment 1.1.4 → 1.2.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 +1 -0
- data/lib/amplitude/client.rb +54 -0
- data/lib/amplitude/config.rb +78 -0
- data/lib/amplitude/constants.rb +45 -0
- data/lib/amplitude/event.rb +244 -0
- data/lib/amplitude/exception.rb +15 -0
- data/lib/amplitude/http_client.rb +161 -0
- data/lib/amplitude/plugin.rb +131 -0
- data/lib/amplitude/processor.rb +100 -0
- data/lib/amplitude/storage.rb +146 -0
- data/lib/amplitude/timeline.rb +98 -0
- data/lib/amplitude/utils.rb +29 -0
- data/lib/amplitude/workers.rb +101 -0
- data/lib/amplitude-experiment.rb +6 -0
- data/lib/amplitude.rb +12 -0
- data/lib/experiment/local/assignment/assignment.rb +21 -0
- data/lib/experiment/local/assignment/assignment_config.rb +12 -0
- data/lib/experiment/local/assignment/assignment_filter.rb +15 -0
- data/lib/experiment/local/assignment/assignment_service.rb +48 -0
- data/lib/experiment/local/client.rb +19 -6
- data/lib/experiment/local/config.rb +6 -1
- data/lib/experiment/remote/client.rb +1 -3
- data/lib/experiment/util/hash.rb +15 -0
- data/lib/experiment/util/lru_cache.rb +107 -0
- data/lib/experiment/version.rb +1 -1
- metadata +35 -2
@@ -0,0 +1,48 @@
|
|
1
|
+
require_relative '../../../amplitude'
|
2
|
+
module AmplitudeExperiment
|
3
|
+
# AssignmentService
|
4
|
+
class AssignmentService
|
5
|
+
def initialize(amplitude, assignment_filter)
|
6
|
+
@amplitude = amplitude
|
7
|
+
@assignment_filter = assignment_filter
|
8
|
+
end
|
9
|
+
|
10
|
+
def track(assignment)
|
11
|
+
@amplitude.track(to_event(assignment)) if @assignment_filter.should_track(assignment)
|
12
|
+
end
|
13
|
+
|
14
|
+
def to_event(assignment)
|
15
|
+
event = AmplitudeAnalytics::BaseEvent.new(
|
16
|
+
'[Experiment] Assignment',
|
17
|
+
user_id: assignment.user.user_id,
|
18
|
+
device_id: assignment.user.device_id,
|
19
|
+
event_properties: {},
|
20
|
+
user_properties: {}
|
21
|
+
)
|
22
|
+
|
23
|
+
assignment.results.each do |results_key, result|
|
24
|
+
event.event_properties["#{results_key}.variant"] = result['variant']['key']
|
25
|
+
end
|
26
|
+
|
27
|
+
set = {}
|
28
|
+
unset = {}
|
29
|
+
|
30
|
+
assignment.results.each do |results_key, result|
|
31
|
+
next if result['type'] == FLAG_TYPE_MUTUAL_EXCLUSION_GROUP
|
32
|
+
|
33
|
+
if result['isDefaultVariant']
|
34
|
+
unset["[Experiment] #{results_key}"] = '-'
|
35
|
+
else
|
36
|
+
set["[Experiment] #{results_key}"] = result['variant']['key']
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
event.user_properties['$set'] = set
|
41
|
+
event.user_properties['$unset'] = unset
|
42
|
+
|
43
|
+
event.insert_id = "#{event.user_id} #{event.device_id} #{AmplitudeExperiment.hash_code(assignment.canonicalize)} #{assignment.timestamp / DAY_MILLIS}"
|
44
|
+
|
45
|
+
event
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -1,13 +1,17 @@
|
|
1
1
|
require 'uri'
|
2
2
|
require 'logger'
|
3
|
+
require_relative '../../amplitude'
|
3
4
|
|
4
5
|
module AmplitudeExperiment
|
6
|
+
FLAG_TYPE_MUTUAL_EXCLUSION_GROUP = 'mutual_exclusion_group'.freeze
|
7
|
+
FLAG_TYPE_HOLDOUT_GROUP = 'holdout-group'.freeze
|
5
8
|
# Main client for fetching variant data.
|
6
9
|
class LocalEvaluationClient
|
7
10
|
# Creates a new Experiment Client instance.
|
8
11
|
#
|
9
12
|
# @param [String] api_key The environment API Key
|
10
13
|
# @param [LocalEvaluationConfig] config The config object
|
14
|
+
|
11
15
|
def initialize(api_key, config = nil)
|
12
16
|
require 'experiment/local/evaluation/evaluation'
|
13
17
|
@api_key = api_key
|
@@ -22,6 +26,9 @@ module AmplitudeExperiment
|
|
22
26
|
end
|
23
27
|
@fetcher = LocalEvaluationFetcher.new(api_key, @logger, @config.server_url)
|
24
28
|
raise ArgumentError, 'Experiment API key is empty' if @api_key.nil? || @api_key.empty?
|
29
|
+
|
30
|
+
@assignment_service = nil
|
31
|
+
@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
|
25
32
|
end
|
26
33
|
|
27
34
|
# Locally evaluates flag variants for a user.
|
@@ -37,10 +44,11 @@ module AmplitudeExperiment
|
|
37
44
|
return {} if flags.nil?
|
38
45
|
|
39
46
|
user_str = user.to_json
|
47
|
+
|
40
48
|
@logger.debug("[Experiment] Evaluate: User: #{user_str} - Rules: #{flags}") if @config.debug
|
41
49
|
result = evaluation(flags, user_str)
|
42
50
|
@logger.debug("[Experiment] evaluate - result: #{result}") if @config.debug
|
43
|
-
parse_results(result, flag_keys)
|
51
|
+
parse_results(result, flag_keys, user)
|
44
52
|
end
|
45
53
|
|
46
54
|
# Fetch initial flag configurations and start polling for updates.
|
@@ -61,15 +69,20 @@ module AmplitudeExperiment
|
|
61
69
|
|
62
70
|
private
|
63
71
|
|
64
|
-
def parse_results(result, flag_keys)
|
72
|
+
def parse_results(result, flag_keys, user)
|
65
73
|
variants = {}
|
74
|
+
assignments = {}
|
66
75
|
result.each do |key, value|
|
67
|
-
|
76
|
+
included = flag_keys.empty? || flag_keys.include?(key)
|
77
|
+
if !value['isDefaultVariant'] && included
|
78
|
+
variant_key = value['variant']['key']
|
79
|
+
variant_payload = value['variant']['payload']
|
80
|
+
variants.store(key, Variant.new(variant_key, variant_payload))
|
81
|
+
end
|
68
82
|
|
69
|
-
|
70
|
-
variant_payload = value['variant']['payload']
|
71
|
-
variants.store(key, Variant.new(variant_key, variant_payload))
|
83
|
+
assignments[key] = value if included || value['type'] == FLAG_TYPE_MUTUAL_EXCLUSION_GROUP || value['type'] == FLAG_TYPE_HOLDOUT_GROUP
|
72
84
|
end
|
85
|
+
@assignment_service&.track(Assignment.new(user, assignments))
|
73
86
|
variants
|
74
87
|
end
|
75
88
|
|
@@ -16,16 +16,21 @@ module AmplitudeExperiment
|
|
16
16
|
# @return [long] the value of flag config polling interval in million seconds
|
17
17
|
attr_accessor :flag_config_polling_interval_millis
|
18
18
|
|
19
|
+
# Configuration for automatically tracking assignment events after an evaluation.
|
20
|
+
# @return [AssignmentConfig] the config instance
|
21
|
+
attr_accessor :assignment_config
|
22
|
+
|
19
23
|
# @param [Boolean] debug Set to true to log some extra information to the console.
|
20
24
|
# @param [String] server_url The server endpoint from which to request variants.
|
21
25
|
# @param [Hash] bootstrap The value of bootstrap.
|
22
26
|
# @param [long] flag_config_polling_interval_millis The value of flag config polling interval in million seconds.
|
23
27
|
def initialize(server_url: DEFAULT_SERVER_URL, bootstrap: {},
|
24
|
-
flag_config_polling_interval_millis: 30_000, debug: false)
|
28
|
+
flag_config_polling_interval_millis: 30_000, debug: false, assignment_config: nil)
|
25
29
|
@debug = debug || false
|
26
30
|
@server_url = server_url
|
27
31
|
@bootstrap = bootstrap
|
28
32
|
@flag_config_polling_interval_millis = flag_config_polling_interval_millis
|
33
|
+
@assignment_config = assignment_config
|
29
34
|
end
|
30
35
|
end
|
31
36
|
end
|
@@ -102,9 +102,7 @@ module AmplitudeExperiment
|
|
102
102
|
http = PersistentHttpClient.get(@uri, { read_timeout: read_timeout }, @api_key)
|
103
103
|
request = Net::HTTP::Post.new(@uri, headers)
|
104
104
|
request.body = user_context.to_json
|
105
|
-
if request.body.length > 8000
|
106
|
-
@logger.warn("[Experiment] encoded user object length #{request.body.length} cannot be cached by CDN; must be < 8KB")
|
107
|
-
end
|
105
|
+
@logger.warn("[Experiment] encoded user object length #{request.body.length} cannot be cached by CDN; must be < 8KB") if request.body.length > 8000
|
108
106
|
@logger.debug("[Experiment] Fetch variants for user: #{request.body}")
|
109
107
|
response = http.request(request)
|
110
108
|
end_time = Time.now
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# AmplitudeExperiment
|
2
|
+
module AmplitudeExperiment
|
3
|
+
def self.hash_code(string)
|
4
|
+
hash = 0
|
5
|
+
return hash if string.empty?
|
6
|
+
|
7
|
+
string.each_char do |char|
|
8
|
+
chr_code = char.ord
|
9
|
+
hash = ((hash << 5) - hash) + chr_code
|
10
|
+
hash &= 0xFFFFFFFF
|
11
|
+
end
|
12
|
+
|
13
|
+
hash
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,107 @@
|
|
1
|
+
module AmplitudeExperiment
|
2
|
+
# ListNode
|
3
|
+
class ListNode
|
4
|
+
attr_accessor :prev, :next, :data
|
5
|
+
|
6
|
+
def initialize(data)
|
7
|
+
@prev = nil
|
8
|
+
@next = nil
|
9
|
+
@data = data
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
# CacheItem
|
14
|
+
class CacheItem
|
15
|
+
attr_accessor :key, :value, :created_at
|
16
|
+
|
17
|
+
def initialize(key, value)
|
18
|
+
@key = key
|
19
|
+
@value = value
|
20
|
+
@created_at = (Time.now.to_f * 1000).to_i
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
# Cache
|
25
|
+
class LRUCache
|
26
|
+
def initialize(capacity, ttl_millis)
|
27
|
+
@capacity = capacity
|
28
|
+
@ttl_millis = ttl_millis
|
29
|
+
@cache = {}
|
30
|
+
@head = nil
|
31
|
+
@tail = nil
|
32
|
+
end
|
33
|
+
|
34
|
+
def put(key, value)
|
35
|
+
if @cache.key?(key)
|
36
|
+
remove_from_list(key)
|
37
|
+
elsif @cache.size >= @capacity
|
38
|
+
evict_lru
|
39
|
+
end
|
40
|
+
|
41
|
+
cache_item = CacheItem.new(key, value)
|
42
|
+
node = ListNode.new(cache_item)
|
43
|
+
@cache[key] = node
|
44
|
+
insert_to_list(node)
|
45
|
+
end
|
46
|
+
|
47
|
+
def get(key)
|
48
|
+
node = @cache[key]
|
49
|
+
return nil unless node
|
50
|
+
|
51
|
+
time_elapsed = (Time.now.to_f * 1000).to_i - node.data.created_at
|
52
|
+
if time_elapsed > @ttl_millis
|
53
|
+
remove(key)
|
54
|
+
return nil
|
55
|
+
end
|
56
|
+
|
57
|
+
remove_from_list(key)
|
58
|
+
insert_to_list(node)
|
59
|
+
node.data.value
|
60
|
+
end
|
61
|
+
|
62
|
+
def remove(key)
|
63
|
+
remove_from_list(key)
|
64
|
+
@cache.delete(key)
|
65
|
+
end
|
66
|
+
|
67
|
+
def clear
|
68
|
+
@cache.clear
|
69
|
+
@head = nil
|
70
|
+
@tail = nil
|
71
|
+
end
|
72
|
+
|
73
|
+
private
|
74
|
+
|
75
|
+
def evict_lru
|
76
|
+
remove(@head.data.key) if @head
|
77
|
+
end
|
78
|
+
|
79
|
+
def remove_from_list(key)
|
80
|
+
node = @cache[key]
|
81
|
+
return unless node
|
82
|
+
|
83
|
+
if node.prev
|
84
|
+
node.prev.next = node.next
|
85
|
+
else
|
86
|
+
@head = node.next
|
87
|
+
end
|
88
|
+
|
89
|
+
if node.next
|
90
|
+
node.next.prev = node.prev
|
91
|
+
else
|
92
|
+
@tail = node.prev
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
def insert_to_list(node)
|
97
|
+
if @tail
|
98
|
+
@tail.next = node
|
99
|
+
node.prev = @tail
|
100
|
+
node.next = nil
|
101
|
+
else
|
102
|
+
@head = node
|
103
|
+
end
|
104
|
+
@tail = node
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
data/lib/experiment/version.rb
CHANGED
metadata
CHANGED
@@ -1,15 +1,29 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: amplitude-experiment
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.
|
4
|
+
version: 1.2.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Amplitude
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2023-
|
11
|
+
date: 2023-09-12 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: concurrent-ruby
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 1.2.2
|
20
|
+
type: :development
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: 1.2.2
|
13
27
|
- !ruby/object:Gem::Dependency
|
14
28
|
name: psych
|
15
29
|
requirement: !ruby/object:Gem::Requirement
|
@@ -148,8 +162,25 @@ files:
|
|
148
162
|
- README.md
|
149
163
|
- amplitude-experiment.gemspec
|
150
164
|
- lib/amplitude-experiment.rb
|
165
|
+
- lib/amplitude.rb
|
166
|
+
- lib/amplitude/client.rb
|
167
|
+
- lib/amplitude/config.rb
|
168
|
+
- lib/amplitude/constants.rb
|
169
|
+
- lib/amplitude/event.rb
|
170
|
+
- lib/amplitude/exception.rb
|
171
|
+
- lib/amplitude/http_client.rb
|
172
|
+
- lib/amplitude/plugin.rb
|
173
|
+
- lib/amplitude/processor.rb
|
174
|
+
- lib/amplitude/storage.rb
|
175
|
+
- lib/amplitude/timeline.rb
|
176
|
+
- lib/amplitude/utils.rb
|
177
|
+
- lib/amplitude/workers.rb
|
151
178
|
- lib/experiment/cookie.rb
|
152
179
|
- lib/experiment/factory.rb
|
180
|
+
- lib/experiment/local/assignment/assignment.rb
|
181
|
+
- lib/experiment/local/assignment/assignment_config.rb
|
182
|
+
- lib/experiment/local/assignment/assignment_filter.rb
|
183
|
+
- lib/experiment/local/assignment/assignment_service.rb
|
153
184
|
- lib/experiment/local/client.rb
|
154
185
|
- lib/experiment/local/config.rb
|
155
186
|
- lib/experiment/local/evaluation/evaluation.rb
|
@@ -166,6 +197,8 @@ files:
|
|
166
197
|
- lib/experiment/remote/client.rb
|
167
198
|
- lib/experiment/remote/config.rb
|
168
199
|
- lib/experiment/user.rb
|
200
|
+
- lib/experiment/util/hash.rb
|
201
|
+
- lib/experiment/util/lru_cache.rb
|
169
202
|
- lib/experiment/variant.rb
|
170
203
|
- lib/experiment/version.rb
|
171
204
|
homepage: https://github.com/amplitude/experiment-ruby-server
|