gitlab-experiment 0.2.4 → 0.4.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +139 -77
- data/lib/generators/gitlab/experiment/USAGE +17 -0
- data/lib/generators/gitlab/experiment/experiment_generator.rb +33 -0
- data/lib/generators/gitlab/experiment/install/install_generator.rb +41 -0
- data/lib/generators/gitlab/experiment/install/templates/POST_INSTALL +2 -0
- data/lib/generators/gitlab/experiment/install/templates/application_experiment.rb.tt +4 -0
- data/lib/generators/{gitlab_experiment/install/templates/initializer.rb → gitlab/experiment/install/templates/initializer.rb.tt} +28 -22
- data/lib/generators/gitlab/experiment/templates/experiment.rb.tt +15 -0
- data/lib/generators/rspec/experiment/experiment_generator.rb +15 -0
- data/lib/generators/rspec/experiment/templates/experiment_spec.rb.tt +9 -0
- data/lib/generators/test_unit/experiment/experiment_generator.rb +17 -0
- data/lib/generators/test_unit/experiment/templates/experiment_test.rb.tt +11 -0
- data/lib/gitlab/experiment.rb +86 -21
- data/lib/gitlab/experiment/caching.rb +9 -0
- data/lib/gitlab/experiment/callbacks.rb +39 -0
- data/lib/gitlab/experiment/configuration.rb +24 -10
- data/lib/gitlab/experiment/context.rb +29 -60
- data/lib/gitlab/experiment/cookies.rb +48 -0
- data/lib/gitlab/experiment/version.rb +1 -1
- metadata +29 -5
- data/lib/generators/gitlab_experiment/install/POST_INSTALL +0 -0
- data/lib/generators/gitlab_experiment/install/install_generator.rb +0 -21
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 865d51c081670824fc7ffb48d4bc764fd5370606d3aa63b2196421b5570506e7
|
4
|
+
data.tar.gz: a007f872a3d56ee81c9925117f603e4930d1b7d689078edb074a2ba5567833f2
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 03d401a71b952b74519a21fa851a87ee9e104ccd19011af37ac41c36bec5fd63db3e68c62c05beb34ab296d354e00e15f013dd34a547c3b4c5c94474b89096cc
|
7
|
+
data.tar.gz: 2bb0260e40d4689446c1317273d85140fae9f56394821fda96c7bbc5d1acbe441f3e4024bf00ec05812f64f5588897137fc8061e790d871c5719742f6cedf516
|
data/README.md
CHANGED
@@ -1,10 +1,11 @@
|
|
1
|
-
|
1
|
+
GitLab Experiment
|
2
|
+
=================
|
2
3
|
|
3
4
|
<img alt="experiment" src="/uploads/60990b2dbf4c0406bbf8b7f998de2dea/experiment.png" align="right" width="40%">
|
4
5
|
|
5
6
|
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
7
|
|
7
|
-
This library provides a clean and elegant DSL to define, run, and track your GitLab experiment.
|
8
|
+
This library provides a clean and elegant DSL (domain specific language) to define, run, and track your GitLab experiment.
|
8
9
|
|
9
10
|
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
11
|
|
@@ -16,6 +17,8 @@ When we discuss the behavior of this gem, we'll use terms like experiment, conte
|
|
16
17
|
|
17
18
|
Candidate and variant are the same concept, but simplify how we speak about experimental paths.<br clear="all">
|
18
19
|
|
20
|
+
[[_TOC_]]
|
21
|
+
|
19
22
|
## Installation
|
20
23
|
|
21
24
|
Add the gem to your Gemfile and then `bundle install`.
|
@@ -24,10 +27,10 @@ Add the gem to your Gemfile and then `bundle install`.
|
|
24
27
|
gem 'gitlab-experiment'
|
25
28
|
```
|
26
29
|
|
27
|
-
If you're using Rails, you can install the initializer
|
30
|
+
If you're using Rails, you can install the initializer which provides basic configuration, documentation, and the base experiment class that all your experiments can inherit from.
|
28
31
|
|
29
32
|
```shell
|
30
|
-
$ rails generate gitlab
|
33
|
+
$ rails generate gitlab:experiment:install
|
31
34
|
```
|
32
35
|
|
33
36
|
## Implementing an experiment
|
@@ -38,7 +41,7 @@ In our control (current world) we show a simple toggle interface labeled, "Notif
|
|
38
41
|
|
39
42
|
The behavior will be the same, but the interface will be different and may involve more or fewer steps.
|
40
43
|
|
41
|
-
Our hypothesis is that this will make the action more clear and will help
|
44
|
+
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
45
|
|
43
46
|
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
47
|
|
@@ -51,7 +54,7 @@ Now in our experiment we're going to render one of two views: the control will b
|
|
51
54
|
```ruby
|
52
55
|
class SubscriptionsController < ApplicationController
|
53
56
|
def show
|
54
|
-
experiment(:notification_toggle,
|
57
|
+
experiment(:notification_toggle, actor: user) do |e|
|
55
58
|
e.use { render_toggle } # control
|
56
59
|
e.try { render_button } # candidate
|
57
60
|
end
|
@@ -64,7 +67,7 @@ You can define the experiment using simple control/candidate paths, or provide n
|
|
64
67
|
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
68
|
|
66
69
|
```ruby
|
67
|
-
experiment(:notification_toggle,
|
70
|
+
experiment(:notification_toggle, actor: user) do |e|
|
68
71
|
e.use { render_toggle } # control
|
69
72
|
e.try(:variant_one) { render_button(confirmation: true) }
|
70
73
|
e.try(:variant_two) { render_button(confirmation: false) }
|
@@ -76,75 +79,87 @@ Understanding how an experiment can change behavior is important in evaluating i
|
|
76
79
|
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
80
|
|
78
81
|
```ruby
|
79
|
-
experiment(:notification_toggle,
|
82
|
+
experiment(:notification_toggle, actor: user).track(:clicked_button)
|
80
83
|
```
|
81
84
|
|
82
|
-
|
83
|
-
<summary>You can also use the more low level class or instance interfaces...</summary>
|
85
|
+
### Custom experiments
|
84
86
|
|
85
|
-
|
87
|
+
You can craft more advanced behaviors by defining custom experiments at a higher level. To do this you can define a class that inherits from `ApplicationExperiment` (or `Gitlab::Experiment`).
|
86
88
|
|
87
|
-
|
88
|
-
exp = Gitlab::Experiment.run(:notification_toggle, user_id: user.id) do |e|
|
89
|
-
# Context may be passed in the block, but must be finalized before calling
|
90
|
-
# run or track.
|
91
|
-
e.context(project_id: project.id) # add the project id to the context
|
89
|
+
Let's say you want to do more advanced segmentation, or provide default behavior for the variants on the experiment we've already outlined above -- that way if the variants aren't defined in the block at the time the experiment is run, these methods will be used.
|
92
90
|
|
93
|
-
|
94
|
-
e.use { render_toggle } # control
|
95
|
-
e.try { render_button } # candidate
|
96
|
-
end
|
91
|
+
You can generate a custom experiment by running:
|
97
92
|
|
98
|
-
|
99
|
-
|
100
|
-
```
|
93
|
+
```shell
|
94
|
+
$ rails generate gitlab:experiment NotificationToggle control candidate
|
95
|
+
```
|
96
|
+
|
97
|
+
This will generate a file in `app/experiments/notification_toggle_experiment.rb`, as well as a test file for you to further expand on.
|
101
98
|
|
102
|
-
|
99
|
+
Here are some examples of what you can introduce once you have a custom experiment defined.
|
103
100
|
|
104
101
|
```ruby
|
105
|
-
|
106
|
-
#
|
107
|
-
#
|
108
|
-
|
102
|
+
class NotificationToggleExperiment < ApplicationExperiment
|
103
|
+
# Segment any account less than 2 weeks old into the candidate, without
|
104
|
+
# asking the variant resolver to decide which variant to provide.
|
105
|
+
segment :account_age, variant: :candidate
|
106
|
+
|
107
|
+
# Define the default control behavior, which can be overridden at
|
108
|
+
# experiment time.
|
109
|
+
def control_behavior
|
110
|
+
render_toggle
|
111
|
+
end
|
109
112
|
|
110
|
-
# Define the
|
111
|
-
|
112
|
-
|
113
|
+
# Define the default candidate behavior, which can be overridden
|
114
|
+
# at experiment time.
|
115
|
+
def candidate_behavior
|
116
|
+
render_button
|
117
|
+
end
|
118
|
+
|
119
|
+
private
|
120
|
+
|
121
|
+
def account_age
|
122
|
+
context.actor && context.actor.created_at < 2.weeks.ago
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
# The class will be looked up based on the experiment name provided.
|
127
|
+
exp = experiment(:notification_toggle, actor: user)
|
128
|
+
exp # => instance of NotificationToggleExperiment
|
113
129
|
|
114
|
-
# Run the experiment
|
130
|
+
# Run the experiment -- returning the result.
|
115
131
|
exp.run
|
116
132
|
|
117
133
|
# Track an event on the experiment we've defined.
|
118
134
|
exp.track(:clicked_button)
|
119
135
|
```
|
120
136
|
|
121
|
-
|
137
|
+
You can now also do things very similar to the simple examples and override the default variant behaviors defined in the custom experiment -- keeping in mind that this should be carefully considered within the scope of your experiment.
|
138
|
+
|
139
|
+
```ruby
|
140
|
+
experiment(:notification_toggle, actor: user) do |e|
|
141
|
+
e.use { render_special_toggle } # override default control behavior
|
142
|
+
end
|
143
|
+
```
|
122
144
|
|
123
145
|
<details>
|
124
|
-
<summary>You can
|
146
|
+
<summary>You can also use the lower level class interface...</summary>
|
125
147
|
|
126
|
-
###
|
148
|
+
### Using the `.run` approach
|
127
149
|
|
128
|
-
|
129
|
-
class NotificationExperiment < Gitlab::Experiment
|
130
|
-
def initialize(variant_name = nil, **context, &block)
|
131
|
-
super(:notification_toggle, variant_name, **context, &block)
|
150
|
+
This is useful if you haven't included the DSL and so don't have access to the `experiment` method, but still want to execute an experiment. This is ultimately what the `experiment` method calls through to, and the method signatures are the same.
|
132
151
|
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
152
|
+
```ruby
|
153
|
+
exp = Gitlab::Experiment.run(:notification_toggle, actor: user) do |e|
|
154
|
+
# Context may be passed in the block, but must be finalized before calling
|
155
|
+
# run or track.
|
156
|
+
e.context(project: project) # add the project to the context
|
138
157
|
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
e.context(project_id: project.id) # add the project id to the context
|
158
|
+
# Define the control and candidate variant.
|
159
|
+
e.use { render_toggle } # control
|
160
|
+
e.try { render_button } # candidate
|
143
161
|
end
|
144
162
|
|
145
|
-
# Run the experiment -- returning the result.
|
146
|
-
exp.run
|
147
|
-
|
148
163
|
# Track an event on the experiment we've defined.
|
149
164
|
exp.track(:clicked_button)
|
150
165
|
```
|
@@ -152,14 +167,14 @@ exp.track(:clicked_button)
|
|
152
167
|
</details>
|
153
168
|
|
154
169
|
<details>
|
155
|
-
<summary>You can also specify the variant to use...</summary>
|
170
|
+
<summary>You can also specify the variant to use for segmentation...</summary>
|
156
171
|
|
157
172
|
### Specifying variant
|
158
173
|
|
159
|
-
|
174
|
+
Generally, defining segmentation rules is a better way to approach routing into specific variants, but it's possible to explicitly specify the variant when running an experiment. It's important to know what this might do to your data during rollout, so use this with careful consideration.
|
160
175
|
|
161
176
|
```ruby
|
162
|
-
experiment(:notification_toggle, :no_interface,
|
177
|
+
experiment(:notification_toggle, :no_interface, actor: user) do |e|
|
163
178
|
e.use { render_toggle } # control
|
164
179
|
e.try { render_button } # candidate
|
165
180
|
e.try(:no_interface) { no_interface! } # variant
|
@@ -169,7 +184,7 @@ end
|
|
169
184
|
Or you can set the variant within the block. This allows using unique segmentation logic or variant resolution if you need it.
|
170
185
|
|
171
186
|
```ruby
|
172
|
-
experiment(:notification_toggle,
|
187
|
+
experiment(:notification_toggle, actor: user) do |e|
|
173
188
|
# Variant selection must be done before calling run or track.
|
174
189
|
e.variant(:no_interface) # set the variant
|
175
190
|
# ...
|
@@ -179,7 +194,7 @@ end
|
|
179
194
|
Or it can be specified in the call to run if you call it from within the block.
|
180
195
|
|
181
196
|
```ruby
|
182
|
-
experiment(:notification_toggle,
|
197
|
+
experiment(:notification_toggle, actor: user) do |e|
|
183
198
|
# ...
|
184
199
|
# Variant selection can be specified when calling run.
|
185
200
|
e.run(:no_interface)
|
@@ -188,6 +203,25 @@ end
|
|
188
203
|
|
189
204
|
</details>
|
190
205
|
|
206
|
+
### Segmentation rules
|
207
|
+
|
208
|
+
This library comes with the capability to segment contexts into a specific variant, before asking the variant resolver which variant to provide.
|
209
|
+
|
210
|
+
Segmentation can be achieved by using a custom experiment class and specifying the segmentation rules at a class level.
|
211
|
+
|
212
|
+
```ruby
|
213
|
+
class NotificationToggleExperiment < ApplicationExperiment
|
214
|
+
segment(variant: :variant_one) { context.actor.username == 'jejacks0n' }
|
215
|
+
segment(variant: :variant_two) { context.actor.created_at < 2.weeks.ago }
|
216
|
+
end
|
217
|
+
```
|
218
|
+
|
219
|
+
In the previous examples, any user with the username `'jejacks0n'` would always receive the experience defined in "variant_one". As well, any account less than 2 weeks old would get the alternate experience defined in "variant_two".
|
220
|
+
|
221
|
+
When an experiment is run, the segmentation rules are executed in the order they're defined. The first segmentation rule to produce a truthy result is the one which gets used to assign the variant. The remaining segmentation rules are skipped.
|
222
|
+
|
223
|
+
This means that any user with the name `'jejacks0n'`, regardless of account age, will always be provided the experience as defined in "variant_one".
|
224
|
+
|
191
225
|
### Return value
|
192
226
|
|
193
227
|
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.
|
@@ -209,13 +243,13 @@ Some experiments may extend outside of those layers, so you may want to include
|
|
209
243
|
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
244
|
|
211
245
|
```ruby
|
212
|
-
class
|
246
|
+
class WelcomeMailer < ApplicationMailer
|
213
247
|
include Gitlab::Experiment::Dsl # include the `experiment` method
|
214
248
|
|
215
249
|
def welcome
|
216
250
|
@user = params[:user]
|
217
251
|
|
218
|
-
ex = experiment(:project_suggestions,
|
252
|
+
ex = experiment(:project_suggestions, actor: @user) do |e|
|
219
253
|
e.use { 'welcome' }
|
220
254
|
e.try { 'welcome_with_project_suggestions' }
|
221
255
|
end
|
@@ -234,8 +268,8 @@ Take for instance, that you might be using `version: 1` in your context currentl
|
|
234
268
|
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
269
|
|
236
270
|
```ruby
|
237
|
-
# Migrate just the `:version` portion of the previous context, `{
|
238
|
-
experiment(:my_experiment,
|
271
|
+
# Migrate just the `:version` portion of the previous context, `{ actor: project, version: 1 }`:
|
272
|
+
experiment(:my_experiment, actor: project, version: 2, migrated_with: { version: 1 })
|
239
273
|
```
|
240
274
|
|
241
275
|
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 +277,8 @@ You can add or remove context by providing a `migrated_from` option. This approa
|
|
243
277
|
If you wanted to introduce a `version` to your context, provide the full previous context.
|
244
278
|
|
245
279
|
```ruby
|
246
|
-
# Migrate the full context from `{
|
247
|
-
experiment(:my_experiment,
|
280
|
+
# Migrate the full context from `{ actor: project }` to `{ actor: project, version: 1 }`:
|
281
|
+
experiment(:my_experiment, actor: project, version: 1, migrated_from: { actor: project })
|
248
282
|
```
|
249
283
|
|
250
284
|
This can impact an experience if you:
|
@@ -252,36 +286,36 @@ This can impact an experience if you:
|
|
252
286
|
1. haven't implemented the concept of migrations in your variant resolver
|
253
287
|
1. haven't enabled a reasonable caching mechanism
|
254
288
|
|
255
|
-
### When there isn't
|
289
|
+
### When there isn't an actor (cookie fallback)
|
256
290
|
|
257
|
-
When there isn't an identifying key in the context (this is `
|
291
|
+
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
292
|
|
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.
|
293
|
+
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
294
|
|
261
|
-
This cookie is a randomized uuid and isn't associated with
|
295
|
+
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
296
|
|
263
297
|
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
298
|
|
265
299
|
You'll need to provide the `request` as an option to the experiment if it's outside of the controller and views.
|
266
300
|
|
267
301
|
```ruby
|
268
|
-
experiment(:my_experiment,
|
302
|
+
experiment(:my_experiment, actor: user, request: request)
|
269
303
|
```
|
270
304
|
|
271
|
-
The cookie isn't set if the
|
305
|
+
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
306
|
|
273
307
|
```ruby
|
274
|
-
#
|
275
|
-
experiment(:my_experiment,
|
308
|
+
# actor is not present, so no cookie is set
|
309
|
+
experiment(:my_experiment, project: project)
|
276
310
|
|
277
|
-
#
|
278
|
-
experiment(:my_experiment,
|
311
|
+
# actor is present and is nil, so the cookie is set and used
|
312
|
+
experiment(:my_experiment, actor: nil, project: project)
|
279
313
|
|
280
|
-
#
|
281
|
-
experiment(:my_experiment,
|
314
|
+
# actor is present and set to a value, so no cookie is set
|
315
|
+
experiment(:my_experiment, actor: user, project: project)
|
282
316
|
```
|
283
317
|
|
284
|
-
For edge cases, you can pass the cookie through by assigning it yourself -- e.g. `
|
318
|
+
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
319
|
|
286
320
|
## Configuration
|
287
321
|
|
@@ -307,7 +341,7 @@ Gitlab::Experiment.configure do |config|
|
|
307
341
|
end
|
308
342
|
```
|
309
343
|
|
310
|
-
More examples for configuration are available in the provided [rails initializer](lib/generators/
|
344
|
+
More examples for configuration are available in the provided [rails initializer](lib/generators/gitlab/experiment/install/templates/initializer.rb).
|
311
345
|
|
312
346
|
### Client layer / JavaScript
|
313
347
|
|
@@ -351,4 +385,32 @@ If you only include a user, that user would get the same experience across every
|
|
351
385
|
|
352
386
|
Each of these approaches could be desirable given the objectives of your experiment.
|
353
387
|
|
354
|
-
|
388
|
+
## Development
|
389
|
+
|
390
|
+
After checking out the repo, run `bundle install` to install dependencies.
|
391
|
+
Then, run `bundle exec rspec` to run the tests. You can also run `bundle exec pry` for an
|
392
|
+
interactive prompt that will allow you to experiment.
|
393
|
+
|
394
|
+
## Contributing
|
395
|
+
|
396
|
+
Bug reports and merge requests are welcome on GitLab at
|
397
|
+
https://gitlab.com/gitlab-org/gitlab-experiment. This project is intended to be a
|
398
|
+
safe, welcoming space for collaboration, and contributors are expected to adhere
|
399
|
+
to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
|
400
|
+
|
401
|
+
## Release Process
|
402
|
+
|
403
|
+
Please refer to the [Release Process](docs/release_process.md).
|
404
|
+
|
405
|
+
## License
|
406
|
+
|
407
|
+
The gem is available as open source under the terms of the
|
408
|
+
[MIT License](http://opensource.org/licenses/MIT).
|
409
|
+
|
410
|
+
## Code of Conduct
|
411
|
+
|
412
|
+
Everyone interacting in the `Gitlab::Experiment` project’s codebases, issue trackers,
|
413
|
+
chat rooms and mailing lists is expected to follow the
|
414
|
+
[code of conduct](CODE_OF_CONDUCT.md).
|
415
|
+
|
416
|
+
***Make code not war***
|
@@ -0,0 +1,17 @@
|
|
1
|
+
Description:
|
2
|
+
Stubs out a new experiment and its variants. Pass the experiment name,
|
3
|
+
either CamelCased or under_scored, and a list of variants as arguments.
|
4
|
+
|
5
|
+
To create an experiment within a module, specify the experiment name as a
|
6
|
+
path like 'parent_module/experiment_name'.
|
7
|
+
|
8
|
+
This generates an experiment class in app/experiments and invokes feature
|
9
|
+
flag, and test framework generators.
|
10
|
+
|
11
|
+
Example:
|
12
|
+
`rails generate gitlab:experiment NullHypothesis control candidate alt_variant`
|
13
|
+
|
14
|
+
NullHypothesis experiment with default variants.
|
15
|
+
Experiment: app/experiments/null_hypothesis_experiment.rb
|
16
|
+
Feature Flag: config/feature_flags/experiment/null_hypothesis.yaml
|
17
|
+
Test: test/experiments/null_hypothesis_experiment_test.rb
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'rails/generators'
|
4
|
+
|
5
|
+
module Gitlab
|
6
|
+
module Generators
|
7
|
+
class ExperimentGenerator < Rails::Generators::NamedBase
|
8
|
+
source_root File.expand_path('templates/', __dir__)
|
9
|
+
check_class_collision suffix: 'Experiment'
|
10
|
+
|
11
|
+
argument :variants,
|
12
|
+
type: :array,
|
13
|
+
default: %w[control candidate],
|
14
|
+
banner: 'variant variant'
|
15
|
+
|
16
|
+
def create_experiment
|
17
|
+
template 'experiment.rb', File.join('app/experiments', class_path, "#{file_name}_experiment.rb")
|
18
|
+
end
|
19
|
+
|
20
|
+
hook_for :test_framework
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
def file_name
|
25
|
+
@_file_name ||= remove_possible_suffix(super)
|
26
|
+
end
|
27
|
+
|
28
|
+
def remove_possible_suffix(name)
|
29
|
+
name.sub(/_?exp[ei]riment$/i, "") # be somewhat forgiving with spelling
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'rails/generators'
|
4
|
+
|
5
|
+
module Gitlab
|
6
|
+
module Generators
|
7
|
+
module Experiment
|
8
|
+
class InstallGenerator < Rails::Generators::Base
|
9
|
+
source_root File.expand_path('templates', __dir__)
|
10
|
+
|
11
|
+
desc 'Installs the Gitlab::Experiment initializer and optional ApplicationExperiment into your application.'
|
12
|
+
|
13
|
+
class_option :skip_initializer,
|
14
|
+
type: :boolean,
|
15
|
+
default: false,
|
16
|
+
desc: 'Skip the initializer with default configuration'
|
17
|
+
|
18
|
+
class_option :skip_baseclass,
|
19
|
+
type: :boolean,
|
20
|
+
default: false,
|
21
|
+
desc: 'Skip the ApplicationExperiment base class'
|
22
|
+
|
23
|
+
def create_initializer
|
24
|
+
return if options[:skip_initializer]
|
25
|
+
|
26
|
+
template 'initializer.rb', 'config/initializers/gitlab_experiment.rb'
|
27
|
+
end
|
28
|
+
|
29
|
+
def create_baseclass
|
30
|
+
return if options[:skip_baseclass]
|
31
|
+
|
32
|
+
template 'application_experiment.rb', 'app/experiments/application_experiment.rb'
|
33
|
+
end
|
34
|
+
|
35
|
+
def display_post_install
|
36
|
+
readme 'POST_INSTALL' if behavior == :invoke
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -5,19 +5,30 @@ Gitlab::Experiment.configure do |config|
|
|
5
5
|
config.name_prefix = nil
|
6
6
|
|
7
7
|
# The logger is used to log various details of the experiments.
|
8
|
-
config.logger = Logger.new(
|
8
|
+
config.logger = Logger.new($stdout)
|
9
9
|
|
10
|
-
# The
|
10
|
+
# The base class that should be instantiated for basic experiments. It should
|
11
|
+
# be a string, so we can constantize it later.
|
12
|
+
config.base_class = 'ApplicationExperiment'
|
13
|
+
|
14
|
+
# The caching layer is expected to respond to fetch, like Rails.cache for
|
15
|
+
# instance -- or anything that adheres to ActiveSupport::Cache::Store.
|
11
16
|
config.cache = nil
|
12
17
|
|
18
|
+
# The domain to use on cookies.
|
19
|
+
#
|
20
|
+
# When not set, it uses the current host. If you want to provide specific
|
21
|
+
# hosts, you use `:all`, or provide an array like
|
22
|
+
# `['www.gitlab.com', '.gitlab.com']`.
|
23
|
+
config.cookie_domain = :all
|
24
|
+
|
13
25
|
# Logic this project uses to resolve a variant for a given experiment.
|
14
26
|
#
|
15
|
-
#
|
16
|
-
#
|
17
|
-
# class will be used.
|
27
|
+
# Should return a symbol or string that represents the variant that should
|
28
|
+
# be assigned.
|
18
29
|
#
|
19
|
-
# This block
|
20
|
-
#
|
30
|
+
# This block is executed within the scope of the experiment and so can access
|
31
|
+
# experiment methods, like `name`, `context`, and `signature`.
|
21
32
|
config.variant_resolver = lambda do |requested_variant|
|
22
33
|
# Run the control, unless a variant was requested in code:
|
23
34
|
requested_variant || 'control'
|
@@ -25,23 +36,12 @@ Gitlab::Experiment.configure do |config|
|
|
25
36
|
# Run the candidate, unless a variant was requested, with a fallback:
|
26
37
|
#
|
27
38
|
# 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
39
|
end
|
39
40
|
|
40
41
|
# Tracking behavior can be implemented to link an event to an experiment.
|
41
42
|
#
|
42
|
-
#
|
43
|
-
# experiment
|
44
|
-
# such as name and signature.
|
43
|
+
# This block is executed within the scope of the experiment and so can access
|
44
|
+
# experiment methods, like `name`, `context`, and `signature`.
|
45
45
|
config.tracking_behavior = lambda do |event, args|
|
46
46
|
# An example of using a generic logger to track events:
|
47
47
|
config.logger.info "Gitlab::Experiment[#{name}] #{event}: #{args.merge(signature: signature)}"
|
@@ -58,8 +58,11 @@ Gitlab::Experiment.configure do |config|
|
|
58
58
|
# Called at the end of every experiment run, with the result.
|
59
59
|
#
|
60
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
|
62
|
-
# into redis.
|
61
|
+
# or push the experiment into the client or publish results elsewhere like
|
62
|
+
# into redis.
|
63
|
+
#
|
64
|
+
# This block is executed within the scope of the experiment and so can access
|
65
|
+
# experiment methods, like `name`, `context`, and `signature`.
|
63
66
|
config.publishing_behavior = lambda do |result|
|
64
67
|
# Track the event using our own configured tracking logic.
|
65
68
|
track(:assignment)
|
@@ -80,6 +83,9 @@ Gitlab::Experiment.configure do |config|
|
|
80
83
|
# Given a specific context hash map, we need to generate a consistent hash
|
81
84
|
# key. The logic in here will be used for generating cache keys, and may also
|
82
85
|
# be used when determining which variant may be presented.
|
86
|
+
#
|
87
|
+
# This block is executed within the scope of the experiment and so can access
|
88
|
+
# experiment methods, like `name`, `context`, and `signature`.
|
83
89
|
config.context_hash_strategy = lambda do |context|
|
84
90
|
values = context.values.map { |v| (v.respond_to?(:to_global_id) ? v.to_global_id : v).to_s }
|
85
91
|
Digest::MD5.hexdigest((context.keys + values).join('|'))
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
<% if namespaced? -%>
|
4
|
+
require_dependency "<%= namespaced_path %>/application_experiment"
|
5
|
+
|
6
|
+
<% end -%>
|
7
|
+
<% module_namespacing do -%>
|
8
|
+
class <%= class_name %>Experiment < ApplicationExperiment
|
9
|
+
<% variants.each do |variant| -%>
|
10
|
+
def <%= variant %>_behavior
|
11
|
+
end
|
12
|
+
<%= "\n" unless variant == variants.last -%>
|
13
|
+
<% end -%>
|
14
|
+
end
|
15
|
+
<% end -%>
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'generators/rspec'
|
4
|
+
|
5
|
+
module Rspec
|
6
|
+
module Generators
|
7
|
+
class ExperimentGenerator < Rspec::Generators::Base
|
8
|
+
source_root File.expand_path('templates/', __dir__)
|
9
|
+
|
10
|
+
def create_experiment_spec
|
11
|
+
template 'experiment_spec.rb', File.join('spec/experiments', class_path, "#{file_name}_experiment_spec.rb")
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'rails/generators/test_unit'
|
4
|
+
|
5
|
+
module TestUnit # :nodoc:
|
6
|
+
module Generators # :nodoc:
|
7
|
+
class ExperimentGenerator < TestUnit::Generators::Base # :nodoc:
|
8
|
+
source_root File.expand_path('templates/', __dir__)
|
9
|
+
|
10
|
+
check_class_collision suffix: 'Test'
|
11
|
+
|
12
|
+
def create_test_file
|
13
|
+
template 'experiment_test.rb', File.join('test/experiments', class_path, "#{file_name}_experiment_test.rb")
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
data/lib/gitlab/experiment.rb
CHANGED
@@ -1,9 +1,14 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require 'scientist'
|
4
|
+
require 'active_support/callbacks'
|
5
|
+
require 'active_support/core_ext/object/blank'
|
6
|
+
require 'active_support/core_ext/string/inflections'
|
4
7
|
|
5
8
|
require 'gitlab/experiment/caching'
|
9
|
+
require 'gitlab/experiment/callbacks'
|
6
10
|
require 'gitlab/experiment/configuration'
|
11
|
+
require 'gitlab/experiment/cookies'
|
7
12
|
require 'gitlab/experiment/context'
|
8
13
|
require 'gitlab/experiment/dsl'
|
9
14
|
require 'gitlab/experiment/variant'
|
@@ -14,53 +19,85 @@ module Gitlab
|
|
14
19
|
class Experiment
|
15
20
|
include Scientist::Experiment
|
16
21
|
include Caching
|
22
|
+
include Callbacks
|
17
23
|
|
18
24
|
class << self
|
19
25
|
def configure
|
20
26
|
yield Configuration
|
21
27
|
end
|
22
28
|
|
23
|
-
def run(name, variant_name = nil, **context, &block)
|
24
|
-
|
29
|
+
def run(name = nil, variant_name = nil, **context, &block)
|
30
|
+
raise ArgumentError, 'name is required' if name.nil? && base?
|
31
|
+
|
32
|
+
instance = constantize(name).new(name, variant_name, **context, &block)
|
25
33
|
return instance unless block_given?
|
26
34
|
|
27
35
|
instance.context.frozen? ? instance.run : instance.tap(&:run)
|
28
36
|
end
|
37
|
+
|
38
|
+
def experiment_name(name = nil, suffix: true, suffix_word: 'experiment')
|
39
|
+
name = (name.presence || self.name).to_s.underscore.sub(%r{(?<char>[_/]|)#{suffix_word}$}, '')
|
40
|
+
name = "#{name}#{Regexp.last_match(:char) || '_'}#{suffix_word}"
|
41
|
+
suffix ? name : name.sub(/_#{suffix_word}$/, '')
|
42
|
+
end
|
43
|
+
|
44
|
+
def base?
|
45
|
+
self == Gitlab::Experiment || name == Configuration.base_class
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
def constantize(name = nil)
|
51
|
+
return self if name.nil?
|
52
|
+
|
53
|
+
experiment_name(name).classify.safe_constantize || Configuration.base_class.constantize
|
54
|
+
end
|
29
55
|
end
|
30
56
|
|
31
|
-
|
57
|
+
def initialize(name = nil, variant_name = nil, **context)
|
58
|
+
raise ArgumentError, 'name is required' if name.blank? && self.class.base?
|
32
59
|
|
33
|
-
|
34
|
-
@name = name
|
60
|
+
@name = self.class.experiment_name(name, suffix: false)
|
35
61
|
@variant_name = variant_name
|
36
|
-
@
|
62
|
+
@excluded = []
|
63
|
+
@context = Context.new(self, context)
|
37
64
|
|
38
|
-
context
|
39
|
-
|
40
|
-
ignore { true }
|
65
|
+
exclude { !@context.trackable? }
|
41
66
|
compare { false }
|
42
67
|
|
43
68
|
yield self if block_given?
|
44
69
|
end
|
45
70
|
|
46
71
|
def context(value = nil)
|
47
|
-
return @context if value.
|
72
|
+
return @context if value.blank?
|
48
73
|
|
49
74
|
@context.value(value)
|
50
75
|
@context
|
51
76
|
end
|
52
77
|
|
53
78
|
def variant(value = nil)
|
54
|
-
|
79
|
+
@variant_name = value unless value.blank?
|
80
|
+
@variant_name ||= :control if excluded?
|
55
81
|
|
56
|
-
|
57
|
-
|
82
|
+
resolved = cache { resolve_variant_name }
|
83
|
+
Variant.new(name: (resolved.presence || :control).to_s)
|
58
84
|
end
|
59
85
|
|
60
|
-
def
|
61
|
-
@
|
86
|
+
def exclude(&block)
|
87
|
+
@excluded << block
|
88
|
+
end
|
62
89
|
|
63
|
-
|
90
|
+
def run(variant_name = nil)
|
91
|
+
@result ||= begin
|
92
|
+
variant_name = variant(variant_name).name
|
93
|
+
run_callbacks(variant_assigned? ? :unsegmented_run : :segmented_run) do
|
94
|
+
if respond_to?((behavior_name = "#{variant_name}_behavior"))
|
95
|
+
behaviors[variant_name] ||= -> { send(behavior_name) } # rubocop:disable GitlabSecurity/PublicSend
|
96
|
+
end
|
97
|
+
|
98
|
+
super(@variant_name = variant_name)
|
99
|
+
end
|
100
|
+
end
|
64
101
|
end
|
65
102
|
|
66
103
|
def publish(result)
|
@@ -68,6 +105,8 @@ module Gitlab
|
|
68
105
|
end
|
69
106
|
|
70
107
|
def track(action, **event_args)
|
108
|
+
return if excluded?
|
109
|
+
|
71
110
|
instance_exec(action, event_args, &Configuration.tracking_behavior)
|
72
111
|
end
|
73
112
|
|
@@ -76,23 +115,49 @@ module Gitlab
|
|
76
115
|
end
|
77
116
|
|
78
117
|
def variant_names
|
79
|
-
@variant_names ||= behaviors.keys.
|
118
|
+
@variant_names ||= behaviors.keys.map(&:to_sym) - [:control]
|
119
|
+
end
|
120
|
+
|
121
|
+
def signature
|
122
|
+
{ variant: variant.name, experiment: name }.merge(context.signature)
|
80
123
|
end
|
81
124
|
|
82
125
|
def enabled?
|
83
126
|
true
|
84
127
|
end
|
85
128
|
|
86
|
-
def
|
87
|
-
|
129
|
+
def excluded?
|
130
|
+
@excluded.any? { |exclude| exclude.call(self) }
|
131
|
+
end
|
132
|
+
|
133
|
+
def variant_assigned?
|
134
|
+
!@variant_name.nil?
|
135
|
+
end
|
136
|
+
|
137
|
+
def id
|
138
|
+
"#{name}:#{key_for(context.value)}"
|
139
|
+
end
|
140
|
+
alias_method :session_id, :id
|
141
|
+
|
142
|
+
def flipper_id
|
143
|
+
"Experiment;#{id}"
|
88
144
|
end
|
89
145
|
|
90
|
-
def
|
91
|
-
|
146
|
+
def key_for(hash)
|
147
|
+
instance_exec(hash, &Configuration.context_hash_strategy)
|
92
148
|
end
|
93
149
|
|
94
150
|
protected
|
95
151
|
|
152
|
+
def resolve_variant_name
|
153
|
+
return :unresolved if @resolving
|
154
|
+
|
155
|
+
@resolving = true
|
156
|
+
result = instance_exec(@variant_name, &Configuration.variant_resolver)
|
157
|
+
@resolving = false
|
158
|
+
result
|
159
|
+
end
|
160
|
+
|
96
161
|
def generate_result(variant_name)
|
97
162
|
observation = Scientist::Observation.new(variant_name, self, &behaviors[variant_name])
|
98
163
|
Scientist::Result.new(self, [observation], observation)
|
@@ -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}:#{context.signature[:key]}",
|
18
|
+
context.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))
|
@@ -0,0 +1,39 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Gitlab
|
4
|
+
class Experiment
|
5
|
+
module Callbacks
|
6
|
+
extend ActiveSupport::Concern
|
7
|
+
include ActiveSupport::Callbacks
|
8
|
+
|
9
|
+
included do
|
10
|
+
define_callbacks(
|
11
|
+
:unsegmented_run,
|
12
|
+
skip_after_callbacks_if_terminated: true
|
13
|
+
)
|
14
|
+
|
15
|
+
define_callbacks(
|
16
|
+
:segmented_run,
|
17
|
+
skip_after_callbacks_if_terminated: false,
|
18
|
+
terminator: lambda do |target, result_lambda|
|
19
|
+
result_lambda.call
|
20
|
+
target.variant_assigned?
|
21
|
+
end
|
22
|
+
)
|
23
|
+
end
|
24
|
+
|
25
|
+
class_methods do
|
26
|
+
def segment(*filter_list, variant:, **options, &block)
|
27
|
+
filters = filter_list.unshift(block).compact.map do |filter|
|
28
|
+
result_lambda = ActiveSupport::Callbacks::CallTemplate.build(filter, self).make_lambda
|
29
|
+
->(target) { target.variant(variant) if result_lambda.call(target, nil) }
|
30
|
+
end
|
31
|
+
|
32
|
+
raise ArgumentError, 'no filters provided' if filters.empty?
|
33
|
+
|
34
|
+
set_callback(:segmented_run, :before, *filters, options, &block)
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -13,14 +13,20 @@ module Gitlab
|
|
13
13
|
@name_prefix = nil
|
14
14
|
|
15
15
|
# The logger is used to log various details of the experiments.
|
16
|
-
@logger = Logger.new(
|
16
|
+
@logger = Logger.new($stdout)
|
17
17
|
|
18
|
-
#
|
18
|
+
# The base class that should be instantiated for basic experiments.
|
19
|
+
@base_class = 'Gitlab::Experiment'
|
20
|
+
|
21
|
+
# The caching layer is expected to respond to fetch, like Rails.cache.
|
19
22
|
@cache = nil
|
20
23
|
|
24
|
+
# The domain to use on cookies.
|
25
|
+
@cookie_domain = :all
|
26
|
+
|
21
27
|
# Logic this project uses to resolve a variant for a given experiment.
|
22
28
|
@variant_resolver = lambda do |requested_variant|
|
23
|
-
requested_variant ||
|
29
|
+
requested_variant || :control
|
24
30
|
end
|
25
31
|
|
26
32
|
# Tracking behavior can be implemented to link an event to an experiment.
|
@@ -28,21 +34,29 @@ module Gitlab
|
|
28
34
|
Configuration.logger.info "Gitlab::Experiment[#{name}] #{event}: #{args.merge(signature: signature)}"
|
29
35
|
end
|
30
36
|
|
31
|
-
# Called at the end of every experiment run, with the
|
32
|
-
# want to push the experiment into the client or push results elsewhere.
|
37
|
+
# Called at the end of every experiment run, with the result.
|
33
38
|
@publishing_behavior = lambda do |_result|
|
34
39
|
track(:assignment)
|
35
40
|
end
|
36
41
|
|
37
42
|
# Algorithm that consistently generates a hash key for a given hash map.
|
38
|
-
@context_hash_strategy = lambda do |
|
39
|
-
values =
|
40
|
-
Digest::MD5.hexdigest((
|
43
|
+
@context_hash_strategy = lambda do |hash_map|
|
44
|
+
values = hash_map.values.map { |v| (v.respond_to?(:to_global_id) ? v.to_global_id : v).to_s }
|
45
|
+
Digest::MD5.hexdigest(([name] + hash_map.keys + values).join('|'))
|
41
46
|
end
|
42
47
|
|
43
48
|
class << self
|
44
|
-
attr_accessor
|
45
|
-
|
49
|
+
attr_accessor(
|
50
|
+
:name_prefix,
|
51
|
+
:logger,
|
52
|
+
:base_class,
|
53
|
+
:cache,
|
54
|
+
:cookie_domain,
|
55
|
+
:variant_resolver,
|
56
|
+
:tracking_behavior,
|
57
|
+
:publishing_behavior,
|
58
|
+
:context_hash_strategy
|
59
|
+
)
|
46
60
|
end
|
47
61
|
end
|
48
62
|
end
|
@@ -3,96 +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: @experiment.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
|
54
|
+
def add_migration(value, merge: false)
|
55
|
+
return unless value.is_a?(Hash)
|
61
56
|
|
62
|
-
|
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
|
-
}
|
83
|
-
|
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 @
|
89
|
-
|
90
|
-
@migrations_from.map { |m| key_for(m) } +
|
91
|
-
@migrations_with.map { |m| key_for(@value.merge(m)) }
|
92
|
-
end
|
61
|
+
return nil if @migrations[:unmerged].empty? && @migrations[:merged].empty?
|
93
62
|
|
94
|
-
|
95
|
-
|
63
|
+
@migrations[:unmerged].map { |m| @experiment.key_for(m) } +
|
64
|
+
@migrations[:merged].map { |m| @experiment.key_for(@value.merge(m)) }
|
96
65
|
end
|
97
66
|
end
|
98
67
|
end
|
@@ -0,0 +1,48 @@
|
|
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: domain)
|
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: domain, httponly: true
|
38
|
+
}
|
39
|
+
|
40
|
+
hash.merge(key => cookie)
|
41
|
+
end
|
42
|
+
|
43
|
+
def domain
|
44
|
+
Configuration.cookie_domain
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
metadata
CHANGED
@@ -1,15 +1,29 @@
|
|
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.4.2
|
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-11-19 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: activesupport
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '3.0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '3.0'
|
13
27
|
- !ruby/object:Gem::Dependency
|
14
28
|
name: scientist
|
15
29
|
requirement: !ruby/object:Gem::Requirement
|
@@ -39,13 +53,23 @@ extra_rdoc_files: []
|
|
39
53
|
files:
|
40
54
|
- LICENSE.txt
|
41
55
|
- README.md
|
42
|
-
- lib/generators/
|
43
|
-
- lib/generators/
|
44
|
-
- lib/generators/
|
56
|
+
- lib/generators/gitlab/experiment/USAGE
|
57
|
+
- lib/generators/gitlab/experiment/experiment_generator.rb
|
58
|
+
- lib/generators/gitlab/experiment/install/install_generator.rb
|
59
|
+
- lib/generators/gitlab/experiment/install/templates/POST_INSTALL
|
60
|
+
- lib/generators/gitlab/experiment/install/templates/application_experiment.rb.tt
|
61
|
+
- lib/generators/gitlab/experiment/install/templates/initializer.rb.tt
|
62
|
+
- lib/generators/gitlab/experiment/templates/experiment.rb.tt
|
63
|
+
- lib/generators/rspec/experiment/experiment_generator.rb
|
64
|
+
- lib/generators/rspec/experiment/templates/experiment_spec.rb.tt
|
65
|
+
- lib/generators/test_unit/experiment/experiment_generator.rb
|
66
|
+
- lib/generators/test_unit/experiment/templates/experiment_test.rb.tt
|
45
67
|
- lib/gitlab/experiment.rb
|
46
68
|
- lib/gitlab/experiment/caching.rb
|
69
|
+
- lib/gitlab/experiment/callbacks.rb
|
47
70
|
- lib/gitlab/experiment/configuration.rb
|
48
71
|
- lib/gitlab/experiment/context.rb
|
72
|
+
- lib/gitlab/experiment/cookies.rb
|
49
73
|
- lib/gitlab/experiment/dsl.rb
|
50
74
|
- lib/gitlab/experiment/engine.rb
|
51
75
|
- lib/gitlab/experiment/variant.rb
|
File without changes
|
@@ -1,21 +0,0 @@
|
|
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
|