gitlab-experiment 0.2.3 → 0.4.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +79 -68
- 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} +16 -9
- 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 +81 -18
- data/lib/gitlab/experiment/caching.rb +9 -0
- data/lib/gitlab/experiment/callbacks.rb +39 -0
- data/lib/gitlab/experiment/configuration.rb +23 -6
- data/lib/gitlab/experiment/context.rb +29 -59
- data/lib/gitlab/experiment/cookies.rb +48 -0
- data/lib/gitlab/experiment/version.rb +1 -1
- metadata +35 -11
- 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: 740552e72dc8655bde106bbca76a2b5be2776196ad957c780cf06d2bc3324a11
|
4
|
+
data.tar.gz: 19f8fbc4588b8efcff729464b5f3480a9d41a0429f8ea8153d097a0c9c948aae
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: b4f4b3f0a7c56087a0bccaf916d568a82ad02cd7784ba94e86254f3c11fabc106cf5da171d889d95818fb4229b34ab9712ddddf597c2dc6075d724c3c08af80b
|
7
|
+
data.tar.gz: aa6c559ca3e62e810ffa8efd7b1846da0beefd6ad91c10e425d7ed1477eb3073cb792b909d39199cb5847ce1b25d9bf19189793190f6e5a519c43468a341fe02
|
data/README.md
CHANGED
@@ -1,9 +1,10 @@
|
|
1
|
-
GitLab Experiment
|
2
|
-
|
1
|
+
# GitLab Experiment
|
2
|
+
|
3
|
+
<img alt="experiment" src="/uploads/60990b2dbf4c0406bbf8b7f998de2dea/experiment.png" align="right" width="40%">
|
3
4
|
|
4
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.
|
5
6
|
|
6
|
-
This library provides a clean and elegant DSL to define, run, and track your GitLab experiment.
|
7
|
+
This library provides a clean and elegant DSL (domain specific language) to define, run, and track your GitLab experiment.
|
7
8
|
|
8
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.
|
9
10
|
|
@@ -13,25 +14,7 @@ When we discuss the behavior of this gem, we'll use terms like experiment, conte
|
|
13
14
|
- `candidate` defines that there's one experimental code path.
|
14
15
|
- `variant(s)` is used when more than one experimental code path exists.
|
15
16
|
|
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.
|
17
|
+
Candidate and variant are the same concept, but simplify how we speak about experimental paths.<br clear="all">
|
35
18
|
|
36
19
|
## Installation
|
37
20
|
|
@@ -44,18 +27,18 @@ gem 'gitlab-experiment'
|
|
44
27
|
If you're using Rails, you can install the initializer. It provides basic configuration and documentation.
|
45
28
|
|
46
29
|
```shell
|
47
|
-
$ rails generate gitlab
|
30
|
+
$ rails generate gitlab:experiment:install
|
48
31
|
```
|
49
32
|
|
50
33
|
## Implementing an experiment
|
51
34
|
|
52
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.
|
53
36
|
|
54
|
-
In our control (current world) we show a simple toggle interface
|
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.
|
38
|
+
|
39
|
+
The behavior will be the same, but the interface will be different and may involve more or fewer steps.
|
55
40
|
|
56
|
-
|
57
|
-
|
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.
|
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.
|
59
42
|
|
60
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.
|
61
44
|
|
@@ -63,12 +46,12 @@ When you implement an experiment you'll need to provide a name, and a context. T
|
|
63
46
|
|
64
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.
|
65
48
|
|
66
|
-
Now in our experiment we're going to render one of two views
|
67
|
-
|
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.
|
50
|
+
|
68
51
|
```ruby
|
69
52
|
class SubscriptionsController < ApplicationController
|
70
53
|
def show
|
71
|
-
experiment(:notification_toggle,
|
54
|
+
experiment(:notification_toggle, actor: user) do |e|
|
72
55
|
e.use { render_toggle } # control
|
73
56
|
e.try { render_button } # candidate
|
74
57
|
end
|
@@ -81,7 +64,7 @@ You can define the experiment using simple control/candidate paths, or provide n
|
|
81
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.
|
82
65
|
|
83
66
|
```ruby
|
84
|
-
experiment(:notification_toggle,
|
67
|
+
experiment(:notification_toggle, actor: user) do |e|
|
85
68
|
e.use { render_toggle } # control
|
86
69
|
e.try(:variant_one) { render_button(confirmation: true) }
|
87
70
|
e.try(:variant_two) { render_button(confirmation: false) }
|
@@ -93,7 +76,7 @@ Understanding how an experiment can change behavior is important in evaluating i
|
|
93
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.
|
94
77
|
|
95
78
|
```ruby
|
96
|
-
experiment(:notification_toggle,
|
79
|
+
experiment(:notification_toggle, actor: user).track(:clicked_button)
|
97
80
|
```
|
98
81
|
|
99
82
|
<details>
|
@@ -102,10 +85,10 @@ experiment(:notification_toggle, user_id: user.id).track(:clicked_button)
|
|
102
85
|
### Class level interface using `.run`
|
103
86
|
|
104
87
|
```ruby
|
105
|
-
exp = Gitlab::Experiment.run(:notification_toggle,
|
88
|
+
exp = Gitlab::Experiment.run(:notification_toggle, actor: user) do |e|
|
106
89
|
# Context may be passed in the block, but must be finalized before calling
|
107
90
|
# run or track.
|
108
|
-
e.context(
|
91
|
+
e.context(project: project) # add the project to the context
|
109
92
|
|
110
93
|
# Define the control and candidate variant.
|
111
94
|
e.use { render_toggle } # control
|
@@ -119,16 +102,16 @@ exp.track(:clicked_button)
|
|
119
102
|
### Instance level interface
|
120
103
|
|
121
104
|
```ruby
|
122
|
-
exp = Gitlab::Experiment.new(:notification_toggle,
|
123
|
-
#
|
124
|
-
# run or track.
|
125
|
-
exp.context(
|
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
|
126
109
|
|
127
110
|
# Define the control and candidate variant.
|
128
111
|
exp.use { render_toggle } # control
|
129
112
|
exp.try { render_button } # candidate
|
130
113
|
|
131
|
-
# Run the experiment
|
114
|
+
# Run the experiment, returning the result.
|
132
115
|
exp.run
|
133
116
|
|
134
117
|
# Track an event on the experiment we've defined.
|
@@ -153,10 +136,10 @@ class NotificationExperiment < Gitlab::Experiment
|
|
153
136
|
end
|
154
137
|
end
|
155
138
|
|
156
|
-
exp = NotificationExperiment.new(
|
157
|
-
# Context may be
|
158
|
-
# run or track.
|
159
|
-
e.context(
|
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
|
160
143
|
end
|
161
144
|
|
162
145
|
# Run the experiment -- returning the result.
|
@@ -176,7 +159,7 @@ exp.track(:clicked_button)
|
|
176
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.
|
177
160
|
|
178
161
|
```ruby
|
179
|
-
experiment(:notification_toggle, :no_interface,
|
162
|
+
experiment(:notification_toggle, :no_interface, actor: user) do |e|
|
180
163
|
e.use { render_toggle } # control
|
181
164
|
e.try { render_button } # candidate
|
182
165
|
e.try(:no_interface) { no_interface! } # variant
|
@@ -186,12 +169,23 @@ end
|
|
186
169
|
Or you can set the variant within the block. This allows using unique segmentation logic or variant resolution if you need it.
|
187
170
|
|
188
171
|
```ruby
|
189
|
-
experiment(:notification_toggle,
|
172
|
+
experiment(:notification_toggle, actor: user) do |e|
|
173
|
+
# Variant selection must be done before calling run or track.
|
190
174
|
e.variant(:no_interface) # set the variant
|
191
175
|
# ...
|
192
176
|
end
|
193
177
|
```
|
194
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
|
+
|
195
189
|
</details>
|
196
190
|
|
197
191
|
### Return value
|
@@ -215,13 +209,13 @@ Some experiments may extend outside of those layers, so you may want to include
|
|
215
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.
|
216
210
|
|
217
211
|
```ruby
|
218
|
-
class
|
212
|
+
class WelcomeMailer < ApplicationMailer
|
219
213
|
include Gitlab::Experiment::Dsl # include the `experiment` method
|
220
214
|
|
221
215
|
def welcome
|
222
216
|
@user = params[:user]
|
223
217
|
|
224
|
-
ex = experiment(:project_suggestions,
|
218
|
+
ex = experiment(:project_suggestions, actor: @user) do |e|
|
225
219
|
e.use { 'welcome' }
|
226
220
|
e.try { 'welcome_with_project_suggestions' }
|
227
221
|
end
|
@@ -235,57 +229,72 @@ end
|
|
235
229
|
|
236
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.
|
237
231
|
|
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.
|
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.
|
239
233
|
|
240
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.
|
241
235
|
|
242
236
|
```ruby
|
243
|
-
|
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 })
|
244
239
|
```
|
245
240
|
|
246
|
-
You can add or remove context by providing a `migrated_from` option. This approach expects a full context replacement -- e.
|
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.
|
247
242
|
|
248
243
|
If you wanted to introduce a `version` to your context, provide the full previous context.
|
249
|
-
|
244
|
+
|
250
245
|
```ruby
|
251
|
-
|
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 })
|
252
248
|
```
|
253
249
|
|
254
|
-
This can impact an experience if you
|
250
|
+
This can impact an experience if you:
|
255
251
|
|
256
|
-
1. implemented the concept of migrations in your variant resolver
|
252
|
+
1. haven't implemented the concept of migrations in your variant resolver
|
257
253
|
1. haven't enabled a reasonable caching mechanism
|
258
254
|
|
259
|
-
### When there isn't
|
255
|
+
### When there isn't an actor (cookie fallback)
|
260
256
|
|
261
|
-
When there isn't an identifying key in the context (this is `
|
257
|
+
When there isn't an identifying key in the context (this is `actor` by default), we fall back to cookies to provide a consistent experience for the client viewing them.
|
262
258
|
|
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.
|
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.
|
264
260
|
|
265
|
-
This cookie is a randomized uuid and isn't associated with
|
261
|
+
This cookie is a temporary, randomized uuid and isn't associated with a user. When we can finally provide an actor, the context is auto migrated from the cookie to that actor.
|
266
262
|
|
267
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.
|
268
264
|
|
269
265
|
You'll need to provide the `request` as an option to the experiment if it's outside of the controller and views.
|
270
266
|
|
271
267
|
```ruby
|
272
|
-
experiment(:my_experiment,
|
268
|
+
experiment(:my_experiment, actor: user, request: request)
|
273
269
|
```
|
274
270
|
|
275
|
-
The cookie isn't set if the
|
271
|
+
The cookie isn't set if the `actor` key isn't present at all in the context. Meaning that when no `actor` key is provided, the cookie will not be set.
|
272
|
+
|
273
|
+
```ruby
|
274
|
+
# actor is not present, so no cookie is set
|
275
|
+
experiment(:my_experiment, project: project)
|
276
276
|
|
277
|
-
|
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`.
|
278
285
|
|
279
286
|
## Configuration
|
280
287
|
|
281
288
|
This gem needs to be configured before being used in a meaningful way.
|
282
289
|
|
283
|
-
The default configuration will always render the control
|
290
|
+
The default configuration will always render the control, so it's important to configure your own logic for resolving variants.
|
284
291
|
|
285
|
-
Yes, the most important aspect of the gem
|
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.
|
286
293
|
|
287
294
|
```ruby
|
288
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.
|
289
298
|
config.variant_resolver = lambda do |requested_variant|
|
290
299
|
# Return the requested variant if a specific one has been provided in code.
|
291
300
|
return requested_variant unless requested_variant.nil?
|
@@ -298,20 +307,20 @@ Gitlab::Experiment.configure do |config|
|
|
298
307
|
end
|
299
308
|
```
|
300
309
|
|
301
|
-
More examples for configuration are available in the provided [rails initializer](lib/generators/
|
310
|
+
More examples for configuration are available in the provided [rails initializer](lib/generators/gitlab/experiment/install/templates/initializer.rb).
|
302
311
|
|
303
312
|
### Client layer / JavaScript
|
304
313
|
|
305
314
|
This library doesn't attempt to provide any logic for the client layer.
|
306
315
|
|
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.
|
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.
|
308
317
|
|
309
318
|
```ruby
|
310
319
|
Gitlab::Experiment.configure do |config|
|
311
320
|
config.publishing_behavior = lambda do |_result|
|
312
321
|
# Push the experiment knowledge into the front end. The signature contains
|
313
322
|
# the context key, and the variant that has been determined.
|
314
|
-
Gon.push(experiment: { name => signature })
|
323
|
+
Gon.push({ experiment: { name => signature } }, true)
|
315
324
|
end
|
316
325
|
end
|
317
326
|
```
|
@@ -322,6 +331,8 @@ In the client you can now access `window.gon.experiment.notificationToggle`.
|
|
322
331
|
|
323
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.
|
324
333
|
|
334
|
+
It's important to understand that using caching can drastically change or override your rollout strategy logic.
|
335
|
+
|
325
336
|
```ruby
|
326
337
|
Gitlab::Experiment.configure do |config|
|
327
338
|
config.cache = Rails.cache
|
@@ -330,11 +341,11 @@ end
|
|
330
341
|
|
331
342
|
## Tracking, anonymity and GDPR
|
332
343
|
|
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).
|
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).
|
334
345
|
|
335
346
|
We generate this key from the context passed to the experiment. This allows creating funnels without exposing any user information.
|
336
347
|
|
337
|
-
This library attempts to be non
|
348
|
+
This library attempts to be non-user-centric, in that a context can contain things like a user or a project.
|
338
349
|
|
339
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.
|
340
351
|
|
@@ -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,7 +5,10 @@ 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
|
+
|
10
|
+
# The base class that should be instantiated for basic experiments.
|
11
|
+
config.base_class = 'ApplicationExperiment'
|
9
12
|
|
10
13
|
# The caching layer is expected to respond to fetch, like Rails.cache.
|
11
14
|
config.cache = nil
|
@@ -26,7 +29,7 @@ Gitlab::Experiment.configure do |config|
|
|
26
29
|
#
|
27
30
|
# requested_variant || variant_names.first || 'control'
|
28
31
|
|
29
|
-
# Using
|
32
|
+
# Using Unleash to determine the variant:
|
30
33
|
#
|
31
34
|
# fallback = Unleash::Variant.new(name: requested_variant || 'control', enabled: true)
|
32
35
|
# Unleash.get_variant(name, context.value, fallback)
|
@@ -40,19 +43,17 @@ Gitlab::Experiment.configure do |config|
|
|
40
43
|
# Tracking behavior can be implemented to link an event to an experiment.
|
41
44
|
#
|
42
45
|
# 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
|
-
#
|
45
|
-
#
|
46
|
+
# experiment instance and so can access any methods on the experiment,
|
47
|
+
# such as name and signature.
|
46
48
|
config.tracking_behavior = lambda do |event, args|
|
47
49
|
# An example of using a generic logger to track events:
|
48
50
|
config.logger.info "Gitlab::Experiment[#{name}] #{event}: #{args.merge(signature: signature)}"
|
49
51
|
|
50
|
-
# Using something like snowplow to track events:
|
52
|
+
# Using something like snowplow to track events (in gitlab):
|
51
53
|
#
|
52
54
|
# Gitlab::Tracking.event(name, event, **args.merge(
|
53
55
|
# context: (args[:context] || []) << SnowplowTracker::SelfDescribingJson.new(
|
54
|
-
# 'iglu:com.gitlab/gitlab_experiment/jsonschema/0-2-0',
|
55
|
-
# signature: signature
|
56
|
+
# 'iglu:com.gitlab/gitlab_experiment/jsonschema/0-2-0', signature
|
56
57
|
# )
|
57
58
|
# ))
|
58
59
|
end
|
@@ -69,7 +70,7 @@ Gitlab::Experiment.configure do |config|
|
|
69
70
|
# Push the experiment knowledge into the front end. The signature contains
|
70
71
|
# the context key, and the variant that has been determined.
|
71
72
|
#
|
72
|
-
# Gon.push(experiment: { name => signature })
|
73
|
+
# Gon.push({ experiment: { name => signature } }, true)
|
73
74
|
|
74
75
|
# Log using our logging system, so the result (which can be large) can be
|
75
76
|
# reviewed later if we want to.
|
@@ -86,4 +87,10 @@ Gitlab::Experiment.configure do |config|
|
|
86
87
|
values = context.values.map { |v| (v.respond_to?(:to_global_id) ? v.to_global_id : v).to_s }
|
87
88
|
Digest::MD5.hexdigest((context.keys + values).join('|'))
|
88
89
|
end
|
90
|
+
|
91
|
+
# The domain for which this cookie applies so you can restrict to the domain level.
|
92
|
+
#
|
93
|
+
# When not set, it uses the current host. If you want to provide specific hosts, you can
|
94
|
+
# provide them either via an array like `['www.gitlab.com', .gitlab.com']`, or set it to `:all`.
|
95
|
+
config.cookie_domain = :all
|
89
96
|
end
|
@@ -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,37 +19,57 @@ 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
|
-
@
|
37
|
-
|
38
|
-
context(context)
|
62
|
+
@excluded = []
|
63
|
+
@context = Context.new(self, context)
|
39
64
|
|
40
|
-
|
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
|
@@ -54,11 +79,30 @@ module Gitlab
|
|
54
79
|
return @variant_name = value unless value.nil?
|
55
80
|
|
56
81
|
result = instance_exec(@variant_name, &Configuration.variant_resolver)
|
57
|
-
result.respond_to?(:name) ? result : Variant.new(name: result.to_s)
|
82
|
+
result.respond_to?(:name) ? result : Variant.new(name: (result.presence || :control).to_s)
|
58
83
|
end
|
59
84
|
|
60
|
-
def
|
61
|
-
@
|
85
|
+
def exclude(&block)
|
86
|
+
@excluded << block
|
87
|
+
end
|
88
|
+
|
89
|
+
def run(variant_name = nil)
|
90
|
+
@result ||= begin
|
91
|
+
@variant_name = variant_name unless variant_name.nil?
|
92
|
+
@variant_name ||= :control if excluded?
|
93
|
+
|
94
|
+
chain = variant_assigned? ? :unsegmented_run : :segmented_run
|
95
|
+
run_callbacks(chain) do
|
96
|
+
variant_name = cache { variant.name }
|
97
|
+
|
98
|
+
method_name = "#{variant_name}_behavior"
|
99
|
+
if respond_to?(method_name)
|
100
|
+
behaviors[variant_name] ||= -> { send(method_name) } # rubocop:disable GitlabSecurity/PublicSend
|
101
|
+
end
|
102
|
+
|
103
|
+
super(variant_name)
|
104
|
+
end
|
105
|
+
end
|
62
106
|
end
|
63
107
|
|
64
108
|
def publish(result)
|
@@ -66,6 +110,8 @@ module Gitlab
|
|
66
110
|
end
|
67
111
|
|
68
112
|
def track(action, **event_args)
|
113
|
+
return if excluded?
|
114
|
+
|
69
115
|
instance_exec(action, event_args, &Configuration.tracking_behavior)
|
70
116
|
end
|
71
117
|
|
@@ -74,19 +120,36 @@ module Gitlab
|
|
74
120
|
end
|
75
121
|
|
76
122
|
def variant_names
|
77
|
-
@variant_names ||= behaviors.keys.
|
123
|
+
@variant_names ||= behaviors.keys.map(&:to_sym) - [:control]
|
124
|
+
end
|
125
|
+
|
126
|
+
def signature
|
127
|
+
{ variant: variant.name, experiment: name }.merge(context.signature)
|
78
128
|
end
|
79
129
|
|
80
130
|
def enabled?
|
81
131
|
true
|
82
132
|
end
|
83
133
|
|
84
|
-
def
|
85
|
-
|
134
|
+
def excluded?
|
135
|
+
@excluded.any? { |exclude| exclude.call(self) }
|
136
|
+
end
|
137
|
+
|
138
|
+
def variant_assigned?
|
139
|
+
!@variant_name.nil?
|
140
|
+
end
|
141
|
+
|
142
|
+
def id
|
143
|
+
"#{name}:#{signature[:key]}"
|
144
|
+
end
|
145
|
+
alias_method :session_id, :id
|
146
|
+
|
147
|
+
def flipper_id
|
148
|
+
"Experiment;#{id}"
|
86
149
|
end
|
87
150
|
|
88
|
-
def
|
89
|
-
|
151
|
+
def key_for(hash)
|
152
|
+
instance_exec(hash, &Configuration.context_hash_strategy)
|
90
153
|
end
|
91
154
|
|
92
155
|
protected
|
@@ -10,6 +10,15 @@ module Gitlab
|
|
10
10
|
migrated_cache(cache, migrations || [], key) or cache.fetch(key, &block)
|
11
11
|
end
|
12
12
|
|
13
|
+
private
|
14
|
+
|
15
|
+
def cache_strategy
|
16
|
+
[
|
17
|
+
"#{name}:#{signature[:key]}",
|
18
|
+
signature[:migration_keys]&.map { |key| "#{name}:#{key}" }
|
19
|
+
]
|
20
|
+
end
|
21
|
+
|
13
22
|
def migrated_cache(cache, migrations, new_key)
|
14
23
|
migrations.find do |old_key|
|
15
24
|
next unless (value = cache.read(old_key))
|
@@ -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,7 +13,10 @@ 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
|
+
|
18
|
+
# The base class that should be instantiated for basic experiments.
|
19
|
+
@base_class = 'Gitlab::Experiment'
|
17
20
|
|
18
21
|
# Cache layer. Expected to respond to fetch, like Rails.cache.
|
19
22
|
@cache = nil
|
@@ -35,14 +38,28 @@ module Gitlab
|
|
35
38
|
end
|
36
39
|
|
37
40
|
# Algorithm that consistently generates a hash key for a given hash map.
|
38
|
-
@context_hash_strategy = lambda do |
|
39
|
-
values =
|
40
|
-
Digest::MD5.hexdigest((
|
41
|
+
@context_hash_strategy = lambda do |hash_map|
|
42
|
+
values = hash_map.values.map { |v| (v.respond_to?(:to_global_id) ? v.to_global_id : v).to_s }
|
43
|
+
Digest::MD5.hexdigest(([name] + hash_map.keys + values).join('|'))
|
41
44
|
end
|
42
45
|
|
46
|
+
# The domain for which this cookie applies so you can restrict to the domain level.
|
47
|
+
# When not set, it uses the current host. If you want to provide specific hosts, you can
|
48
|
+
# provide them either via an array like `['www.gitlab.com', .gitlab.com']`, or set it to `:all`.
|
49
|
+
@cookie_domain = :all
|
50
|
+
|
43
51
|
class << self
|
44
|
-
attr_accessor
|
45
|
-
|
52
|
+
attr_accessor(
|
53
|
+
:name_prefix,
|
54
|
+
:logger,
|
55
|
+
:base_class,
|
56
|
+
:cache,
|
57
|
+
:variant_resolver,
|
58
|
+
:tracking_behavior,
|
59
|
+
:publishing_behavior,
|
60
|
+
:context_hash_strategy,
|
61
|
+
:cookie_domain
|
62
|
+
)
|
46
63
|
end
|
47
64
|
end
|
48
65
|
end
|
@@ -3,95 +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.merge(key => cookie) if hash[key].blank?
|
69
|
-
|
70
|
-
@migrations_with << { key => cookie }
|
71
|
-
jar.delete(cookie_name, domain: :all)
|
72
|
-
|
73
|
-
hash
|
74
|
-
end
|
75
|
-
|
76
|
-
def generate_cookie(jar, hash, key, cookie = SecureRandom.uuid)
|
77
|
-
return hash unless hash.key?(key)
|
78
|
-
|
79
|
-
jar.permanent.signed[cookie_name] = {
|
80
|
-
value: cookie, secure: true, domain: :all, httponly: true
|
81
|
-
}
|
82
|
-
|
83
|
-
hash.merge(key => cookie)
|
57
|
+
@migrations[merge ? :merged : :unmerged] << value
|
84
58
|
end
|
85
59
|
|
86
60
|
def migration_keys
|
87
|
-
return nil if @
|
88
|
-
|
89
|
-
@migrations_from.map { |m| key_for(m) } +
|
90
|
-
@migrations_with.map { |m| key_for(@value.merge(m)) }
|
91
|
-
end
|
61
|
+
return nil if @migrations[:unmerged].empty? && @migrations[:merged].empty?
|
92
62
|
|
93
|
-
|
94
|
-
|
63
|
+
@migrations[:unmerged].map { |m| @experiment.key_for(m) } +
|
64
|
+
@migrations[:merged].map { |m| @experiment.key_for(@value.merge(m)) }
|
95
65
|
end
|
96
66
|
end
|
97
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,35 +1,49 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: gitlab-experiment
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.4.1
|
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-17 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
|
-
name:
|
14
|
+
name: activesupport
|
15
15
|
requirement: !ruby/object:Gem::Requirement
|
16
16
|
requirements:
|
17
17
|
- - ">="
|
18
18
|
- !ruby/object:Gem::Version
|
19
|
-
version:
|
20
|
-
- - "~>"
|
21
|
-
- !ruby/object:Gem::Version
|
22
|
-
version: '1.5'
|
19
|
+
version: '3.0'
|
23
20
|
type: :runtime
|
24
21
|
prerelease: false
|
25
22
|
version_requirements: !ruby/object:Gem::Requirement
|
26
23
|
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '3.0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: scientist
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '1.5'
|
27
34
|
- - ">="
|
28
35
|
- !ruby/object:Gem::Version
|
29
36
|
version: 1.5.0
|
37
|
+
type: :runtime
|
38
|
+
prerelease: false
|
39
|
+
version_requirements: !ruby/object:Gem::Requirement
|
40
|
+
requirements:
|
30
41
|
- - "~>"
|
31
42
|
- !ruby/object:Gem::Version
|
32
43
|
version: '1.5'
|
44
|
+
- - ">="
|
45
|
+
- !ruby/object:Gem::Version
|
46
|
+
version: 1.5.0
|
33
47
|
description:
|
34
48
|
email:
|
35
49
|
- gitlab_rubygems@gitlab.com
|
@@ -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
|
@@ -69,7 +93,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
69
93
|
- !ruby/object:Gem::Version
|
70
94
|
version: '0'
|
71
95
|
requirements: []
|
72
|
-
rubygems_version: 3.
|
96
|
+
rubygems_version: 3.1.4
|
73
97
|
signing_key:
|
74
98
|
specification_version: 4
|
75
99
|
summary: GitLab experiment library built on top of scientist.
|
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
|