gitlab-experiment 0.2.2 → 0.2.3
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +192 -67
- data/lib/generators/gitlab_experiment/install/templates/initializer.rb +34 -22
- data/lib/gitlab/experiment.rb +15 -4
- data/lib/gitlab/experiment/caching.rb +24 -0
- data/lib/gitlab/experiment/configuration.rb +7 -4
- data/lib/gitlab/experiment/context.rb +22 -9
- 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: 1a35b23202fa542fa548093c65ec4580e764fdbda2549291a54bfa3ed98cec96
|
4
|
+
data.tar.gz: 05f4c1ed8c5761ab03ed1e9d763d6e612f661cf9ee8736e46fb0e48b947ed52d
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 9505898d8870749dbccab8fa98974948895a9898838aecc622e9f3243624a889076b248f6c0698ad8d44c63726447cc1b46638158614bb008015a82ce42a8d19
|
7
|
+
data.tar.gz: 3484817693dbaf9254d16083c6fbb745fbe47adc7868bf924bd418d8f67735fd26ac00f7f486aecc4f9ec84647f58e7bfcb0ff15199370b2e782d68cf2a0b2a2
|
data/README.md
CHANGED
@@ -1,26 +1,47 @@
|
|
1
1
|
GitLab Experiment
|
2
2
|
=================
|
3
3
|
|
4
|
-
Here at GitLab,
|
4
|
+
Here at GitLab, we run experiments as A/B/n tests and review the data the experiment generates. From that data, we determine the best performing variant and promote it as the new default code path. Or revert back to the control if no variant outperformed it.
|
5
5
|
|
6
|
-
|
6
|
+
This library provides a clean and elegant DSL to define, run, and track your GitLab experiment.
|
7
|
+
|
8
|
+
When we discuss the behavior of this gem, we'll use terms like experiment, context, control, candidate, and variant. It's worth defining these terms so they're more understood.
|
7
9
|
|
8
10
|
- `experiment` is any deviation of code paths we want to run sometimes and not others.
|
11
|
+
- `context` is used to identify a consistent experience we'll provide in an experiment.
|
9
12
|
- `control` is the default, or "original" code path.
|
10
|
-
- `candidate`
|
13
|
+
- `candidate` defines that there's one experimental code path.
|
11
14
|
- `variant(s)` is used when more than one experimental code path exists.
|
12
15
|
|
13
|
-
Candidate and variant are
|
16
|
+
Candidate and variant are the same concept, but simplify how we speak about experimental paths.
|
17
|
+
|
18
|
+
## Opinionated elevator pitch
|
19
|
+
|
20
|
+
The last time I travelled I went through a process at the airport when I arrived. My passport was checked, I was asked a series of questions about my visit, and was ushered to a digital kiosk where I was prompted with a few more questions on a touch screen.
|
21
|
+
|
22
|
+
It was Iceland, and for the record, it's a beautiful place and you should visit if you haven't yet.
|
23
|
+
|
24
|
+
Anyway, at various stages of this process travellers are presented with a physical emoji button interface, and are encouraged to rate their satisfaction within the various stage.
|
25
|
+
|
26
|
+
Running an experiment could be to change some aspect of the process for only some travelers, let's say by routing them down a different hallway. At various stages of both hallways we still have the emoji interfaces that can be happily tapped or angrily jabbed in their passing.
|
27
|
+
|
28
|
+
After a while we can compare the results and can evaluate which hallway had the better overall experience, based on the ratings provided at the various stages.
|
29
|
+
|
30
|
+
This library is about keeping track of which passports we send down which hallway, so we can consistently route them down the same hallway, and to know which hallway they're rating when they do.
|
31
|
+
|
32
|
+
In this model we don’t need to know anything about the passport holder unless we decide to "ask", as we determine which hallway to send them down initially.
|
33
|
+
|
34
|
+
This library doesn't provide a system of linking passports back to their passport holders, but it doesn't explicitly make doing so impossible. Doing so is often not a relevant detail on well defined and well executed experiments.
|
14
35
|
|
15
36
|
## Installation
|
16
37
|
|
17
|
-
Add the gem to your Gemfile and then bundle install
|
38
|
+
Add the gem to your Gemfile and then `bundle install`.
|
18
39
|
|
19
40
|
```ruby
|
20
41
|
gem 'gitlab-experiment'
|
21
42
|
```
|
22
43
|
|
23
|
-
If you're using Rails, you can install
|
44
|
+
If you're using Rails, you can install the initializer. It provides basic configuration and documentation.
|
24
45
|
|
25
46
|
```shell
|
26
47
|
$ rails generate gitlab-experiment:install
|
@@ -28,46 +49,51 @@ $ rails generate gitlab-experiment:install
|
|
28
49
|
|
29
50
|
## Implementing an experiment
|
30
51
|
|
31
|
-
For the sake of
|
52
|
+
For the sake of an example let's make one up. Let's run an experiment on what we render for disabling desktop notifications.
|
53
|
+
|
54
|
+
In our control (current world) we show a simple toggle interface that reads "Notifications". In our experiment we want a "Turn on/off desktop notifications" button with a confirmation.
|
32
55
|
|
33
|
-
|
56
|
+
The behavior will be the same, but the interface will be different and may involve more or less steps.
|
34
57
|
|
35
|
-
|
58
|
+
This makes the action more clear and will help the user in making a choice about if that's what they want to do. Or that's what we're going to try to find out.
|
36
59
|
|
37
|
-
We'll name our experiment `
|
60
|
+
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.
|
38
61
|
|
39
|
-
When you implement
|
62
|
+
When you implement an experiment you'll need to provide a name, and a context. The name can show up in tracking calls, and potentially other aspects. The context determines the variant assigned, and should be consistent between calls. We'll discuss migrating context in later examples.
|
40
63
|
|
41
|
-
|
64
|
+
A context "key" represents the unique id of a context. It allows us to give the same experience between different calls to the experiment and can be used in caching.
|
65
|
+
|
66
|
+
Now in our experiment we're going to render one of two views. Control will be our current view, and candidate will be the new toggle button with a confirmation flow.
|
42
67
|
|
43
68
|
```ruby
|
44
69
|
class SubscriptionsController < ApplicationController
|
45
|
-
include Gitlab::Experiment::Dsl
|
46
|
-
|
47
70
|
def show
|
48
|
-
experiment(:
|
49
|
-
e.use {
|
50
|
-
e.try {
|
71
|
+
experiment(:notification_toggle, user_id: user.id) do |e|
|
72
|
+
e.use { render_toggle } # control
|
73
|
+
e.try { render_button } # candidate
|
51
74
|
end
|
52
75
|
end
|
53
76
|
end
|
54
77
|
```
|
55
78
|
|
56
|
-
You can
|
79
|
+
You can define the experiment using simple control/candidate paths, or provide named variants.
|
80
|
+
|
81
|
+
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.
|
57
82
|
|
58
83
|
```ruby
|
59
|
-
experiment(:
|
60
|
-
e.use {
|
61
|
-
e.try(:variant_one) {
|
62
|
-
e.try(:variant_two) {
|
84
|
+
experiment(:notification_toggle, user_id: user.id) do |e|
|
85
|
+
e.use { render_toggle } # control
|
86
|
+
e.try(:variant_one) { render_button(confirmation: true) }
|
87
|
+
e.try(:variant_two) { render_button(confirmation: false) }
|
63
88
|
end
|
64
89
|
```
|
65
90
|
|
66
|
-
|
91
|
+
Understanding how an experiment can change behavior is important in evaluating its performance.
|
92
|
+
|
93
|
+
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.
|
67
94
|
|
68
95
|
```ruby
|
69
|
-
|
70
|
-
exp.track('clicked_button')
|
96
|
+
experiment(:notification_toggle, user_id: user.id).track(:clicked_button)
|
71
97
|
```
|
72
98
|
|
73
99
|
<details>
|
@@ -76,14 +102,14 @@ exp.track('clicked_button')
|
|
76
102
|
### Class level interface using `.run`
|
77
103
|
|
78
104
|
```ruby
|
79
|
-
exp = Gitlab::Experiment.run(:
|
80
|
-
# Context may be passed in the block, but must be finalized before calling
|
105
|
+
exp = Gitlab::Experiment.run(:notification_toggle, user_id: user.id) do |e|
|
106
|
+
# Context may be passed in the block, but must be finalized before calling
|
81
107
|
# run or track.
|
82
108
|
e.context(project_id: project.id) # add the project id to the context
|
83
|
-
|
109
|
+
|
84
110
|
# Define the control and candidate variant.
|
85
|
-
e.use {
|
86
|
-
e.try {
|
111
|
+
e.use { render_toggle } # control
|
112
|
+
e.try { render_button } # candidate
|
87
113
|
end
|
88
114
|
|
89
115
|
# Track an event on the experiment we've defined.
|
@@ -93,14 +119,14 @@ exp.track(:clicked_button)
|
|
93
119
|
### Instance level interface
|
94
120
|
|
95
121
|
```ruby
|
96
|
-
exp = Gitlab::Experiment.new(:
|
97
|
-
# Context may be passed in the block, but must be finalized before calling
|
122
|
+
exp = Gitlab::Experiment.new(:notification_toggle, user_id: user.id)
|
123
|
+
# Context may be passed in the block, but must be finalized before calling
|
98
124
|
# run or track.
|
99
125
|
exp.context(project_id: project.id) # add the project id to the context
|
100
126
|
|
101
127
|
# Define the control and candidate variant.
|
102
|
-
exp.use {
|
103
|
-
exp.try {
|
128
|
+
exp.use { render_toggle } # control
|
129
|
+
exp.try { render_button } # candidate
|
104
130
|
|
105
131
|
# Run the experiment -- returning the result.
|
106
132
|
exp.run
|
@@ -112,23 +138,23 @@ exp.track(:clicked_button)
|
|
112
138
|
</details>
|
113
139
|
|
114
140
|
<details>
|
115
|
-
<summary>You can define use custom classes...</summary>
|
141
|
+
<summary>You can define and use custom classes...</summary>
|
116
142
|
|
117
143
|
### Custom class
|
118
144
|
|
119
145
|
```ruby
|
120
|
-
class
|
146
|
+
class NotificationExperiment < Gitlab::Experiment
|
121
147
|
def initialize(variant_name = nil, **context, &block)
|
122
|
-
super(:
|
148
|
+
super(:notification_toggle, variant_name, **context, &block)
|
123
149
|
|
124
150
|
# Define the control and candidate variant.
|
125
|
-
use {
|
126
|
-
try {
|
151
|
+
use { render_toggle } # control
|
152
|
+
try { render_button } # candidate
|
127
153
|
end
|
128
154
|
end
|
129
155
|
|
130
|
-
exp =
|
131
|
-
# Context may be passed in the block, but must be finalized before calling
|
156
|
+
exp = NotificationExperiment.new(user_id: user.id) do |e|
|
157
|
+
# Context may be passed in the block, but must be finalized before calling
|
132
158
|
# run or track.
|
133
159
|
e.context(project_id: project.id) # add the project id to the context
|
134
160
|
end
|
@@ -143,24 +169,24 @@ exp.track(:clicked_button)
|
|
143
169
|
</details>
|
144
170
|
|
145
171
|
<details>
|
146
|
-
<summary>You can also
|
172
|
+
<summary>You can also specify the variant to use...</summary>
|
147
173
|
|
148
|
-
### Specifying
|
174
|
+
### Specifying variant
|
149
175
|
|
150
|
-
|
176
|
+
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.
|
151
177
|
|
152
178
|
```ruby
|
153
|
-
experiment(:
|
154
|
-
e.use {
|
155
|
-
e.try {
|
179
|
+
experiment(:notification_toggle, :no_interface, user_id: user.id) do |e|
|
180
|
+
e.use { render_toggle } # control
|
181
|
+
e.try { render_button } # candidate
|
156
182
|
e.try(:no_interface) { no_interface! } # variant
|
157
183
|
end
|
158
184
|
```
|
159
185
|
|
160
|
-
Or you can set the variant within the block
|
186
|
+
Or you can set the variant within the block. This allows using unique segmentation logic or variant resolution if you need it.
|
161
187
|
|
162
188
|
```ruby
|
163
|
-
experiment(:
|
189
|
+
experiment(:notification_toggle, user_id: user.id) do |e|
|
164
190
|
e.variant(:no_interface) # set the variant
|
165
191
|
# ...
|
166
192
|
end
|
@@ -168,51 +194,150 @@ end
|
|
168
194
|
|
169
195
|
</details>
|
170
196
|
|
171
|
-
|
197
|
+
### Return value
|
198
|
+
|
199
|
+
By default the return value is a `Gitlab::Experiment` instance. In simple cases you may want only the results of the experiment though. You can call `run` within the block to get the return value of the assigned variant.
|
200
|
+
|
201
|
+
```ruby
|
202
|
+
experiment(:notification_toggle) do |e|
|
203
|
+
e.use { 'A' }
|
204
|
+
e.try { 'B' }
|
205
|
+
e.run
|
206
|
+
end # => 'A'
|
207
|
+
```
|
208
|
+
|
209
|
+
### Including the DSL
|
210
|
+
|
211
|
+
By default, `Gitlab::Experiment` injects itself into the controller and view layers. This exposes the `experiment` method application wide in those layers.
|
212
|
+
|
213
|
+
Some experiments may extend outside of those layers, so you may want to include it elsewhere. For instance in a mailer, service object, background job, or similar.
|
214
|
+
|
215
|
+
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.
|
216
|
+
|
217
|
+
```ruby
|
218
|
+
class UserMailer < ApplicationMailer
|
219
|
+
include Gitlab::Experiment::Dsl # include the `experiment` method
|
220
|
+
|
221
|
+
def welcome
|
222
|
+
@user = params[:user]
|
223
|
+
|
224
|
+
ex = experiment(:project_suggestions, user_id: @user.id) do |e|
|
225
|
+
e.use { 'welcome' }
|
226
|
+
e.try { 'welcome_with_project_suggestions' }
|
227
|
+
end
|
228
|
+
|
229
|
+
mail(to: @user.email, subject: 'Welcome!', template: ex.run)
|
230
|
+
end
|
231
|
+
end
|
232
|
+
```
|
172
233
|
|
173
234
|
### Context migrations
|
174
235
|
|
175
|
-
There are times when we
|
236
|
+
There are times when we need to change context while an experiment is running. We make this possible by passing the migration data to the experiment.
|
237
|
+
|
238
|
+
Take for instance, that you might be using `version: 1` in your context currently. To migrate this `version: 2`, provide the context to change using a `migrated_with` option.
|
176
239
|
|
177
|
-
|
240
|
+
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.
|
178
241
|
|
179
242
|
```ruby
|
180
243
|
experiment(:my_experiment, user_id: 42, version: 2, migrated_with: { version: 1 })
|
181
244
|
```
|
182
245
|
|
183
|
-
|
246
|
+
You can add or remove context by providing a `migrated_from` option. This approach expects a full context replacement -- e.g. what it was before you added or removed the new context key.
|
247
|
+
|
248
|
+
If you wanted to introduce a `version` to your context, provide the full previous context.
|
184
249
|
|
185
250
|
```ruby
|
186
251
|
experiment(:my_experiment, user_id: 42, version: 1, migrated_from: { user_id: 42 })
|
187
252
|
```
|
188
253
|
|
189
|
-
|
254
|
+
This can impact an experience if you haven't:
|
255
|
+
|
256
|
+
1. implemented the concept of migrations in your variant resolver
|
257
|
+
1. haven't enabled a reasonable caching mechanism
|
190
258
|
|
191
259
|
### When there isn't a user (cookies)
|
192
260
|
|
193
|
-
When there isn't
|
261
|
+
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.
|
262
|
+
|
263
|
+
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.
|
194
264
|
|
195
|
-
This is
|
265
|
+
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.
|
196
266
|
|
197
|
-
To read and write cookies, we
|
267
|
+
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.
|
268
|
+
|
269
|
+
You'll need to provide the `request` as an option to the experiment if it's outside of the controller and views.
|
198
270
|
|
199
271
|
```ruby
|
200
|
-
experiment(:
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
```
|
272
|
+
experiment(:my_experiment, user_id: user&.id, request: request)
|
273
|
+
```
|
274
|
+
|
275
|
+
The cookie isn't set if the identifying key isn't present in the context. In this case, if there was no `user_id` key provided, the cookie wouldn't be set.
|
205
276
|
|
206
|
-
|
277
|
+
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 experiment name (prefixed if configured) with `_id` appended.
|
207
278
|
|
208
279
|
## Configuration
|
209
280
|
|
210
|
-
|
281
|
+
This gem needs to be configured before being used in a meaningful way.
|
211
282
|
|
212
|
-
The
|
283
|
+
The default configuration will always render the control. So it's important to configure your own logic for resolving variants.
|
213
284
|
|
214
|
-
|
285
|
+
Yes, the most important aspect of the gem, determining which variant to render and when, is up to you. Consider using [Unleash](https://github.com/Unleash/unleash-client-ruby) or [Flipper](https://github.com/jnunemaker/flipper) for this.
|
286
|
+
|
287
|
+
```ruby
|
288
|
+
Gitlab::Experiment.configure do |config|
|
289
|
+
config.variant_resolver = lambda do |requested_variant|
|
290
|
+
# Return the requested variant if a specific one has been provided in code.
|
291
|
+
return requested_variant unless requested_variant.nil?
|
292
|
+
|
293
|
+
# Ask Unleash to determine the variant, given the context we've built,
|
294
|
+
# using the control as the fallback.
|
295
|
+
fallback = Unleash::Variant.new(name: 'control', enabled: true)
|
296
|
+
UNLEASH.get_variant(name, context.value, fallback)
|
297
|
+
end
|
298
|
+
end
|
299
|
+
```
|
300
|
+
|
301
|
+
More examples for configuration are available in the provided [rails initializer](lib/generators/gitlab_experiment/install/templates/initializer.rb).
|
302
|
+
|
303
|
+
### Client layer / JavaScript
|
304
|
+
|
305
|
+
This library doesn't attempt to provide any logic for the client layer.
|
306
|
+
|
307
|
+
Instead it allows you to do this yourself in configuration. Using Gon to publish your experiment information to the client layer is pretty simple.
|
308
|
+
|
309
|
+
```ruby
|
310
|
+
Gitlab::Experiment.configure do |config|
|
311
|
+
config.publishing_behavior = lambda do |_result|
|
312
|
+
# Push the experiment knowledge into the front end. The signature contains
|
313
|
+
# the context key, and the variant that has been determined.
|
314
|
+
Gon.push(experiment: { name => signature })
|
315
|
+
end
|
316
|
+
end
|
317
|
+
```
|
318
|
+
|
319
|
+
In the client you can now access `window.gon.experiment.notificationToggle`.
|
320
|
+
|
321
|
+
### Caching
|
322
|
+
|
323
|
+
Caching can be enabled in configuration, and is implemented towards the `Rails.cache` / `ActiveSupport::Cache::Store` interface. When you enable caching, any variant resolution will be cached. Migrating the cache through context migrations is handled automatically, and this helps ensure an experiment experience remains consistent.
|
324
|
+
|
325
|
+
```ruby
|
326
|
+
Gitlab::Experiment.configure do |config|
|
327
|
+
config.cache = Rails.cache
|
328
|
+
end
|
329
|
+
```
|
215
330
|
|
216
331
|
## Tracking, anonymity and GDPR
|
217
332
|
|
218
|
-
We
|
333
|
+
We generally try not to track things like user identifying values in our experimentation. What we can and do track is the "experiment experience" (a.k.a the context key).
|
334
|
+
|
335
|
+
We generate this key from the context passed to the experiment. This allows creating funnels without exposing any user information.
|
336
|
+
|
337
|
+
This library attempts to be non user centric, in that a context can contain things like a user, or a project.
|
338
|
+
|
339
|
+
If you only include a user, that user would get the same experience across every project they view. If you only include the project, every user who views that project would get the same experience.
|
340
|
+
|
341
|
+
Each of these approaches could be desirable given the objectives of your experiment.
|
342
|
+
|
343
|
+
### Make code not war
|
@@ -7,6 +7,9 @@ Gitlab::Experiment.configure do |config|
|
|
7
7
|
# The logger is used to log various details of the experiments.
|
8
8
|
config.logger = Logger.new(STDOUT)
|
9
9
|
|
10
|
+
# The caching layer is expected to respond to fetch, like Rails.cache.
|
11
|
+
config.cache = nil
|
12
|
+
|
10
13
|
# Logic this project uses to resolve a variant for a given experiment.
|
11
14
|
#
|
12
15
|
# This can return an instance of any object that responds to `name`, or can
|
@@ -16,53 +19,62 @@ Gitlab::Experiment.configure do |config|
|
|
16
19
|
# This block will be executed within the scope of the experiment instance,
|
17
20
|
# so can easily access experiment methods, like getting the name or context.
|
18
21
|
config.variant_resolver = lambda do |requested_variant|
|
19
|
-
#
|
22
|
+
# Run the control, unless a variant was requested in code:
|
20
23
|
requested_variant || 'control'
|
21
24
|
|
22
|
-
#
|
23
|
-
#
|
25
|
+
# Run the candidate, unless a variant was requested, with a fallback:
|
26
|
+
#
|
27
|
+
# requested_variant || variant_names.first || 'control'
|
24
28
|
|
25
29
|
# Using unleash to determine the variant:
|
26
|
-
#
|
30
|
+
#
|
27
31
|
# fallback = Unleash::Variant.new(name: requested_variant || 'control', enabled: true)
|
28
|
-
#
|
32
|
+
# Unleash.get_variant(name, context.value, fallback)
|
29
33
|
|
30
34
|
# Using Flipper to determine the variant:
|
35
|
+
#
|
31
36
|
# TODO: provide example.
|
32
|
-
# Variant.new(name:
|
37
|
+
# Variant.new(name: requested_variant || 'control')
|
33
38
|
end
|
34
39
|
|
35
40
|
# Tracking behavior can be implemented to link an event to an experiment.
|
36
41
|
#
|
37
|
-
# Similar to the
|
42
|
+
# Similar to the variant_resolver, this is called within the scope of the
|
38
43
|
# experiment instance and so can access any methods on the experiment.
|
39
|
-
|
44
|
+
#
|
45
|
+
#
|
46
|
+
config.tracking_behavior = lambda do |event, args|
|
40
47
|
# An example of using a generic logger to track events:
|
41
|
-
|
42
|
-
config.logger.info "Gitlab::Experiment[#{name}] #{action}: #{event_args}"
|
48
|
+
config.logger.info "Gitlab::Experiment[#{name}] #{event}: #{args.merge(signature: signature)}"
|
43
49
|
|
44
50
|
# Using something like snowplow to track events:
|
45
|
-
# (event_args[:context] ||= []) << SnowplowTracker::SelfDescribingJson.new(
|
46
|
-
# 'iglu:com.gitlab/gitlab_experiment/jsonschema/1-0-0',
|
47
|
-
# experiment: context.signature.merge(group: variant.name)
|
48
|
-
# )
|
49
51
|
#
|
50
|
-
# Tracking.event(name,
|
52
|
+
# Gitlab::Tracking.event(name, event, **args.merge(
|
53
|
+
# context: (args[:context] || []) << SnowplowTracker::SelfDescribingJson.new(
|
54
|
+
# 'iglu:com.gitlab/gitlab_experiment/jsonschema/0-2-0',
|
55
|
+
# signature: signature
|
56
|
+
# )
|
57
|
+
# ))
|
51
58
|
end
|
52
59
|
|
53
60
|
# Called at the end of every experiment run, with the result.
|
54
61
|
#
|
55
62
|
# You may want to track that you've assigned a variant to a given context,
|
56
63
|
# or push the experiment into the client or publish results elsewhere, like
|
57
|
-
# into redis.
|
58
|
-
config.publishing_behavior = lambda do |
|
59
|
-
|
60
|
-
|
61
|
-
# Log the results, so we can inspect them if we wanted to later.
|
62
|
-
# LogRage.log(result: _result)
|
64
|
+
# into redis. Also called within the scope of the experiment instance.
|
65
|
+
config.publishing_behavior = lambda do |result|
|
66
|
+
# Track the event using our own configured tracking logic.
|
67
|
+
track(:assignment)
|
63
68
|
|
64
|
-
# Push the experiment knowledge into the
|
69
|
+
# Push the experiment knowledge into the front end. The signature contains
|
70
|
+
# the context key, and the variant that has been determined.
|
71
|
+
#
|
65
72
|
# Gon.push(experiment: { name => signature })
|
73
|
+
|
74
|
+
# Log using our logging system, so the result (which can be large) can be
|
75
|
+
# reviewed later if we want to.
|
76
|
+
#
|
77
|
+
# Lograge::Event.log(experiment: name, result: result, signature: signature)
|
66
78
|
end
|
67
79
|
|
68
80
|
# Algorithm that consistently generates a hash key for a given hash map.
|
data/lib/gitlab/experiment.rb
CHANGED
@@ -2,6 +2,7 @@
|
|
2
2
|
|
3
3
|
require 'scientist'
|
4
4
|
|
5
|
+
require 'gitlab/experiment/caching'
|
5
6
|
require 'gitlab/experiment/configuration'
|
6
7
|
require 'gitlab/experiment/context'
|
7
8
|
require 'gitlab/experiment/dsl'
|
@@ -12,6 +13,7 @@ require 'gitlab/experiment/engine' if defined?(Rails::Engine)
|
|
12
13
|
module Gitlab
|
13
14
|
class Experiment
|
14
15
|
include Scientist::Experiment
|
16
|
+
include Caching
|
15
17
|
|
16
18
|
class << self
|
17
19
|
def configure
|
@@ -26,7 +28,7 @@ module Gitlab
|
|
26
28
|
end
|
27
29
|
end
|
28
30
|
|
29
|
-
delegate :signature, to: :context
|
31
|
+
delegate :signature, :cache_strategy, to: :context
|
30
32
|
|
31
33
|
def initialize(name, variant_name = nil, **context)
|
32
34
|
@name = name
|
@@ -49,13 +51,14 @@ module Gitlab
|
|
49
51
|
end
|
50
52
|
|
51
53
|
def variant(value = nil)
|
52
|
-
@variant_name = value unless value.nil?
|
54
|
+
return @variant_name = value unless value.nil?
|
55
|
+
|
53
56
|
result = instance_exec(@variant_name, &Configuration.variant_resolver)
|
54
57
|
result.respond_to?(:name) ? result : Variant.new(name: result.to_s)
|
55
58
|
end
|
56
59
|
|
57
60
|
def run
|
58
|
-
@result ||= super(variant.name)
|
61
|
+
@result ||= super(cache { variant.name })
|
59
62
|
end
|
60
63
|
|
61
64
|
def publish(result)
|
@@ -71,13 +74,21 @@ module Gitlab
|
|
71
74
|
end
|
72
75
|
|
73
76
|
def variant_names
|
74
|
-
@variant_names
|
77
|
+
@variant_names ||= behaviors.keys.tap { |keys| keys.delete('control') }.map(&:to_sym)
|
75
78
|
end
|
76
79
|
|
77
80
|
def enabled?
|
78
81
|
true
|
79
82
|
end
|
80
83
|
|
84
|
+
def identifying_key
|
85
|
+
:user_id
|
86
|
+
end
|
87
|
+
|
88
|
+
def cache_key_for(key, migration: false)
|
89
|
+
"#{name}:#{key}"
|
90
|
+
end
|
91
|
+
|
81
92
|
protected
|
82
93
|
|
83
94
|
def generate_result(variant_name)
|
@@ -0,0 +1,24 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Gitlab
|
4
|
+
class Experiment
|
5
|
+
module Caching
|
6
|
+
def cache(&block)
|
7
|
+
return yield unless (cache = Configuration.cache)
|
8
|
+
|
9
|
+
key, migrations = cache_strategy
|
10
|
+
migrated_cache(cache, migrations || [], key) or cache.fetch(key, &block)
|
11
|
+
end
|
12
|
+
|
13
|
+
def migrated_cache(cache, migrations, new_key)
|
14
|
+
migrations.find do |old_key|
|
15
|
+
next unless (value = cache.read(old_key))
|
16
|
+
|
17
|
+
cache.write(new_key, value)
|
18
|
+
cache.delete(old_key)
|
19
|
+
break value
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -15,20 +15,23 @@ module Gitlab
|
|
15
15
|
# The logger is used to log various details of the experiments.
|
16
16
|
@logger = Logger.new(STDOUT)
|
17
17
|
|
18
|
+
# Cache layer. Expected to respond to fetch, like Rails.cache.
|
19
|
+
@cache = nil
|
20
|
+
|
18
21
|
# Logic this project uses to resolve a variant for a given experiment.
|
19
22
|
@variant_resolver = lambda do |requested_variant|
|
20
23
|
requested_variant || 'control'
|
21
24
|
end
|
22
25
|
|
23
26
|
# Tracking behavior can be implemented to link an event to an experiment.
|
24
|
-
@tracking_behavior = lambda do |
|
25
|
-
Configuration.logger.info "Gitlab::Experiment[#{name}] #{
|
27
|
+
@tracking_behavior = lambda do |event, args|
|
28
|
+
Configuration.logger.info "Gitlab::Experiment[#{name}] #{event}: #{args.merge(signature: signature)}"
|
26
29
|
end
|
27
30
|
|
28
31
|
# Called at the end of every experiment run, with the results. You may
|
29
32
|
# want to push the experiment into the client or push results elsewhere.
|
30
33
|
@publishing_behavior = lambda do |_result|
|
31
|
-
track(:assignment)
|
34
|
+
track(:assignment)
|
32
35
|
end
|
33
36
|
|
34
37
|
# Algorithm that consistently generates a hash key for a given hash map.
|
@@ -38,7 +41,7 @@ module Gitlab
|
|
38
41
|
end
|
39
42
|
|
40
43
|
class << self
|
41
|
-
attr_accessor :name_prefix, :logger
|
44
|
+
attr_accessor :name_prefix, :logger, :cache
|
42
45
|
attr_accessor :variant_resolver, :tracking_behavior, :publishing_behavior, :context_hash_strategy
|
43
46
|
end
|
44
47
|
end
|
@@ -16,18 +16,27 @@ module Gitlab
|
|
16
16
|
return @value if value.nil?
|
17
17
|
|
18
18
|
value = value.dup # dup so we don't mutate
|
19
|
-
@signature = nil # clear
|
19
|
+
@signature = @cache_strategy = nil # clear memoization
|
20
20
|
|
21
21
|
@migrations_from << value.delete(:migrated_from) if value[:migrated_from]
|
22
22
|
@migrations_with << value.delete(:migrated_with) if value[:migrated_with]
|
23
|
-
@value.merge!(
|
23
|
+
@value.merge!(auto_migrate_cookie(value, value.delete(:request)))
|
24
24
|
end
|
25
25
|
|
26
26
|
def freeze
|
27
|
-
|
27
|
+
cache_strategy # ensure we memoize before freezing
|
28
28
|
super
|
29
29
|
end
|
30
30
|
|
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
|
+
]
|
38
|
+
end
|
39
|
+
|
31
40
|
def signature
|
32
41
|
@signature ||= {
|
33
42
|
key: key_for(@value),
|
@@ -38,16 +47,18 @@ module Gitlab
|
|
38
47
|
|
39
48
|
private
|
40
49
|
|
41
|
-
def
|
42
|
-
return hash unless (
|
43
|
-
return hash unless request.respond_to?(:headers) && request.respond_to?(:cookie_jar)
|
50
|
+
def auto_migrate_cookie(hash, request)
|
51
|
+
return hash unless request&.respond_to?(:headers) && request&.respond_to?(:cookie_jar)
|
44
52
|
return hash if request.headers['DNT'].to_s.match?(DNT_REGEXP)
|
45
53
|
|
46
|
-
|
47
|
-
resolver = [jar, hash, :user_id, jar.signed[cookie_name]].compact
|
54
|
+
resolver = cookie_resolver(request.cookie_jar, hash)
|
48
55
|
resolve_cookie(*resolver) or generate_cookie(*resolver)
|
49
56
|
end
|
50
57
|
|
58
|
+
def cookie_resolver(jar, hash)
|
59
|
+
[jar, hash, @experiment.identifying_key, jar.signed[cookie_name]].compact
|
60
|
+
end
|
61
|
+
|
51
62
|
def cookie_name
|
52
63
|
@cookie_name ||= [@experiment.name, 'id'].join('_')
|
53
64
|
end
|
@@ -56,13 +67,15 @@ module Gitlab
|
|
56
67
|
return if cookie.blank? && hash[key].blank?
|
57
68
|
return hash.merge(key => cookie) if hash[key].blank?
|
58
69
|
|
59
|
-
@migrations_with << {
|
70
|
+
@migrations_with << { key => cookie }
|
60
71
|
jar.delete(cookie_name, domain: :all)
|
61
72
|
|
62
73
|
hash
|
63
74
|
end
|
64
75
|
|
65
76
|
def generate_cookie(jar, hash, key, cookie = SecureRandom.uuid)
|
77
|
+
return hash unless hash.key?(key)
|
78
|
+
|
66
79
|
jar.permanent.signed[cookie_name] = {
|
67
80
|
value: cookie, secure: true, domain: :all, httponly: true
|
68
81
|
}
|
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
|
+
version: 0.2.3
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- GitLab
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2020-09-
|
11
|
+
date: 2020-09-27 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: scientist
|
@@ -43,6 +43,7 @@ files:
|
|
43
43
|
- lib/generators/gitlab_experiment/install/install_generator.rb
|
44
44
|
- lib/generators/gitlab_experiment/install/templates/initializer.rb
|
45
45
|
- lib/gitlab/experiment.rb
|
46
|
+
- lib/gitlab/experiment/caching.rb
|
46
47
|
- lib/gitlab/experiment/configuration.rb
|
47
48
|
- lib/gitlab/experiment/context.rb
|
48
49
|
- lib/gitlab/experiment/dsl.rb
|