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 +4 -4
- data/README.md +32 -32
- data/lib/gitlab/experiment.rb +22 -15
- data/lib/gitlab/experiment/caching.rb +9 -0
- data/lib/gitlab/experiment/context.rb +29 -56
- data/lib/gitlab/experiment/cookies.rb +44 -0
- data/lib/gitlab/experiment/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 6134f703a49eb7411ff59e1fa7778421890498a537419cd765a99c5e07ff1523
|
4
|
+
data.tar.gz: ea3736188b46c9527818109dc87839c33937e0c9086bae4464992046f425874a
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
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,
|
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,
|
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,
|
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,
|
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(
|
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,
|
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(
|
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(
|
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(
|
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,
|
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,
|
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,
|
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
|
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,
|
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, `{
|
238
|
-
experiment(:my_experiment,
|
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 `{
|
247
|
-
experiment(:my_experiment,
|
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
|
255
|
+
### When there isn't an actor (cookie fallback)
|
256
256
|
|
257
|
-
When there isn't an identifying key in the context (this is `
|
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
|
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,
|
268
|
+
experiment(:my_experiment, actor: user, request: request)
|
269
269
|
```
|
270
270
|
|
271
|
-
The cookie isn't set if the
|
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
|
-
#
|
275
|
-
experiment(:my_experiment,
|
274
|
+
# actor is not present, so no cookie is set
|
275
|
+
experiment(:my_experiment, project: project)
|
276
276
|
|
277
|
-
#
|
278
|
-
experiment(:my_experiment,
|
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
|
-
#
|
281
|
-
experiment(:my_experiment,
|
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. `
|
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
|
|
data/lib/gitlab/experiment.rb
CHANGED
@@ -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
|
-
@
|
37
|
-
|
38
|
-
context(context)
|
35
|
+
@excluded = []
|
36
|
+
@context = Context.new(self, context)
|
39
37
|
|
40
|
-
|
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
|
-
@
|
63
|
+
@result ||= begin
|
64
|
+
@variant_name = variant_name unless variant_name.nil?
|
65
|
+
@variant_name ||= :control if excluded?
|
62
66
|
|
63
|
-
|
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.
|
86
|
+
@variant_names ||= behaviors.keys.map(&:to_sym) - [:control]
|
80
87
|
end
|
81
88
|
|
82
|
-
def
|
83
|
-
|
89
|
+
def signature
|
90
|
+
{ variant: variant.name, experiment: name }.merge(context.signature)
|
84
91
|
end
|
85
92
|
|
86
|
-
def
|
87
|
-
|
93
|
+
def enabled?
|
94
|
+
true
|
88
95
|
end
|
89
96
|
|
90
|
-
def
|
91
|
-
|
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
|
-
@
|
12
|
-
|
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
|
-
|
27
|
+
reinitialize(value.delete(:request))
|
20
28
|
|
21
|
-
@
|
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
|
27
|
-
|
28
|
-
super
|
32
|
+
def trackable?
|
33
|
+
!(@request && @request.headers['DNT'].to_s.match?(DNT_REGEXP))
|
29
34
|
end
|
30
35
|
|
31
|
-
def
|
32
|
-
|
33
|
-
|
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
|
51
|
-
|
52
|
-
|
47
|
+
def process_migrations(value)
|
48
|
+
add_migration(value.delete(:migrated_from))
|
49
|
+
add_migration(value.delete(:migrated_with), merge: true)
|
53
50
|
|
54
|
-
|
55
|
-
resolve_cookie(*resolver) or generate_cookie(*resolver)
|
51
|
+
migrate_cookie(value, "#{@experiment.name}_id")
|
56
52
|
end
|
57
53
|
|
58
|
-
def
|
59
|
-
|
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
|
-
|
57
|
+
@migrations[merge ? :merged : :unmerged] << value
|
85
58
|
end
|
86
59
|
|
87
60
|
def migration_keys
|
88
|
-
return nil if @
|
61
|
+
return nil if @migrations[:unmerged].empty? && @migrations[:merged].empty?
|
89
62
|
|
90
|
-
@
|
91
|
-
@
|
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
|
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.
|
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-
|
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
|