amplitude-experiment 1.3.1 → 1.4.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: 021a7dbe3ce531b888b2df293019ad43c0f492e4662e6fc211694e06a321253b
4
- data.tar.gz: 0c59c6325418d0ef315688a692014caf952d3dcb5dbd04e794c0920c06c4e574
3
+ metadata.gz: 6df1a01ac720c72256be526321f9b30f93fcdde050ff6a0743c24bd53a64dd5b
4
+ data.tar.gz: d10ebba30ae8a59b7964536909315059c8d096dc1b3a9d0e735c7242db400153
5
5
  SHA512:
6
- metadata.gz: 990ae6f7e507ec78e12bdbf0e67d7ee3e03ae196651add45af3e331fdcc19caa24cbbdc39fe9745dedd08a3463842ce5f23a31c39aa35945696bb62ef4a5d1c0
7
- data.tar.gz: 87b946e0d25f380731b9d8db86f32565934793f175ccfbade88c2c3a8dc2fbc96e686a12db4fbd77b2941125ed48cc9da472ebeba1fa1dede7b5149a1938f1dd
6
+ metadata.gz: d623457be0fb4cc632f708365d53abbab2b7b647baf7ab37d18d6a05f21e321960e0632152ff2e60686d590e598cfa4333313cb6e31ad29868d2e4798be3e200
7
+ data.tar.gz: e9800dd0da6e0edf6ee8c01bf289700e7f61963fc2e379ea55ddd54dda7c52c8b85f0185c07fcd2a3d2aa8b92391a0bda9828c794f1960d16e39f1e10ed15210
@@ -15,6 +15,9 @@ require 'experiment/local/assignment/assignment_service'
15
15
  require 'experiment/local/assignment/assignment_config'
16
16
  require 'experiment/util/lru_cache'
17
17
  require 'experiment/util/hash'
18
+ require 'experiment/util/topological_sort'
19
+ require 'experiment/util/user'
20
+ require 'experiment/util/variant'
18
21
  require 'experiment/error'
19
22
 
20
23
  # Amplitude Experiment Module
@@ -8,4 +8,18 @@ module AmplitudeExperiment
8
8
  @status_code = status_code
9
9
  end
10
10
  end
11
+
12
+ class CycleError < StandardError
13
+ # Raised when topological sorting encounters a cycle between flag dependencies.
14
+ attr_reader :path
15
+
16
+ def initialize(path)
17
+ super
18
+ @path = path
19
+ end
20
+
21
+ def to_s
22
+ "Detected a cycle between flags #{@path}"
23
+ end
24
+ end
11
25
  end
@@ -13,7 +13,9 @@ module AmplitudeExperiment
13
13
  def canonicalize
14
14
  sb = "#{@user&.user_id&.strip} #{@user&.device_id&.strip} "
15
15
  results.sort.to_h.each do |key, value|
16
- sb += "#{key.strip} #{value['variant']&.fetch('key', '')&.strip} "
16
+ next unless value.key
17
+
18
+ sb += "#{key.strip} #{value.key&.strip} "
17
19
  end
18
20
  sb
19
21
  end
@@ -8,10 +8,10 @@ module AmplitudeExperiment
8
8
  end
9
9
 
10
10
  def track(assignment)
11
- @amplitude.track(to_event(assignment)) if @assignment_filter.should_track(assignment)
11
+ @amplitude.track(AssignmentService.to_event(assignment)) if @assignment_filter.should_track(assignment)
12
12
  end
13
13
 
14
- def to_event(assignment)
14
+ def self.to_event(assignment)
15
15
  event = AmplitudeAnalytics::BaseEvent.new(
16
16
  '[Experiment] Assignment',
17
17
  user_id: assignment.user.user_id,
@@ -20,28 +20,29 @@ module AmplitudeExperiment
20
20
  user_properties: {}
21
21
  )
22
22
 
23
- assignment.results.each do |results_key, result|
24
- event.event_properties["#{results_key}.variant"] = result['variant']['key']
25
- end
26
-
27
23
  set = {}
28
24
  unset = {}
29
25
 
30
- assignment.results.each do |results_key, result|
31
- next if result['type'] == FLAG_TYPE_MUTUAL_EXCLUSION_GROUP
26
+ assignment.results.sort.to_h.each do |flag_key, variant|
27
+ next unless variant.key
28
+
29
+ version = variant.metadata['flagVersion'] if variant.metadata
30
+ segment_name = variant.metadata['segmentName'] if variant.metadata
31
+ flag_type = variant.metadata['flagType'] if variant.metadata
32
+ default = variant.metadata ? variant.metadata.fetch('default', false) : false
33
+ event.event_properties["#{flag_key}.variant"] = variant.key
34
+ event.event_properties["#{flag_key}.details"] = "v#{version} rule:#{segment_name}" if version && segment_name
35
+ next if flag_type == FLAG_TYPE_MUTUAL_EXCLUSION_GROUP
32
36
 
33
- if result['isDefaultVariant']
34
- unset["[Experiment] #{results_key}"] = '-'
37
+ if default
38
+ unset["[Experiment] #{flag_key}"] = '-'
35
39
  else
36
- set["[Experiment] #{results_key}"] = result['variant']['key']
40
+ set["[Experiment] #{flag_key}"] = variant.key
37
41
  end
38
42
  end
39
-
40
43
  event.user_properties['$set'] = set
41
44
  event.user_properties['$unset'] = unset
42
-
43
45
  event.insert_id = "#{event.user_id} #{event.device_id} #{AmplitudeExperiment.hash_code(assignment.canonicalize)} #{assignment.timestamp / DAY_MILLIS}"
44
-
45
46
  event
46
47
  end
47
48
  end
@@ -3,8 +3,7 @@ require 'logger'
3
3
  require_relative '../../amplitude'
4
4
 
5
5
  module AmplitudeExperiment
6
- FLAG_TYPE_MUTUAL_EXCLUSION_GROUP = 'mutual_exclusion_group'.freeze
7
- FLAG_TYPE_HOLDOUT_GROUP = 'holdout-group'.freeze
6
+ FLAG_TYPE_MUTUAL_EXCLUSION_GROUP = 'mutual-exclusion-group'.freeze
8
7
  # Main client for fetching variant data.
9
8
  class LocalEvaluationClient
10
9
  # Creates a new Experiment Client instance.
@@ -37,18 +36,38 @@ module AmplitudeExperiment
37
36
  # @param [String[]] flag_keys The flags to evaluate with the user. If empty, all flags from the flag cache are evaluated
38
37
  #
39
38
  # @return [Hash[String, Variant]] The evaluated variants
39
+ # @deprecated Please use {evaluate_v2} instead
40
40
  def evaluate(user, flag_keys = [])
41
+ variants = evaluate_v2(user, flag_keys)
42
+ AmplitudeExperiment.filter_default_variants(variants)
43
+ end
44
+
45
+ # Locally evaluates flag variants for a user.
46
+ # This function will only evaluate flags for the keys specified in the flag_keys argument. If flag_keys is
47
+ # missing or None, all flags are evaluated. This function differs from evaluate as it will return a default
48
+ # variant object if the flag was evaluated but the user was not assigned (i.e. off).
49
+ #
50
+ # @param [User] user The user to evaluate
51
+ # @param [String[]] flag_keys The flags to evaluate with the user, if empty all flags are evaluated
52
+ # @return [Hash[String, Variant]] The evaluated variants
53
+ def evaluate_v2(user, flag_keys = [])
41
54
  flags = @flags_mutex.synchronize do
42
55
  @flags
43
56
  end
44
57
  return {} if flags.nil?
45
58
 
46
- user_str = user.to_json
59
+ sorted_flags = AmplitudeExperiment.topological_sort(flags, flag_keys.to_set)
60
+ flags_json = sorted_flags.to_json
61
+
62
+ enriched_user = AmplitudeExperiment.user_to_evaluation_context(user)
63
+ user_str = enriched_user.to_json
47
64
 
48
65
  @logger.debug("[Experiment] Evaluate: User: #{user_str} - Rules: #{flags}") if @config.debug
49
- result = evaluation(flags, user_str)
66
+ result = evaluation(flags_json, user_str)
50
67
  @logger.debug("[Experiment] evaluate - result: #{result}") if @config.debug
51
- parse_results(result, flag_keys, user)
68
+ variants = AmplitudeExperiment.evaluation_variants_json_to_variants(result)
69
+ @assignment_service&.track(Assignment.new(user, variants))
70
+ variants
52
71
  end
53
72
 
54
73
  # Fetch initial flag configurations and start polling for updates.
@@ -69,29 +88,14 @@ module AmplitudeExperiment
69
88
 
70
89
  private
71
90
 
72
- def parse_results(result, flag_keys, user)
73
- variants = {}
74
- assignments = {}
75
- result.each do |key, value|
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
82
-
83
- assignments[key] = value if included || value['type'] == FLAG_TYPE_MUTUAL_EXCLUSION_GROUP || value['type'] == FLAG_TYPE_HOLDOUT_GROUP
84
- end
85
- @assignment_service&.track(Assignment.new(user, assignments))
86
- variants
87
- end
88
-
89
91
  def run
90
92
  @is_running = true
91
93
  begin
92
- flags = @fetcher.fetch_v1
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 }
93
97
  @flags_mutex.synchronize do
94
- @flags = flags
98
+ @flags = flags_map
95
99
  end
96
100
  rescue StandardError => e
97
101
  @logger.error("[Experiment] Flag poller - error: #{e.message}")
@@ -57,11 +57,11 @@ module EvaluationInterop
57
57
  attach_function :libevaluation_interop_symbols, [], Libevaluation_interop_ExportedSymbols.by_ref
58
58
  end
59
59
 
60
- def evaluation(rule_json, user_json)
60
+ def evaluation(rule_json, context_json)
61
61
  lib = EvaluationInterop.libevaluation_interop_symbols()
62
62
  evaluate = lib[:kotlin][:root][:evaluate]
63
63
  dispose = lib[:DisposeString]
64
- result_raw = evaluate.call(rule_json, user_json)
64
+ result_raw = evaluate.call(rule_json, context_json)
65
65
  result_json = result_raw.read_string
66
66
  result = JSON.parse(result_json)
67
67
  dispose.call(result_raw)
@@ -99,7 +99,7 @@ typedef struct {
99
99
  /* User functions. */
100
100
  struct {
101
101
  struct {
102
- const char* (*evaluate)(const char* rules, const char* user);
102
+ const char* (*evaluate)(const char* flags, const char* context);
103
103
  } root;
104
104
  } kotlin;
105
105
  } libevaluation_interop_ExportedSymbols;
@@ -99,7 +99,7 @@ typedef struct {
99
99
  /* User functions. */
100
100
  struct {
101
101
  struct {
102
- const char* (*evaluate)(const char* rules, const char* user);
102
+ const char* (*evaluate)(const char* flags, const char* context);
103
103
  } root;
104
104
  } kotlin;
105
105
  } libevaluation_interop_ExportedSymbols;
@@ -99,7 +99,7 @@ typedef struct {
99
99
  /* User functions. */
100
100
  struct {
101
101
  struct {
102
- const char* (*evaluate)(const char* rules, const char* user);
102
+ const char* (*evaluate)(const char* flags, const char* context);
103
103
  } root;
104
104
  } kotlin;
105
105
  } libevaluation_interop_ExportedSymbols;
@@ -99,7 +99,7 @@ typedef struct {
99
99
  /* User functions. */
100
100
  struct {
101
101
  struct {
102
- const char* (*evaluate)(const char* rules, const char* user);
102
+ const char* (*evaluate)(const char* flags, const char* context);
103
103
  } root;
104
104
  } kotlin;
105
105
  } libevaluation_interop_ExportedSymbols;
@@ -31,6 +31,21 @@ module AmplitudeExperiment
31
31
  response.body
32
32
  end
33
33
 
34
+ def fetch_v2
35
+ # fetch flag_configs
36
+ headers = {
37
+ 'Authorization' => "Api-Key #{@api_key}",
38
+ 'Content-Type' => 'application/json;charset=utf-8',
39
+ 'X-Amp-Exp-Library' => "experiment-ruby-server/#{VERSION}"
40
+ }
41
+ request = Net::HTTP::Get.new("#{@server_url}/sdk/v2/flags?v=0", headers)
42
+ response = @http.request(request)
43
+ raise "flagConfigs - received error response: #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPOK)
44
+
45
+ @logger.debug("[Experiment] Fetch flag configs: #{response.body}")
46
+ response.body
47
+ end
48
+
34
49
  # Fetch local evaluation mode flag configs from the Experiment API server.
35
50
  # These flag configs can be used to perform local evaluation.
36
51
  #
@@ -30,7 +30,7 @@ module AmplitudeExperiment
30
30
  # @param [User] user
31
31
  # @return [Hash] Variants Hash
32
32
  def fetch(user)
33
- filter_default_variants(fetch_internal(user))
33
+ AmplitudeExperiment.filter_default_variants(fetch_internal(user))
34
34
  rescue StandardError => e
35
35
  @logger.error("[Experiment] Failed to fetch variants: #{e.message}")
36
36
  {}
@@ -144,30 +144,11 @@ module AmplitudeExperiment
144
144
  raise FetchError.new(response.code.to_i, "Fetch error response: status=#{response.code} #{response.message}") if response.code != '200'
145
145
 
146
146
  json = JSON.parse(response.body)
147
- variants = parse_json_variants(json)
147
+ variants = AmplitudeExperiment.evaluation_variants_json_to_variants(json)
148
148
  @logger.debug("[Experiment] Fetched variants: #{variants}")
149
149
  variants
150
150
  end
151
151
 
152
- # Parse JSON response hash
153
- #
154
- # @param [Hash] json
155
- # @return [Hash] Hash with String => Variant
156
- def parse_json_variants(json)
157
- variants = {}
158
- json.each do |key, value|
159
- variant_value = ''
160
- if value.key?('value')
161
- variant_value = value.fetch('value')
162
- elsif value.key?('key')
163
- # value was previously under the "key" field
164
- variant_value = value.fetch('key')
165
- end
166
- variants.store(key, Variant.new(variant_value, value.fetch('payload', nil), value.fetch('key', nil), value.fetch('metadata', nil)))
167
- end
168
- variants
169
- end
170
-
171
152
  # @param [User] user
172
153
  # @return [User, Hash] user with library context
173
154
  def add_context(user)
@@ -181,18 +162,5 @@ module AmplitudeExperiment
181
162
 
182
163
  true
183
164
  end
184
-
185
- def filter_default_variants(variants)
186
- variants.each do |key, value|
187
- default = value&.metadata&.fetch('default', nil)
188
- deployed = value&.metadata&.fetch('deployed', nil)
189
- default = false if default.nil?
190
- deployed = true if deployed.nil?
191
- variants.delete(key) if default || !deployed
192
- end
193
- variants
194
- end
195
-
196
- private :filter_default_variants
197
165
  end
198
166
  end
@@ -72,6 +72,14 @@ module AmplitudeExperiment
72
72
  # @return [Hash, nil] the value of user properties
73
73
  attr_accessor :user_properties
74
74
 
75
+ # Predefined field, must be manually provided
76
+ # @return [Hash, nil] the value of groups
77
+ attr_accessor :groups
78
+
79
+ # Predefined field, must be manually provided
80
+ # @return [Hash, nil] the value of group properties
81
+ attr_accessor :group_properties
82
+
75
83
  # @param [String, nil] device_id Device ID for associating with an identity in Amplitude
76
84
  # @param [String, nil] user_id User ID for associating with an identity in Amplitude
77
85
  # @param [String, nil] country Predefined field, must be manually provided
@@ -89,9 +97,11 @@ module AmplitudeExperiment
89
97
  # @param [String, nil] carrier Predefined field, must be manually provided
90
98
  # @param [String, nil] library Predefined field, auto populated, can be manually overridden
91
99
  # @param [Hash, nil] user_properties Custom user properties
100
+ # @param [Hash, nil] groups List of groups the user belongs to
101
+ # @param [Hash, nil] group_properties Custom properties for groups
92
102
  def initialize(device_id: nil, user_id: nil, country: nil, city: nil, region: nil, dma: nil, ip_address: nil, language: nil,
93
103
  platform: nil, version: nil, os: nil, device_manufacturer: nil, device_brand: nil,
94
- device_model: nil, carrier: nil, library: nil, user_properties: nil)
104
+ device_model: nil, carrier: nil, library: nil, user_properties: nil, groups: nil, group_properties: nil)
95
105
  @device_id = device_id
96
106
  @user_id = user_id
97
107
  @country = country
@@ -109,6 +119,8 @@ module AmplitudeExperiment
109
119
  @carrier = carrier
110
120
  @library = library
111
121
  @user_properties = user_properties
122
+ @groups = groups
123
+ @group_properties = group_properties
112
124
  end
113
125
 
114
126
  # Return User as Hash.
@@ -131,8 +143,10 @@ module AmplitudeExperiment
131
143
  device_model: @device_model,
132
144
  carrier: @carrier,
133
145
  library: @library,
134
- user_properties: @user_properties
135
- }
146
+ user_properties: @user_properties,
147
+ groups: @groups,
148
+ group_properties: @group_properties
149
+ }.compact
136
150
  end
137
151
 
138
152
  # Return user information as JSON string.
@@ -0,0 +1,39 @@
1
+ module AmplitudeExperiment
2
+ def self.topological_sort(flags, keys = nil, ordered: false)
3
+ available = flags.dup
4
+ result = []
5
+ starting_keys = keys.nil? || keys.empty? ? flags.keys : keys
6
+ # Used for testing to ensure consistency.
7
+ starting_keys.sort! if ordered && (keys.nil? || keys.empty?)
8
+
9
+ starting_keys.each do |flag_key|
10
+ traversal = parent_traversal(flag_key, available, Set.new)
11
+ result.concat(traversal) unless traversal.nil?
12
+ end
13
+ result
14
+ end
15
+
16
+ def self.parent_traversal(flag_key, available, path)
17
+ flag = available[flag_key]
18
+ return nil if flag.nil?
19
+
20
+ dependencies = flag['dependencies']
21
+ if dependencies.nil? || dependencies.empty?
22
+ available.delete(flag_key)
23
+ return [flag]
24
+ end
25
+
26
+ path.add(flag_key)
27
+ result = []
28
+ dependencies.each do |parent_key|
29
+ raise CycleError, path if path.include?(parent_key)
30
+
31
+ traversal = parent_traversal(parent_key, available, path)
32
+ result.concat(traversal) unless traversal.nil?
33
+ end
34
+ result << flag
35
+ path.delete(flag_key)
36
+ available.delete(flag_key)
37
+ result
38
+ end
39
+ end
@@ -0,0 +1,33 @@
1
+ module AmplitudeExperiment
2
+ def self.user_to_evaluation_context(user)
3
+ user_groups = user.groups
4
+ user_group_properties = user.group_properties
5
+ user_hash = user.as_json.compact
6
+ user_hash.delete(:groups)
7
+ user_hash.delete(:group_properties)
8
+
9
+ context = user_hash.empty? ? {} : { user: user_hash }
10
+
11
+ return context if user_groups.nil?
12
+
13
+ groups = {}
14
+ user_groups.each do |group_type, group_name|
15
+ group_name = group_name[0] if group_name.is_a?(Array) && !group_name.empty?
16
+
17
+ groups[group_type.to_sym] = { group_name: group_name }
18
+
19
+ next if user_group_properties.nil?
20
+
21
+ group_properties_type = user_group_properties[group_type.to_sym]
22
+ next if group_properties_type.nil? || !group_properties_type.is_a?(Hash)
23
+
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
28
+ end
29
+
30
+ context[:groups] = groups unless groups.empty?
31
+ context
32
+ end
33
+ end
@@ -0,0 +1,32 @@
1
+ require 'json'
2
+ module AmplitudeExperiment
3
+ def self.evaluation_variants_json_to_variants(variants_json)
4
+ variants = {}
5
+ variants_json.each do |key, value|
6
+ variants[key] = AmplitudeExperiment.evaluation_variant_json_to_variant(value)
7
+ end
8
+ variants
9
+ end
10
+
11
+ def self.evaluation_variant_json_to_variant(variant_json)
12
+ value = variant_json['value']
13
+ value = value.to_json if value && !value.is_a?(String)
14
+ Variant.new(
15
+ value: value,
16
+ key: variant_json['key'],
17
+ payload: variant_json['payload'],
18
+ metadata: variant_json['metadata']
19
+ )
20
+ end
21
+
22
+ def self.filter_default_variants(variants)
23
+ variants.each do |key, value|
24
+ default = value&.metadata&.fetch('default', nil)
25
+ deployed = value&.metadata&.fetch('deployed', nil)
26
+ default = false if default.nil?
27
+ deployed = true if deployed.nil?
28
+ variants.delete(key) if default || !deployed
29
+ end
30
+ variants
31
+ end
32
+ end
@@ -17,7 +17,9 @@ module AmplitudeExperiment
17
17
 
18
18
  # @param [String] value The value of the variant determined by the flag configuration.
19
19
  # @param [Object, nil] payload The attached payload, if any.
20
- def initialize(value, payload = nil, key = nil, metadata = nil)
20
+ # @param [String] key The key of the variant determined by the flag configuration.
21
+ # @param [Object, nil] metadata The attached metadata, if any.
22
+ def initialize(value: nil, payload: nil, key: nil, metadata: nil)
21
23
  @key = key
22
24
  @value = value
23
25
  @payload = payload
@@ -1,3 +1,3 @@
1
1
  module AmplitudeExperiment
2
- VERSION = '1.3.1'.freeze
2
+ VERSION = '1.4.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.3.1
4
+ version: 1.4.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-06-28 00:00:00.000000000 Z
11
+ date: 2024-07-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: concurrent-ruby
@@ -200,6 +200,9 @@ files:
200
200
  - lib/experiment/user.rb
201
201
  - lib/experiment/util/hash.rb
202
202
  - lib/experiment/util/lru_cache.rb
203
+ - lib/experiment/util/topological_sort.rb
204
+ - lib/experiment/util/user.rb
205
+ - lib/experiment/util/variant.rb
203
206
  - lib/experiment/variant.rb
204
207
  - lib/experiment/version.rb
205
208
  homepage: https://github.com/amplitude/experiment-ruby-server