gitlab-experiment 0.2.0 → 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 +250 -78
- data/lib/generators/gitlab_experiment/install/POST_INSTALL +0 -0
- data/lib/generators/gitlab_experiment/install/install_generator.rb +21 -0
- data/lib/generators/gitlab_experiment/install/templates/initializer.rb +87 -0
- data/lib/gitlab/experiment.rb +35 -11
- data/lib/gitlab/experiment/caching.rb +33 -0
- data/lib/gitlab/experiment/configuration.rb +15 -28
- data/lib/gitlab/experiment/context.rb +34 -32
- data/lib/gitlab/experiment/cookies.rb +44 -0
- data/lib/gitlab/experiment/dsl.rb +1 -0
- data/lib/gitlab/experiment/engine.rb +16 -0
- data/lib/gitlab/experiment/version.rb +1 -1
- metadata +15 -9
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
@@ -1,182 +1,354 @@
|
|
1
|
-
# Experiment
|
1
|
+
# GitLab Experiment
|
2
2
|
|
3
|
-
|
3
|
+
<img alt="experiment" src="/uploads/60990b2dbf4c0406bbf8b7f998de2dea/experiment.png" align="right" width="40%">
|
4
4
|
|
5
|
-
|
5
|
+
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.
|
6
6
|
|
7
|
-
|
7
|
+
This library provides a clean and elegant DSL to define, run, and track your GitLab experiment.
|
8
8
|
|
9
|
-
|
9
|
+
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.
|
10
10
|
|
11
|
-
|
11
|
+
- `experiment` is any deviation of code paths we want to run sometimes and not others.
|
12
|
+
- `context` is used to identify a consistent experience we'll provide in an experiment.
|
13
|
+
- `control` is the default, or "original" code path.
|
14
|
+
- `candidate` defines that there's one experimental code path.
|
15
|
+
- `variant(s)` is used when more than one experimental code path exists.
|
12
16
|
|
13
|
-
|
17
|
+
Candidate and variant are the same concept, but simplify how we speak about experimental paths.<br clear="all">
|
18
|
+
|
19
|
+
## Installation
|
20
|
+
|
21
|
+
Add the gem to your Gemfile and then `bundle install`.
|
22
|
+
|
23
|
+
```ruby
|
24
|
+
gem 'gitlab-experiment'
|
25
|
+
```
|
26
|
+
|
27
|
+
If you're using Rails, you can install the initializer. It provides basic configuration and documentation.
|
28
|
+
|
29
|
+
```shell
|
30
|
+
$ rails generate gitlab-experiment:install
|
31
|
+
```
|
14
32
|
|
15
33
|
## Implementing an experiment
|
16
34
|
|
17
|
-
For the sake of
|
35
|
+
For the sake of an example let's make one up. Let's run an experiment on what we render for disabling desktop notifications.
|
36
|
+
|
37
|
+
In our control (current world) we show a simple toggle interface labeled, "Notifications." In our experiment we want a "Turn on/off desktop notifications" button with a confirmation.
|
18
38
|
|
19
|
-
|
39
|
+
The behavior will be the same, but the interface will be different and may involve more or fewer steps.
|
20
40
|
|
21
|
-
|
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.
|
22
42
|
|
23
|
-
|
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.
|
24
44
|
|
25
|
-
|
45
|
+
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.
|
26
46
|
|
27
|
-
|
47
|
+
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.
|
28
48
|
|
29
|
-
|
49
|
+
Now in our experiment we're going to render one of two views: the control will be our current view, and the candidate will be the new toggle button with a confirmation flow.
|
30
50
|
|
31
51
|
```ruby
|
32
52
|
class SubscriptionsController < ApplicationController
|
33
|
-
include Gitlab::GrowthExperiment::Interface
|
34
|
-
|
35
53
|
def show
|
36
|
-
experiment(:
|
37
|
-
e.use {
|
38
|
-
e.try {
|
54
|
+
experiment(:notification_toggle, actor: user) do |e|
|
55
|
+
e.use { render_toggle } # control
|
56
|
+
e.try { render_button } # candidate
|
39
57
|
end
|
40
58
|
end
|
41
59
|
end
|
42
60
|
```
|
43
61
|
|
44
|
-
You can
|
62
|
+
You can define the experiment using simple control/candidate paths, or provide named variants.
|
63
|
+
|
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.
|
45
65
|
|
46
66
|
```ruby
|
47
|
-
experiment(:
|
48
|
-
e.use {
|
49
|
-
e.try(:variant_one) {
|
50
|
-
e.try(:variant_two) {
|
67
|
+
experiment(:notification_toggle, actor: user) do |e|
|
68
|
+
e.use { render_toggle } # control
|
69
|
+
e.try(:variant_one) { render_button(confirmation: true) }
|
70
|
+
e.try(:variant_two) { render_button(confirmation: false) }
|
51
71
|
end
|
52
72
|
```
|
53
73
|
|
54
|
-
|
74
|
+
Understanding how an experiment can change behavior is important in evaluating its performance.
|
75
|
+
|
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.
|
55
77
|
|
56
78
|
```ruby
|
57
|
-
|
58
|
-
exp.track('clicked_button')
|
79
|
+
experiment(:notification_toggle, actor: user).track(:clicked_button)
|
59
80
|
```
|
60
81
|
|
61
82
|
<details>
|
62
|
-
<summary>You can use the more low level class or instance interfaces...</summary>
|
83
|
+
<summary>You can also use the more low level class or instance interfaces...</summary>
|
63
84
|
|
64
85
|
### Class level interface using `.run`
|
65
86
|
|
66
87
|
```ruby
|
67
|
-
exp = Gitlab::
|
68
|
-
#
|
69
|
-
#
|
70
|
-
e.context(
|
71
|
-
|
72
|
-
|
73
|
-
e.
|
88
|
+
exp = Gitlab::Experiment.run(:notification_toggle, actor: user) do |e|
|
89
|
+
# Context may be passed in the block, but must be finalized before calling
|
90
|
+
# run or track.
|
91
|
+
e.context(project: project) # add the project to the context
|
92
|
+
|
93
|
+
# Define the control and candidate variant.
|
94
|
+
e.use { render_toggle } # control
|
95
|
+
e.try { render_button } # candidate
|
74
96
|
end
|
75
97
|
|
76
|
-
#
|
98
|
+
# Track an event on the experiment we've defined.
|
77
99
|
exp.track(:clicked_button)
|
78
100
|
```
|
79
101
|
|
80
|
-
While `Gitlab::GrowthExperiment.run` is what we document, you can also use `Gitlab::GrowthExperiment.experiment`.
|
81
|
-
|
82
102
|
### Instance level interface
|
83
103
|
|
84
104
|
```ruby
|
85
|
-
exp = Gitlab::
|
86
|
-
# context
|
87
|
-
#
|
88
|
-
exp.context(
|
89
|
-
|
90
|
-
|
105
|
+
exp = Gitlab::Experiment.new(:notification_toggle, actor: user)
|
106
|
+
# Additional context may be provided to the instance (exp) but must be
|
107
|
+
# finalized before calling run or track.
|
108
|
+
exp.context(project: project) # add the project id to the context
|
109
|
+
|
110
|
+
# Define the control and candidate variant.
|
111
|
+
exp.use { render_toggle } # control
|
112
|
+
exp.try { render_button } # candidate
|
113
|
+
|
114
|
+
# Run the experiment, returning the result.
|
91
115
|
exp.run
|
92
116
|
|
93
|
-
#
|
117
|
+
# Track an event on the experiment we've defined.
|
94
118
|
exp.track(:clicked_button)
|
95
119
|
```
|
96
120
|
|
97
121
|
</details>
|
98
122
|
|
99
123
|
<details>
|
100
|
-
<summary>You can define use custom classes...</summary>
|
124
|
+
<summary>You can define and use custom classes...</summary>
|
101
125
|
|
102
126
|
### Custom class
|
103
127
|
|
104
128
|
```ruby
|
105
|
-
class
|
129
|
+
class NotificationExperiment < Gitlab::Experiment
|
106
130
|
def initialize(variant_name = nil, **context, &block)
|
107
|
-
super(:
|
131
|
+
super(:notification_toggle, variant_name, **context, &block)
|
132
|
+
|
133
|
+
# Define the control and candidate variant.
|
134
|
+
use { render_toggle } # control
|
135
|
+
try { render_button } # candidate
|
108
136
|
end
|
109
137
|
end
|
110
138
|
|
111
|
-
exp =
|
112
|
-
#
|
113
|
-
#
|
114
|
-
e.context(
|
115
|
-
|
116
|
-
e.use { toggle_button_interface } # control
|
117
|
-
e.try { cancel_button_interface } # candidate
|
139
|
+
exp = NotificationExperiment.new(actor: user) do |e|
|
140
|
+
# Context may be provided within the block or to the instance (exp) but must
|
141
|
+
# be finalized before calling run or track.
|
142
|
+
e.context(project: project) # add the project id to the context
|
118
143
|
end
|
119
144
|
|
120
|
-
#
|
145
|
+
# Run the experiment -- returning the result.
|
146
|
+
exp.run
|
147
|
+
|
148
|
+
# Track an event on the experiment we've defined.
|
121
149
|
exp.track(:clicked_button)
|
122
150
|
```
|
123
151
|
|
124
152
|
</details>
|
125
153
|
|
126
154
|
<details>
|
127
|
-
<summary>You can
|
155
|
+
<summary>You can also specify the variant to use...</summary>
|
128
156
|
|
129
|
-
### Specifying
|
157
|
+
### Specifying variant
|
130
158
|
|
131
|
-
|
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.
|
132
160
|
|
133
161
|
```ruby
|
134
|
-
experiment(:
|
135
|
-
e.use {
|
136
|
-
e.try {
|
162
|
+
experiment(:notification_toggle, :no_interface, actor: user) do |e|
|
163
|
+
e.use { render_toggle } # control
|
164
|
+
e.try { render_button } # candidate
|
137
165
|
e.try(:no_interface) { no_interface! } # variant
|
138
166
|
end
|
139
167
|
```
|
140
168
|
|
141
|
-
Or you can set the variant within the block.
|
169
|
+
Or you can set the variant within the block. This allows using unique segmentation logic or variant resolution if you need it.
|
142
170
|
|
143
171
|
```ruby
|
144
|
-
experiment(:
|
145
|
-
|
172
|
+
experiment(:notification_toggle, actor: user) do |e|
|
173
|
+
# Variant selection must be done before calling run or track.
|
174
|
+
e.variant(:no_interface) # set the variant
|
146
175
|
# ...
|
147
176
|
end
|
148
177
|
```
|
149
178
|
|
179
|
+
Or it can be specified in the call to run if you call it from within the block.
|
180
|
+
|
181
|
+
```ruby
|
182
|
+
experiment(:notification_toggle, actor: user) do |e|
|
183
|
+
# ...
|
184
|
+
# Variant selection can be specified when calling run.
|
185
|
+
e.run(:no_interface)
|
186
|
+
end
|
187
|
+
```
|
188
|
+
|
150
189
|
</details>
|
151
190
|
|
152
|
-
|
191
|
+
### Return value
|
192
|
+
|
193
|
+
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.
|
194
|
+
|
195
|
+
```ruby
|
196
|
+
experiment(:notification_toggle) do |e|
|
197
|
+
e.use { 'A' }
|
198
|
+
e.try { 'B' }
|
199
|
+
e.run
|
200
|
+
end # => 'A'
|
201
|
+
```
|
202
|
+
|
203
|
+
### Including the DSL
|
204
|
+
|
205
|
+
By default, `Gitlab::Experiment` injects itself into the controller and view layers. This exposes the `experiment` method application wide in those layers.
|
206
|
+
|
207
|
+
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.
|
208
|
+
|
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
|
+
|
211
|
+
```ruby
|
212
|
+
class WelcomeMailer < ApplicationMailer
|
213
|
+
include Gitlab::Experiment::Dsl # include the `experiment` method
|
214
|
+
|
215
|
+
def welcome
|
216
|
+
@user = params[:user]
|
217
|
+
|
218
|
+
ex = experiment(:project_suggestions, actor: @user) do |e|
|
219
|
+
e.use { 'welcome' }
|
220
|
+
e.try { 'welcome_with_project_suggestions' }
|
221
|
+
end
|
222
|
+
|
223
|
+
mail(to: @user.email, subject: 'Welcome!', template: ex.run)
|
224
|
+
end
|
225
|
+
end
|
226
|
+
```
|
153
227
|
|
154
228
|
### Context migrations
|
155
229
|
|
156
|
-
There are times when we
|
230
|
+
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.
|
231
|
+
|
232
|
+
Take for instance, that you might be using `version: 1` in your context currently. To migrate this to `version: 2`, provide the portion of the context you wish to change using a `migrated_with` option.
|
233
|
+
|
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.
|
157
235
|
|
158
|
-
|
236
|
+
```ruby
|
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
|
+
```
|
240
|
+
|
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.
|
242
|
+
|
243
|
+
If you wanted to introduce a `version` to your context, provide the full previous context.
|
159
244
|
|
160
245
|
```ruby
|
161
|
-
|
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 })
|
162
248
|
```
|
163
249
|
|
164
|
-
|
250
|
+
This can impact an experience if you:
|
251
|
+
|
252
|
+
1. haven't implemented the concept of migrations in your variant resolver
|
253
|
+
1. haven't enabled a reasonable caching mechanism
|
254
|
+
|
255
|
+
### When there isn't an actor (cookie fallback)
|
165
256
|
|
166
|
-
|
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.
|
167
258
|
|
168
|
-
|
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
|
+
|
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
|
+
|
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
|
+
|
265
|
+
You'll need to provide the `request` as an option to the experiment if it's outside of the controller and views.
|
266
|
+
|
267
|
+
```ruby
|
268
|
+
experiment(:my_experiment, actor: user, request: request)
|
269
|
+
```
|
169
270
|
|
170
|
-
|
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
|
+
|
273
|
+
```ruby
|
274
|
+
# actor is not present, so no cookie is set
|
275
|
+
experiment(:my_experiment, project: project)
|
276
|
+
|
277
|
+
# actor is present and is nil, so the cookie is set and used
|
278
|
+
experiment(:my_experiment, actor: nil, project: project)
|
279
|
+
|
280
|
+
# actor is present and set to a value, so no cookie is set
|
281
|
+
experiment(:my_experiment, actor: user, project: project)
|
282
|
+
```
|
283
|
+
|
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
|
+
|
286
|
+
## Configuration
|
287
|
+
|
288
|
+
This gem needs to be configured before being used in a meaningful way.
|
289
|
+
|
290
|
+
The default configuration will always render the control, so it's important to configure your own logic for resolving variants.
|
291
|
+
|
292
|
+
Yes, the most important aspect of the gem -- that of 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.
|
293
|
+
|
294
|
+
```ruby
|
295
|
+
Gitlab::Experiment.configure do |config|
|
296
|
+
# The block here is evaluated within the scope of the experiment instance,
|
297
|
+
# which is why we are able to access things like name and context.
|
298
|
+
config.variant_resolver = lambda do |requested_variant|
|
299
|
+
# Return the requested variant if a specific one has been provided in code.
|
300
|
+
return requested_variant unless requested_variant.nil?
|
301
|
+
|
302
|
+
# Ask Unleash to determine the variant, given the context we've built,
|
303
|
+
# using the control as the fallback.
|
304
|
+
fallback = Unleash::Variant.new(name: 'control', enabled: true)
|
305
|
+
UNLEASH.get_variant(name, context.value, fallback)
|
306
|
+
end
|
307
|
+
end
|
308
|
+
```
|
309
|
+
|
310
|
+
More examples for configuration are available in the provided [rails initializer](lib/generators/gitlab_experiment/install/templates/initializer.rb).
|
311
|
+
|
312
|
+
### Client layer / JavaScript
|
313
|
+
|
314
|
+
This library doesn't attempt to provide any logic for the client layer.
|
315
|
+
|
316
|
+
Instead it allows you to do this yourself in configuration. Using [Gon](https://github.com/gazay/gon) to publish your experiment information to the client layer is pretty simple.
|
317
|
+
|
318
|
+
```ruby
|
319
|
+
Gitlab::Experiment.configure do |config|
|
320
|
+
config.publishing_behavior = lambda do |_result|
|
321
|
+
# Push the experiment knowledge into the front end. The signature contains
|
322
|
+
# the context key, and the variant that has been determined.
|
323
|
+
Gon.push({ experiment: { name => signature } }, true)
|
324
|
+
end
|
325
|
+
end
|
326
|
+
```
|
327
|
+
|
328
|
+
In the client you can now access `window.gon.experiment.notificationToggle`.
|
329
|
+
|
330
|
+
### Caching
|
331
|
+
|
332
|
+
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.
|
333
|
+
|
334
|
+
It's important to understand that using caching can drastically change or override your rollout strategy logic.
|
335
|
+
|
336
|
+
```ruby
|
337
|
+
Gitlab::Experiment.configure do |config|
|
338
|
+
config.cache = Rails.cache
|
339
|
+
end
|
340
|
+
```
|
171
341
|
|
172
342
|
## Tracking, anonymity and GDPR
|
173
343
|
|
174
|
-
We
|
344
|
+
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).
|
345
|
+
|
346
|
+
We generate this key from the context passed to the experiment. This allows creating funnels without exposing any user information.
|
175
347
|
|
176
|
-
|
348
|
+
This library attempts to be non-user-centric, in that a context can contain things like a user or a project.
|
177
349
|
|
178
|
-
|
350
|
+
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.
|
179
351
|
|
180
|
-
|
352
|
+
Each of these approaches could be desirable given the objectives of your experiment.
|
181
353
|
|
182
|
-
|
354
|
+
### Make code not war
|
File without changes
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'rails/generators'
|
4
|
+
|
5
|
+
module GitlabExperiment
|
6
|
+
module Generators
|
7
|
+
class InstallGenerator < Rails::Generators::Base
|
8
|
+
source_root File.expand_path(__dir__)
|
9
|
+
|
10
|
+
desc 'Installs the Gitlab Experiment initializer into your application.'
|
11
|
+
|
12
|
+
def copy_initializers
|
13
|
+
copy_file 'templates/initializer.rb', 'config/initializers/gitlab_experiment.rb'
|
14
|
+
end
|
15
|
+
|
16
|
+
def display_post_install
|
17
|
+
readme 'POST_INSTALL' if behavior == :invoke
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,87 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
Gitlab::Experiment.configure do |config|
|
4
|
+
# Prefix all experiment names with a given value. Use `nil` for none.
|
5
|
+
config.name_prefix = nil
|
6
|
+
|
7
|
+
# The logger is used to log various details of the experiments.
|
8
|
+
config.logger = Logger.new(STDOUT)
|
9
|
+
|
10
|
+
# The caching layer is expected to respond to fetch, like Rails.cache.
|
11
|
+
config.cache = nil
|
12
|
+
|
13
|
+
# Logic this project uses to resolve a variant for a given experiment.
|
14
|
+
#
|
15
|
+
# This can return an instance of any object that responds to `name`, or can
|
16
|
+
# return a variant name as a string, in which case the build in variant
|
17
|
+
# class will be used.
|
18
|
+
#
|
19
|
+
# This block will be executed within the scope of the experiment instance,
|
20
|
+
# so can easily access experiment methods, like getting the name or context.
|
21
|
+
config.variant_resolver = lambda do |requested_variant|
|
22
|
+
# Run the control, unless a variant was requested in code:
|
23
|
+
requested_variant || 'control'
|
24
|
+
|
25
|
+
# Run the candidate, unless a variant was requested, with a fallback:
|
26
|
+
#
|
27
|
+
# requested_variant || variant_names.first || 'control'
|
28
|
+
|
29
|
+
# Using Unleash to determine the variant:
|
30
|
+
#
|
31
|
+
# fallback = Unleash::Variant.new(name: requested_variant || 'control', enabled: true)
|
32
|
+
# Unleash.get_variant(name, context.value, fallback)
|
33
|
+
|
34
|
+
# Using Flipper to determine the variant:
|
35
|
+
#
|
36
|
+
# TODO: provide example.
|
37
|
+
# Variant.new(name: requested_variant || 'control')
|
38
|
+
end
|
39
|
+
|
40
|
+
# Tracking behavior can be implemented to link an event to an experiment.
|
41
|
+
#
|
42
|
+
# Similar to the variant_resolver, this is called within the scope of the
|
43
|
+
# experiment instance and so can access any methods on the experiment,
|
44
|
+
# such as name and signature.
|
45
|
+
config.tracking_behavior = lambda do |event, args|
|
46
|
+
# An example of using a generic logger to track events:
|
47
|
+
config.logger.info "Gitlab::Experiment[#{name}] #{event}: #{args.merge(signature: signature)}"
|
48
|
+
|
49
|
+
# Using something like snowplow to track events (in gitlab):
|
50
|
+
#
|
51
|
+
# Gitlab::Tracking.event(name, event, **args.merge(
|
52
|
+
# context: (args[:context] || []) << SnowplowTracker::SelfDescribingJson.new(
|
53
|
+
# 'iglu:com.gitlab/gitlab_experiment/jsonschema/0-2-0', signature
|
54
|
+
# )
|
55
|
+
# ))
|
56
|
+
end
|
57
|
+
|
58
|
+
# Called at the end of every experiment run, with the result.
|
59
|
+
#
|
60
|
+
# You may want to track that you've assigned a variant to a given context,
|
61
|
+
# or push the experiment into the client or publish results elsewhere, like
|
62
|
+
# into redis. Also called within the scope of the experiment instance.
|
63
|
+
config.publishing_behavior = lambda do |result|
|
64
|
+
# Track the event using our own configured tracking logic.
|
65
|
+
track(:assignment)
|
66
|
+
|
67
|
+
# Push the experiment knowledge into the front end. The signature contains
|
68
|
+
# the context key, and the variant that has been determined.
|
69
|
+
#
|
70
|
+
# Gon.push({ experiment: { name => signature } }, true)
|
71
|
+
|
72
|
+
# Log using our logging system, so the result (which can be large) can be
|
73
|
+
# reviewed later if we want to.
|
74
|
+
#
|
75
|
+
# Lograge::Event.log(experiment: name, result: result, signature: signature)
|
76
|
+
end
|
77
|
+
|
78
|
+
# Algorithm that consistently generates a hash key for a given hash map.
|
79
|
+
#
|
80
|
+
# Given a specific context hash map, we need to generate a consistent hash
|
81
|
+
# key. The logic in here will be used for generating cache keys, and may also
|
82
|
+
# be used when determining which variant may be presented.
|
83
|
+
config.context_hash_strategy = lambda do |context|
|
84
|
+
values = context.values.map { |v| (v.respond_to?(:to_global_id) ? v.to_global_id : v).to_s }
|
85
|
+
Digest::MD5.hexdigest((context.keys + values).join('|'))
|
86
|
+
end
|
87
|
+
end
|
data/lib/gitlab/experiment.rb
CHANGED
@@ -2,15 +2,19 @@
|
|
2
2
|
|
3
3
|
require 'scientist'
|
4
4
|
|
5
|
+
require 'gitlab/experiment/caching'
|
5
6
|
require 'gitlab/experiment/configuration'
|
7
|
+
require 'gitlab/experiment/cookies'
|
6
8
|
require 'gitlab/experiment/context'
|
7
9
|
require 'gitlab/experiment/dsl'
|
8
10
|
require 'gitlab/experiment/variant'
|
9
11
|
require 'gitlab/experiment/version'
|
12
|
+
require 'gitlab/experiment/engine' if defined?(Rails::Engine)
|
10
13
|
|
11
14
|
module Gitlab
|
12
15
|
class Experiment
|
13
16
|
include Scientist::Experiment
|
17
|
+
include Caching
|
14
18
|
|
15
19
|
class << self
|
16
20
|
def configure
|
@@ -28,11 +32,10 @@ module Gitlab
|
|
28
32
|
def initialize(name, variant_name = nil, **context)
|
29
33
|
@name = name
|
30
34
|
@variant_name = variant_name
|
31
|
-
@
|
35
|
+
@excluded = []
|
36
|
+
@context = Context.new(self, context)
|
32
37
|
|
33
|
-
context
|
34
|
-
|
35
|
-
ignore { true }
|
38
|
+
exclude { !@context.trackable? }
|
36
39
|
compare { false }
|
37
40
|
|
38
41
|
yield self if block_given?
|
@@ -46,19 +49,32 @@ module Gitlab
|
|
46
49
|
end
|
47
50
|
|
48
51
|
def variant(value = nil)
|
49
|
-
@variant_name = value unless value.nil?
|
50
|
-
|
52
|
+
return @variant_name = value unless value.nil?
|
53
|
+
|
54
|
+
result = instance_exec(@variant_name, &Configuration.variant_resolver)
|
55
|
+
result.respond_to?(:name) ? result : Variant.new(name: result.to_s)
|
56
|
+
end
|
57
|
+
|
58
|
+
def exclude(&block)
|
59
|
+
@excluded << block
|
51
60
|
end
|
52
61
|
|
53
|
-
def run
|
54
|
-
@result ||=
|
62
|
+
def run(variant_name = nil)
|
63
|
+
@result ||= begin
|
64
|
+
@variant_name = variant_name unless variant_name.nil?
|
65
|
+
@variant_name ||= :control if excluded?
|
66
|
+
|
67
|
+
super(cache { variant.name })
|
68
|
+
end
|
55
69
|
end
|
56
70
|
|
57
|
-
def publish(
|
58
|
-
|
71
|
+
def publish(result)
|
72
|
+
instance_exec(result, &Configuration.publishing_behavior)
|
59
73
|
end
|
60
74
|
|
61
75
|
def track(action, **event_args)
|
76
|
+
return if excluded?
|
77
|
+
|
62
78
|
instance_exec(action, event_args, &Configuration.tracking_behavior)
|
63
79
|
end
|
64
80
|
|
@@ -67,13 +83,21 @@ module Gitlab
|
|
67
83
|
end
|
68
84
|
|
69
85
|
def variant_names
|
70
|
-
@variant_names
|
86
|
+
@variant_names ||= behaviors.keys.map(&:to_sym) - [:control]
|
87
|
+
end
|
88
|
+
|
89
|
+
def signature
|
90
|
+
{ variant: variant.name, experiment: name }.merge(context.signature)
|
71
91
|
end
|
72
92
|
|
73
93
|
def enabled?
|
74
94
|
true
|
75
95
|
end
|
76
96
|
|
97
|
+
def excluded?
|
98
|
+
@excluded.any? { |exclude| exclude.call(self) }
|
99
|
+
end
|
100
|
+
|
77
101
|
protected
|
78
102
|
|
79
103
|
def generate_result(variant_name)
|
@@ -0,0 +1,33 @@
|
|
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
|
+
private
|
14
|
+
|
15
|
+
def cache_strategy
|
16
|
+
[
|
17
|
+
"#{name}:#{signature[:key]}",
|
18
|
+
signature[:migration_keys]&.map { |key| "#{name}:#{key}" }
|
19
|
+
]
|
20
|
+
end
|
21
|
+
|
22
|
+
def migrated_cache(cache, migrations, new_key)
|
23
|
+
migrations.find do |old_key|
|
24
|
+
next unless (value = cache.read(old_key))
|
25
|
+
|
26
|
+
cache.write(new_key, value)
|
27
|
+
cache.delete(old_key)
|
28
|
+
break value
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -10,41 +10,28 @@ module Gitlab
|
|
10
10
|
include Singleton
|
11
11
|
|
12
12
|
# Prefix all experiment names with a given value. Use `nil` for none.
|
13
|
-
@name_prefix =
|
13
|
+
@name_prefix = nil
|
14
14
|
|
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
|
-
|
21
|
-
Variant.new(name: requested_variant || 'control')
|
22
|
-
|
23
|
-
# Always run candidate, unless a variant was requested, with fallback:
|
24
|
-
# Variant.new(name: variant_name || variant_names.first || 'control')
|
25
|
-
|
26
|
-
# Using unleash to determine the variant:
|
27
|
-
# fallback = Unleash::Variant.new(name: 'control', enabled: true)
|
28
|
-
# Unleash.get_variant(name, context.value, fallback)
|
29
|
-
|
30
|
-
# Using Flipper to determine the variant:
|
31
|
-
# TODO: provide example
|
32
|
-
# Variant.new(name: resolved)
|
23
|
+
requested_variant || 'control'
|
33
24
|
end
|
34
25
|
|
35
26
|
# Tracking behavior can be implemented to link an event to an experiment.
|
36
|
-
@tracking_behavior = lambda do |
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
# experiment: context.signature.merge(group: variant.name)
|
45
|
-
# )
|
46
|
-
#
|
47
|
-
# Tracking.event(name, action, **event_args)
|
27
|
+
@tracking_behavior = lambda do |event, args|
|
28
|
+
Configuration.logger.info "Gitlab::Experiment[#{name}] #{event}: #{args.merge(signature: signature)}"
|
29
|
+
end
|
30
|
+
|
31
|
+
# Called at the end of every experiment run, with the results. You may
|
32
|
+
# want to push the experiment into the client or push results elsewhere.
|
33
|
+
@publishing_behavior = lambda do |_result|
|
34
|
+
track(:assignment)
|
48
35
|
end
|
49
36
|
|
50
37
|
# Algorithm that consistently generates a hash key for a given hash map.
|
@@ -54,8 +41,8 @@ module Gitlab
|
|
54
41
|
end
|
55
42
|
|
56
43
|
class << self
|
57
|
-
attr_accessor :name_prefix, :logger
|
58
|
-
attr_accessor :variant_resolver, :tracking_behavior, :context_hash_strategy
|
44
|
+
attr_accessor :name_prefix, :logger, :cache
|
45
|
+
attr_accessor :variant_resolver, :tracking_behavior, :publishing_behavior, :context_hash_strategy
|
59
46
|
end
|
60
47
|
end
|
61
48
|
end
|
@@ -3,26 +3,38 @@
|
|
3
3
|
module Gitlab
|
4
4
|
class Experiment
|
5
5
|
class Context
|
6
|
-
|
6
|
+
include Cookies
|
7
|
+
|
8
|
+
DNT_REGEXP = /^(true|t|yes|y|1|on)$/i.freeze
|
9
|
+
|
10
|
+
def initialize(experiment, **initial_value)
|
7
11
|
@experiment = experiment
|
8
12
|
@value = {}
|
9
|
-
@
|
10
|
-
|
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)
|
11
21
|
end
|
12
22
|
|
13
23
|
def value(value = nil)
|
14
24
|
return @value if value.nil?
|
15
25
|
|
16
26
|
value = value.dup # dup so we don't mutate
|
17
|
-
|
27
|
+
reinitialize(value.delete(:request))
|
18
28
|
|
19
|
-
@
|
20
|
-
|
21
|
-
|
29
|
+
@value.merge!(process_migrations(value))
|
30
|
+
end
|
31
|
+
|
32
|
+
def trackable?
|
33
|
+
!(@request && @request.headers['DNT'].to_s.match?(DNT_REGEXP))
|
22
34
|
end
|
23
35
|
|
24
36
|
def freeze
|
25
|
-
signature #
|
37
|
+
signature # finalize before freezing
|
26
38
|
super
|
27
39
|
end
|
28
40
|
|
@@ -32,34 +44,24 @@ module Gitlab
|
|
32
44
|
|
33
45
|
private
|
34
46
|
|
35
|
-
def
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
elsif cookie_value # we know via the cookie
|
47
|
-
if value[:user_id].blank?
|
48
|
-
value[:user_id] = cookie_value
|
49
|
-
else
|
50
|
-
@migrations_with << { user_id: cookie_value }
|
51
|
-
request.cookie_jar.delete(cookie_name, domain: :all)
|
52
|
-
end
|
53
|
-
end
|
54
|
-
|
55
|
-
value
|
47
|
+
def process_migrations(value)
|
48
|
+
add_migration(value.delete(:migrated_from))
|
49
|
+
add_migration(value.delete(:migrated_with), merge: true)
|
50
|
+
|
51
|
+
migrate_cookie(value, "#{@experiment.name}_id")
|
52
|
+
end
|
53
|
+
|
54
|
+
def add_migration(value, merge: false)
|
55
|
+
return unless value.is_a?(Hash)
|
56
|
+
|
57
|
+
@migrations[merge ? :merged : :unmerged] << value
|
56
58
|
end
|
57
59
|
|
58
60
|
def migration_keys
|
59
|
-
return nil if @
|
61
|
+
return nil if @migrations[:unmerged].empty? && @migrations[:merged].empty?
|
60
62
|
|
61
|
-
@
|
62
|
-
@
|
63
|
+
@migrations[:unmerged].map { |m| key_for(m) } +
|
64
|
+
@migrations[:merged].map { |m| key_for(@value.merge(m)) }
|
63
65
|
end
|
64
66
|
|
65
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
|
@@ -0,0 +1,16 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Gitlab
|
4
|
+
class Experiment
|
5
|
+
class Engine < ::Rails::Engine
|
6
|
+
def self.include_dsl
|
7
|
+
ActionController::Base.include(Dsl)
|
8
|
+
ActionController::Base.helper_method(:experiment)
|
9
|
+
end
|
10
|
+
|
11
|
+
config.after_initialize do
|
12
|
+
include_dsl if defined?(ActionController)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
metadata
CHANGED
@@ -1,35 +1,35 @@
|
|
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-
|
11
|
+
date: 2020-10-21 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: scientist
|
15
15
|
requirement: !ruby/object:Gem::Requirement
|
16
16
|
requirements:
|
17
|
-
- - ">="
|
18
|
-
- !ruby/object:Gem::Version
|
19
|
-
version: 1.5.0
|
20
17
|
- - "~>"
|
21
18
|
- !ruby/object:Gem::Version
|
22
19
|
version: '1.5'
|
20
|
+
- - ">="
|
21
|
+
- !ruby/object:Gem::Version
|
22
|
+
version: 1.5.0
|
23
23
|
type: :runtime
|
24
24
|
prerelease: false
|
25
25
|
version_requirements: !ruby/object:Gem::Requirement
|
26
26
|
requirements:
|
27
|
-
- - ">="
|
28
|
-
- !ruby/object:Gem::Version
|
29
|
-
version: 1.5.0
|
30
27
|
- - "~>"
|
31
28
|
- !ruby/object:Gem::Version
|
32
29
|
version: '1.5'
|
30
|
+
- - ">="
|
31
|
+
- !ruby/object:Gem::Version
|
32
|
+
version: 1.5.0
|
33
33
|
description:
|
34
34
|
email:
|
35
35
|
- gitlab_rubygems@gitlab.com
|
@@ -39,10 +39,16 @@ extra_rdoc_files: []
|
|
39
39
|
files:
|
40
40
|
- LICENSE.txt
|
41
41
|
- README.md
|
42
|
+
- lib/generators/gitlab_experiment/install/POST_INSTALL
|
43
|
+
- lib/generators/gitlab_experiment/install/install_generator.rb
|
44
|
+
- lib/generators/gitlab_experiment/install/templates/initializer.rb
|
42
45
|
- lib/gitlab/experiment.rb
|
46
|
+
- lib/gitlab/experiment/caching.rb
|
43
47
|
- lib/gitlab/experiment/configuration.rb
|
44
48
|
- lib/gitlab/experiment/context.rb
|
49
|
+
- lib/gitlab/experiment/cookies.rb
|
45
50
|
- lib/gitlab/experiment/dsl.rb
|
51
|
+
- lib/gitlab/experiment/engine.rb
|
46
52
|
- lib/gitlab/experiment/variant.rb
|
47
53
|
- lib/gitlab/experiment/version.rb
|
48
54
|
homepage: https://gitlab.com/gitlab-org/gitlab-experiment
|
@@ -64,7 +70,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
64
70
|
- !ruby/object:Gem::Version
|
65
71
|
version: '0'
|
66
72
|
requirements: []
|
67
|
-
rubygems_version: 3.
|
73
|
+
rubygems_version: 3.1.4
|
68
74
|
signing_key:
|
69
75
|
specification_version: 4
|
70
76
|
summary: GitLab experiment library built on top of scientist.
|