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.
@@ -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
- next if value['isDefaultVariant'] || (flag_keys.empty? && flag_keys.include?(key))
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
- variant_key = value['variant']['key']
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
@@ -1,3 +1,3 @@
1
1
  module AmplitudeExperiment
2
- VERSION = '1.1.4'.freeze
2
+ VERSION = '1.2.0'.freeze
3
3
  end
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.1.4
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-07-12 00:00:00.000000000 Z
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