gitlab-experiment 0.2.4 → 0.3.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: a6284349854da93da979695a6493df95c0247616e10d5adc047d56d5dbdbf324
4
- data.tar.gz: 1e4456945313fce9fbf03250299e3a0eb1b406035e4cce0d0559eee1fb3cfe53
3
+ metadata.gz: 6134f703a49eb7411ff59e1fa7778421890498a537419cd765a99c5e07ff1523
4
+ data.tar.gz: ea3736188b46c9527818109dc87839c33937e0c9086bae4464992046f425874a
5
5
  SHA512:
6
- metadata.gz: '08d5c04e817c2679d115a845df588a4b81358e1b7ac7ddc8282a4cdaa2a878f6a4111b318fa3155671fdd05c0cb950ab076210518142e5f8a04db89e7b164104'
7
- data.tar.gz: 118de1ae6b9995edee4058a1b756030ee6659199bf19a29cf2c1446ddb87a1a8ff29f479cf9ff20f7af47ff7706bbc97256929ed800f803fc381450bcedbf82c
6
+ metadata.gz: ec048634699257e018d7e67f290dfa2dd5e2712d58b1ef25eeb1c9353c5a23ad2f7a8ec0484493e6bccaabc57801aa22ceb8632a5a8c355652ae4a5541a43286
7
+ data.tar.gz: 51f63fb0256c49f393887451723483233927a0a983c51ca80f98494634e156dab28d0f87f21319a4df750392bb8329923e94ab23091929b3b26904edb25ce5b3
data/README.md CHANGED
@@ -38,7 +38,7 @@ In our control (current world) we show a simple toggle interface labeled, "Notif
38
38
 
39
39
  The behavior will be the same, but the interface will be different and may involve more or fewer steps.
40
40
 
41
- Our hypothesis is that this will make the action more clear and will help the user in making a choice about if that's what they really want to do.
41
+ Our hypothesis is that this will make the action more clear and will help in making a choice about if that's what the user really wants to do.
42
42
 
43
43
  We'll name our experiment `notification_toggle`. This name is prefixed based on configuration. If you've set `config.name_prefix = 'gitlab'`, the experiment name would be `gitlab_notification_toggle` elsewhere.
44
44
 
@@ -51,7 +51,7 @@ Now in our experiment we're going to render one of two views: the control will b
51
51
  ```ruby
52
52
  class SubscriptionsController < ApplicationController
53
53
  def show
54
- experiment(:notification_toggle, user_id: user.id) do |e|
54
+ experiment(:notification_toggle, actor: user) do |e|
55
55
  e.use { render_toggle } # control
56
56
  e.try { render_button } # candidate
57
57
  end
@@ -64,7 +64,7 @@ You can define the experiment using simple control/candidate paths, or provide n
64
64
  Handling multi-variant experiments is up to the configuration you provide around resolving variants. But in our example we may want to try with and without the confirmation. We can run any number of variations in our experiments this way.
65
65
 
66
66
  ```ruby
67
- experiment(:notification_toggle, user_id: user.id) do |e|
67
+ experiment(:notification_toggle, actor: user) do |e|
68
68
  e.use { render_toggle } # control
69
69
  e.try(:variant_one) { render_button(confirmation: true) }
70
70
  e.try(:variant_two) { render_button(confirmation: false) }
@@ -76,7 +76,7 @@ Understanding how an experiment can change behavior is important in evaluating i
76
76
  To this end, we track events that are important by calling the same experiment elsewhere in code. By using the same context, you'll have consistent behavior and the ability to track events to it.
77
77
 
78
78
  ```ruby
79
- experiment(:notification_toggle, user_id: user.id).track(:clicked_button)
79
+ experiment(:notification_toggle, actor: user).track(:clicked_button)
80
80
  ```
81
81
 
82
82
  <details>
@@ -85,10 +85,10 @@ experiment(:notification_toggle, user_id: user.id).track(:clicked_button)
85
85
  ### Class level interface using `.run`
86
86
 
87
87
  ```ruby
88
- exp = Gitlab::Experiment.run(:notification_toggle, user_id: user.id) do |e|
88
+ exp = Gitlab::Experiment.run(:notification_toggle, actor: user) do |e|
89
89
  # Context may be passed in the block, but must be finalized before calling
90
90
  # run or track.
91
- e.context(project_id: project.id) # add the project id to the context
91
+ e.context(project: project) # add the project to the context
92
92
 
93
93
  # Define the control and candidate variant.
94
94
  e.use { render_toggle } # control
@@ -102,10 +102,10 @@ exp.track(:clicked_button)
102
102
  ### Instance level interface
103
103
 
104
104
  ```ruby
105
- exp = Gitlab::Experiment.new(:notification_toggle, user_id: user.id)
105
+ exp = Gitlab::Experiment.new(:notification_toggle, actor: user)
106
106
  # Additional context may be provided to the instance (exp) but must be
107
107
  # finalized before calling run or track.
108
- exp.context(project_id: project.id) # add the project id to the context
108
+ exp.context(project: project) # add the project id to the context
109
109
 
110
110
  # Define the control and candidate variant.
111
111
  exp.use { render_toggle } # control
@@ -136,10 +136,10 @@ class NotificationExperiment < Gitlab::Experiment
136
136
  end
137
137
  end
138
138
 
139
- exp = NotificationExperiment.new(user_id: user.id) do |e|
139
+ exp = NotificationExperiment.new(actor: user) do |e|
140
140
  # Context may be provided within the block or to the instance (exp) but must
141
141
  # be finalized before calling run or track.
142
- e.context(project_id: project.id) # add the project id to the context
142
+ e.context(project: project) # add the project id to the context
143
143
  end
144
144
 
145
145
  # Run the experiment -- returning the result.
@@ -159,7 +159,7 @@ exp.track(:clicked_button)
159
159
  You can hardcode the variant if you want. It's important to know what this might do to your data during rollout, so use this with consideration.
160
160
 
161
161
  ```ruby
162
- experiment(:notification_toggle, :no_interface, user_id: user.id) do |e|
162
+ experiment(:notification_toggle, :no_interface, actor: user) do |e|
163
163
  e.use { render_toggle } # control
164
164
  e.try { render_button } # candidate
165
165
  e.try(:no_interface) { no_interface! } # variant
@@ -169,7 +169,7 @@ end
169
169
  Or you can set the variant within the block. This allows using unique segmentation logic or variant resolution if you need it.
170
170
 
171
171
  ```ruby
172
- experiment(:notification_toggle, user_id: user.id) do |e|
172
+ experiment(:notification_toggle, actor: user) do |e|
173
173
  # Variant selection must be done before calling run or track.
174
174
  e.variant(:no_interface) # set the variant
175
175
  # ...
@@ -179,7 +179,7 @@ end
179
179
  Or it can be specified in the call to run if you call it from within the block.
180
180
 
181
181
  ```ruby
182
- experiment(:notification_toggle, user_id: user.id) do |e|
182
+ experiment(:notification_toggle, actor: user) do |e|
183
183
  # ...
184
184
  # Variant selection can be specified when calling run.
185
185
  e.run(:no_interface)
@@ -209,13 +209,13 @@ Some experiments may extend outside of those layers, so you may want to include
209
209
  Note: In a lot of these contexts you may not have a reference to the request (unless you pass it in, or provide access to it) which may be needed if you want to enable cookie behaviors and track that through to user conversion.
210
210
 
211
211
  ```ruby
212
- class UserMailer < ApplicationMailer
212
+ class WelcomeMailer < ApplicationMailer
213
213
  include Gitlab::Experiment::Dsl # include the `experiment` method
214
214
 
215
215
  def welcome
216
216
  @user = params[:user]
217
217
 
218
- ex = experiment(:project_suggestions, user_id: @user.id) do |e|
218
+ ex = experiment(:project_suggestions, actor: @user) do |e|
219
219
  e.use { 'welcome' }
220
220
  e.try { 'welcome_with_project_suggestions' }
221
221
  end
@@ -234,8 +234,8 @@ Take for instance, that you might be using `version: 1` in your context currentl
234
234
  In providing the context migration data, we can resolve an experience and its events all the way back. This can also help in keeping our cache relevant.
235
235
 
236
236
  ```ruby
237
- # Migrate just the `:version` portion of the previous context, `{ user_id: 42, version: 1 }`:
238
- experiment(:my_experiment, user_id: 42, version: 2, migrated_with: { version: 1 })
237
+ # Migrate just the `:version` portion of the previous context, `{ actor: project, version: 1 }`:
238
+ experiment(:my_experiment, actor: project, version: 2, migrated_with: { version: 1 })
239
239
  ```
240
240
 
241
241
  You can add or remove context by providing a `migrated_from` option. This approach expects a full context replacement -- i.e. what it was before you added or removed the new context key.
@@ -243,8 +243,8 @@ You can add or remove context by providing a `migrated_from` option. This approa
243
243
  If you wanted to introduce a `version` to your context, provide the full previous context.
244
244
 
245
245
  ```ruby
246
- # Migrate the full context from `{ user_id: 42 }` to `{ user_id: 42, version: 1 }`:
247
- experiment(:my_experiment, user_id: 42, version: 1, migrated_from: { user_id: 42 })
246
+ # Migrate the full context from `{ actor: project }` to `{ actor: project, version: 1 }`:
247
+ experiment(:my_experiment, actor: project, version: 1, migrated_from: { actor: project })
248
248
  ```
249
249
 
250
250
  This can impact an experience if you:
@@ -252,36 +252,36 @@ This can impact an experience if you:
252
252
  1. haven't implemented the concept of migrations in your variant resolver
253
253
  1. haven't enabled a reasonable caching mechanism
254
254
 
255
- ### When there isn't a user (cookies)
255
+ ### When there isn't an actor (cookie fallback)
256
256
 
257
- When there isn't an identifying key in the context (this is `user_id` by default), we fall back to cookies to provide a consistent experience.
257
+ When there isn't an identifying key in the context (this is `actor` by default), we fall back to cookies to provide a consistent experience for the client viewing them.
258
258
 
259
- Once we assign a certain variant to a context, we need to always provide the same experience. We achieve this by setting a cookie for the experiment in question.
259
+ Once we assign a certain variant to a context, we need to always provide the same experience. We achieve this by setting a cookie for the experiment in question, but only when needed.
260
260
 
261
- This cookie is a randomized uuid and isn't associated with the user. When we can finally provide an identifying key, the context is auto migrated from the cookie to that identifying key. The cookie is a temporary value, and isn't used for tracking.
261
+ This cookie is a temporary, randomized uuid and isn't associated with a user. When we can finally provide an actor, the context is auto migrated from the cookie to that actor.
262
262
 
263
263
  To read and write cookies, we provide the `request` from within the controller and views. The cookie migration will happen automatically if the experiment is within those layers.
264
264
 
265
265
  You'll need to provide the `request` as an option to the experiment if it's outside of the controller and views.
266
266
 
267
267
  ```ruby
268
- experiment(:my_experiment, user_id: user&.id, request: request)
268
+ experiment(:my_experiment, actor: user, request: request)
269
269
  ```
270
270
 
271
- The cookie isn't set if the identifying key isn't present at all in the context. Using the default identifying key, when there is no `user_id` key provided, the cookie will not be set.
271
+ The cookie isn't set if the `actor` key isn't present at all in the context. Meaning that when no `actor` key is provided, the cookie will not be set.
272
272
 
273
273
  ```ruby
274
- # no user_id context key is present, so no cookie is set
275
- experiment(:my_experiment, project_id: @project.id)
274
+ # actor is not present, so no cookie is set
275
+ experiment(:my_experiment, project: project)
276
276
 
277
- # user_id context key is present, but is set to nil, so the cookie is set & used instead
278
- experiment(:my_experiment, user_id: nil, project_id: @project.id)
277
+ # actor is present and is nil, so the cookie is set and used
278
+ experiment(:my_experiment, actor: nil, project: project)
279
279
 
280
- # user_id context key is present and set to a value, so no cookie is set
281
- experiment(:my_experiment, user_id: @user.id, project_id: @project.id)
280
+ # actor is present and set to a value, so no cookie is set
281
+ experiment(:my_experiment, actor: user, project: project)
282
282
  ```
283
283
 
284
- For edge cases, you can pass the cookie through by assigning it yourself -- e.g. `user_id: request.cookie_jar.signed['my_experiment_id']`. The cookie name is the full experiment name (including any configured prefix) with `_id` appended -- e.g. `gitlab_notification_toggle_id` for the `:notification_toggle` experiment key with a configured prefix of `gitlab`.
284
+ For edge cases, you can pass the cookie through by assigning it yourself -- e.g. `actor: request.cookie_jar.signed['my_experiment_actor']`. The cookie name is the full experiment name (including any configured prefix) with `_actor` appended -- e.g. `gitlab_notification_toggle_actor` for the `:notification_toggle` experiment key with a configured prefix of `gitlab`.
285
285
 
286
286
  ## Configuration
287
287
 
@@ -4,6 +4,7 @@ require 'scientist'
4
4
 
5
5
  require 'gitlab/experiment/caching'
6
6
  require 'gitlab/experiment/configuration'
7
+ require 'gitlab/experiment/cookies'
7
8
  require 'gitlab/experiment/context'
8
9
  require 'gitlab/experiment/dsl'
9
10
  require 'gitlab/experiment/variant'
@@ -28,16 +29,13 @@ module Gitlab
28
29
  end
29
30
  end
30
31
 
31
- delegate :signature, :cache_strategy, to: :context
32
-
33
32
  def initialize(name, variant_name = nil, **context)
34
33
  @name = name
35
34
  @variant_name = variant_name
36
- @context = Context.new(self)
37
-
38
- context(context)
35
+ @excluded = []
36
+ @context = Context.new(self, context)
39
37
 
40
- ignore { true }
38
+ exclude { !@context.trackable? }
41
39
  compare { false }
42
40
 
43
41
  yield self if block_given?
@@ -57,10 +55,17 @@ module Gitlab
57
55
  result.respond_to?(:name) ? result : Variant.new(name: result.to_s)
58
56
  end
59
57
 
58
+ def exclude(&block)
59
+ @excluded << block
60
+ end
61
+
60
62
  def run(variant_name = nil)
61
- @variant_name = variant_name unless variant_name.nil?
63
+ @result ||= begin
64
+ @variant_name = variant_name unless variant_name.nil?
65
+ @variant_name ||= :control if excluded?
62
66
 
63
- @result ||= super(cache { variant.name })
67
+ super(cache { variant.name })
68
+ end
64
69
  end
65
70
 
66
71
  def publish(result)
@@ -68,6 +73,8 @@ module Gitlab
68
73
  end
69
74
 
70
75
  def track(action, **event_args)
76
+ return if excluded?
77
+
71
78
  instance_exec(action, event_args, &Configuration.tracking_behavior)
72
79
  end
73
80
 
@@ -76,19 +83,19 @@ module Gitlab
76
83
  end
77
84
 
78
85
  def variant_names
79
- @variant_names ||= behaviors.keys.tap { |keys| keys.delete('control') }.map(&:to_sym)
86
+ @variant_names ||= behaviors.keys.map(&:to_sym) - [:control]
80
87
  end
81
88
 
82
- def enabled?
83
- true
89
+ def signature
90
+ { variant: variant.name, experiment: name }.merge(context.signature)
84
91
  end
85
92
 
86
- def identifying_key
87
- :user_id
93
+ def enabled?
94
+ true
88
95
  end
89
96
 
90
- def cache_key_for(key, migration: false)
91
- "#{name}:#{key}"
97
+ def excluded?
98
+ @excluded.any? { |exclude| exclude.call(self) }
92
99
  end
93
100
 
94
101
  protected
@@ -10,6 +10,15 @@ module Gitlab
10
10
  migrated_cache(cache, migrations || [], key) or cache.fetch(key, &block)
11
11
  end
12
12
 
13
+ private
14
+
15
+ def cache_strategy
16
+ [
17
+ "#{name}:#{signature[:key]}",
18
+ signature[:migration_keys]&.map { |key| "#{name}:#{key}" }
19
+ ]
20
+ end
21
+
13
22
  def migrated_cache(cache, migrations, new_key)
14
23
  migrations.find do |old_key|
15
24
  next unless (value = cache.read(old_key))
@@ -3,92 +3,65 @@
3
3
  module Gitlab
4
4
  class Experiment
5
5
  class Context
6
+ include Cookies
7
+
6
8
  DNT_REGEXP = /^(true|t|yes|y|1|on)$/i.freeze
7
9
 
8
- def initialize(experiment)
10
+ def initialize(experiment, **initial_value)
9
11
  @experiment = experiment
10
12
  @value = {}
11
- @migrations_from = []
12
- @migrations_with = []
13
+ @migrations = { merged: [], unmerged: [] }
14
+
15
+ value(initial_value)
16
+ end
17
+
18
+ def reinitialize(request)
19
+ @signature = nil # clear memoization
20
+ @request = request if request.respond_to?(:headers) && request.respond_to?(:cookie_jar)
13
21
  end
14
22
 
15
23
  def value(value = nil)
16
24
  return @value if value.nil?
17
25
 
18
26
  value = value.dup # dup so we don't mutate
19
- @signature = @cache_strategy = nil # clear memoization
27
+ reinitialize(value.delete(:request))
20
28
 
21
- @migrations_from << value.delete(:migrated_from) if value[:migrated_from]
22
- @migrations_with << value.delete(:migrated_with) if value[:migrated_with]
23
- @value.merge!(auto_migrate_cookie(value, value.delete(:request)))
29
+ @value.merge!(process_migrations(value))
24
30
  end
25
31
 
26
- def freeze
27
- cache_strategy # ensure we memoize before freezing
28
- super
32
+ def trackable?
33
+ !(@request && @request.headers['DNT'].to_s.match?(DNT_REGEXP))
29
34
  end
30
35
 
31
- def cache_strategy
32
- @cache_strategy ||= [
33
- @experiment.cache_key_for(signature[:key]),
34
- signature[:migration_keys]&.map do |key|
35
- @experiment.cache_key_for(key, migration: true)
36
- end
37
- ]
36
+ def freeze
37
+ signature # finalize before freezing
38
+ super
38
39
  end
39
40
 
40
41
  def signature
41
- @signature ||= {
42
- key: key_for(@value),
43
- migration_keys: migration_keys,
44
- variant: @experiment.variant.name
45
- }.compact
42
+ @signature ||= { key: key_for(@value), migration_keys: migration_keys }.compact
46
43
  end
47
44
 
48
45
  private
49
46
 
50
- def auto_migrate_cookie(hash, request)
51
- return hash unless request&.respond_to?(:headers) && request&.respond_to?(:cookie_jar)
52
- return hash if request.headers['DNT'].to_s.match?(DNT_REGEXP)
47
+ def process_migrations(value)
48
+ add_migration(value.delete(:migrated_from))
49
+ add_migration(value.delete(:migrated_with), merge: true)
53
50
 
54
- resolver = cookie_resolver(request.cookie_jar, hash)
55
- resolve_cookie(*resolver) or generate_cookie(*resolver)
51
+ migrate_cookie(value, "#{@experiment.name}_id")
56
52
  end
57
53
 
58
- def cookie_resolver(jar, hash)
59
- [jar, hash, @experiment.identifying_key, jar.signed[cookie_name]].compact
60
- end
61
-
62
- def cookie_name
63
- @cookie_name ||= [@experiment.name, 'id'].join('_')
64
- end
65
-
66
- def resolve_cookie(jar, hash, key, cookie = nil)
67
- return if cookie.blank? && hash[key].blank?
68
- return hash if cookie.blank?
69
- return hash.merge(key => cookie) if hash[key].blank?
70
-
71
- @migrations_with << { key => cookie }
72
- jar.delete(cookie_name, domain: :all)
73
-
74
- hash
75
- end
76
-
77
- def generate_cookie(jar, hash, key, cookie = SecureRandom.uuid)
78
- return hash unless hash.key?(key)
79
-
80
- jar.permanent.signed[cookie_name] = {
81
- value: cookie, secure: true, domain: :all, httponly: true
82
- }
54
+ def add_migration(value, merge: false)
55
+ return unless value.is_a?(Hash)
83
56
 
84
- hash.merge(key => cookie)
57
+ @migrations[merge ? :merged : :unmerged] << value
85
58
  end
86
59
 
87
60
  def migration_keys
88
- return nil if @migrations_from.empty? && @migrations_with.empty?
61
+ return nil if @migrations[:unmerged].empty? && @migrations[:merged].empty?
89
62
 
90
- @migrations_from.map { |m| key_for(m) } +
91
- @migrations_with.map { |m| key_for(@value.merge(m)) }
63
+ @migrations[:unmerged].map { |m| key_for(m) } +
64
+ @migrations[:merged].map { |m| key_for(@value.merge(m)) }
92
65
  end
93
66
 
94
67
  def key_for(context)
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Gitlab
6
+ class Experiment
7
+ module Cookies
8
+ private
9
+
10
+ def migrate_cookie(hash, cookie_name)
11
+ return hash if cookie_jar.nil?
12
+
13
+ resolver = [hash, :actor, cookie_name, cookie_jar.signed[cookie_name]]
14
+ resolve_cookie(*resolver) or generate_cookie(*resolver)
15
+ end
16
+
17
+ def cookie_jar
18
+ @request&.cookie_jar
19
+ end
20
+
21
+ def resolve_cookie(hash, key, cookie_name, cookie)
22
+ return if cookie.to_s.empty? && hash[key].nil?
23
+ return hash if cookie.to_s.empty?
24
+ return hash.merge(key => cookie) if hash[key].nil?
25
+
26
+ add_migration(key => cookie)
27
+ cookie_jar.delete(cookie_name, domain: :all)
28
+
29
+ hash
30
+ end
31
+
32
+ def generate_cookie(hash, key, cookie_name, cookie)
33
+ return hash unless hash.key?(key)
34
+
35
+ cookie ||= SecureRandom.uuid
36
+ cookie_jar.permanent.signed[cookie_name] = {
37
+ value: cookie, secure: true, domain: :all, httponly: true
38
+ }
39
+
40
+ hash.merge(key => cookie)
41
+ end
42
+ end
43
+ end
44
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Gitlab
4
4
  class Experiment
5
- VERSION = '0.2.4'
5
+ VERSION = '0.3.0'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gitlab-experiment
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.4
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - GitLab
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-10-06 00:00:00.000000000 Z
11
+ date: 2020-10-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: scientist
@@ -46,6 +46,7 @@ files:
46
46
  - lib/gitlab/experiment/caching.rb
47
47
  - lib/gitlab/experiment/configuration.rb
48
48
  - lib/gitlab/experiment/context.rb
49
+ - lib/gitlab/experiment/cookies.rb
49
50
  - lib/gitlab/experiment/dsl.rb
50
51
  - lib/gitlab/experiment/engine.rb
51
52
  - lib/gitlab/experiment/variant.rb