amplitude-experiment 1.1.4 → 1.2.0

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