gitlab-experiment 0.2.2 → 0.4.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +218 -82
- 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.tt +96 -0
- 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 +88 -14
- data/lib/gitlab/experiment/caching.rb +33 -0
- data/lib/gitlab/experiment/callbacks.rb +39 -0
- data/lib/gitlab/experiment/configuration.rb +29 -9
- data/lib/gitlab/experiment/context.rb +29 -46
- data/lib/gitlab/experiment/cookies.rb +48 -0
- data/lib/gitlab/experiment/version.rb +1 -1
- metadata +36 -11
- data/lib/generators/gitlab_experiment/install/POST_INSTALL +0 -0
- data/lib/generators/gitlab_experiment/install/install_generator.rb +0 -21
- data/lib/generators/gitlab_experiment/install/templates/initializer.rb +0 -77
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 8524dff7f908e481923e3131f3dd67c34f426e31ed10171896ea10a209e0b74a
|
4
|
+
data.tar.gz: 151f2e7f7bbf9692350c02f46f0c4c8cfa6f9c7ec05fdcb0fda50e34324c06fb
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 3f23698aabf3968c77f49cdb924125879663670c12d22c390a47f7c791687f87fb1211adf432777f66012e3be1db2e90d5c33a1dc0a156332bb8926938c4e587
|
7
|
+
data.tar.gz: 51973494136edef02672c9086970fec2ecb1fb3a6716edb10d1cfe758172f8d256ecd6ef1b5cefdd50133898637e022af527fc7e20df2212b1f7210db2dd087e
|
data/README.md
CHANGED
@@ -1,73 +1,82 @@
|
|
1
|
-
GitLab Experiment
|
2
|
-
=================
|
1
|
+
# GitLab Experiment
|
3
2
|
|
4
|
-
|
3
|
+
<img alt="experiment" src="/uploads/60990b2dbf4c0406bbf8b7f998de2dea/experiment.png" align="right" width="40%">
|
5
4
|
|
6
|
-
|
5
|
+
Here at GitLab, we run experiments as A/B/n tests and review the data the experiment generates. From that data, we determine the best performing variant and promote it as the new default code path. Or revert back to the control if no variant outperformed it.
|
6
|
+
|
7
|
+
This library provides a clean and elegant DSL (domain specific language) to define, run, and track your GitLab experiment.
|
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.
|
7
10
|
|
8
11
|
- `experiment` is any deviation of code paths we want to run sometimes and not others.
|
12
|
+
- `context` is used to identify a consistent experience we'll provide in an experiment.
|
9
13
|
- `control` is the default, or "original" code path.
|
10
|
-
- `candidate`
|
14
|
+
- `candidate` defines that there's one experimental code path.
|
11
15
|
- `variant(s)` is used when more than one experimental code path exists.
|
12
16
|
|
13
|
-
Candidate and variant are
|
17
|
+
Candidate and variant are the same concept, but simplify how we speak about experimental paths.<br clear="all">
|
14
18
|
|
15
19
|
## Installation
|
16
20
|
|
17
|
-
Add the gem to your Gemfile and then bundle install
|
21
|
+
Add the gem to your Gemfile and then `bundle install`.
|
18
22
|
|
19
23
|
```ruby
|
20
24
|
gem 'gitlab-experiment'
|
21
25
|
```
|
22
26
|
|
23
|
-
If you're using Rails, you can install
|
27
|
+
If you're using Rails, you can install the initializer. It provides basic configuration and documentation.
|
24
28
|
|
25
29
|
```shell
|
26
|
-
$ rails generate gitlab
|
30
|
+
$ rails generate gitlab:experiment:install
|
27
31
|
```
|
28
32
|
|
29
33
|
## Implementing an experiment
|
30
34
|
|
31
|
-
For the sake of
|
35
|
+
For the sake of an example let's make one up. Let's run an experiment on what we render for disabling desktop notifications.
|
36
|
+
|
37
|
+
In our control (current world) we show a simple toggle interface labeled, "Notifications." In our experiment we want a "Turn on/off desktop notifications" button with a confirmation.
|
38
|
+
|
39
|
+
The behavior will be the same, but the interface will be different and may involve more or fewer steps.
|
40
|
+
|
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.
|
32
42
|
|
33
|
-
|
34
|
-
|
35
|
-
We hypothesize that making it more clear what the action is will help people in making a choice about taking that action or not.
|
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.
|
36
44
|
|
37
|
-
|
45
|
+
When you implement an experiment you'll need to provide a name, and a context. The name can show up in tracking calls, and potentially other aspects. The context determines the variant assigned, and should be consistent between calls. We'll discuss migrating context in later examples.
|
38
46
|
|
39
|
-
|
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.
|
48
|
+
|
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.
|
40
50
|
|
41
|
-
In our experiment we're going to render one of two views. Control will be our current one, and candidate will be the new view with a cancel button and javascript confirm call.
|
42
|
-
|
43
51
|
```ruby
|
44
52
|
class SubscriptionsController < ApplicationController
|
45
|
-
include Gitlab::Experiment::Dsl
|
46
|
-
|
47
53
|
def show
|
48
|
-
experiment(:
|
49
|
-
e.use {
|
50
|
-
e.try {
|
54
|
+
experiment(:notification_toggle, actor: user) do |e|
|
55
|
+
e.use { render_toggle } # control
|
56
|
+
e.try { render_button } # candidate
|
51
57
|
end
|
52
58
|
end
|
53
59
|
end
|
54
60
|
```
|
55
61
|
|
56
|
-
You can
|
62
|
+
You can define the experiment using simple control/candidate paths, or provide named variants.
|
63
|
+
|
64
|
+
Handling multi-variant experiments is up to the configuration you provide around resolving variants. But in our example we may want to try with and without the confirmation. We can run any number of variations in our experiments this way.
|
57
65
|
|
58
66
|
```ruby
|
59
|
-
experiment(:
|
60
|
-
e.use {
|
61
|
-
e.try(:variant_one) {
|
62
|
-
e.try(:variant_two) {
|
67
|
+
experiment(:notification_toggle, actor: user) do |e|
|
68
|
+
e.use { render_toggle } # control
|
69
|
+
e.try(:variant_one) { render_button(confirmation: true) }
|
70
|
+
e.try(:variant_two) { render_button(confirmation: false) }
|
63
71
|
end
|
64
72
|
```
|
65
73
|
|
66
|
-
|
74
|
+
Understanding how an experiment can change behavior is important in evaluating its performance.
|
75
|
+
|
76
|
+
To this end, we track events that are important by calling the same experiment elsewhere in code. By using the same context, you'll have consistent behavior and the ability to track events to it.
|
67
77
|
|
68
78
|
```ruby
|
69
|
-
|
70
|
-
exp.track('clicked_button')
|
79
|
+
experiment(:notification_toggle, actor: user).track(:clicked_button)
|
71
80
|
```
|
72
81
|
|
73
82
|
<details>
|
@@ -76,14 +85,14 @@ exp.track('clicked_button')
|
|
76
85
|
### Class level interface using `.run`
|
77
86
|
|
78
87
|
```ruby
|
79
|
-
exp = Gitlab::Experiment.run(:
|
80
|
-
# Context may be passed in the block, but must be finalized before calling
|
88
|
+
exp = Gitlab::Experiment.run(:notification_toggle, actor: user) do |e|
|
89
|
+
# Context may be passed in the block, but must be finalized before calling
|
81
90
|
# run or track.
|
82
|
-
e.context(
|
83
|
-
|
91
|
+
e.context(project: project) # add the project to the context
|
92
|
+
|
84
93
|
# Define the control and candidate variant.
|
85
|
-
e.use {
|
86
|
-
e.try {
|
94
|
+
e.use { render_toggle } # control
|
95
|
+
e.try { render_button } # candidate
|
87
96
|
end
|
88
97
|
|
89
98
|
# Track an event on the experiment we've defined.
|
@@ -93,16 +102,16 @@ exp.track(:clicked_button)
|
|
93
102
|
### Instance level interface
|
94
103
|
|
95
104
|
```ruby
|
96
|
-
exp = Gitlab::Experiment.new(:
|
97
|
-
#
|
98
|
-
# run or track.
|
99
|
-
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
|
100
109
|
|
101
110
|
# Define the control and candidate variant.
|
102
|
-
exp.use {
|
103
|
-
exp.try {
|
111
|
+
exp.use { render_toggle } # control
|
112
|
+
exp.try { render_button } # candidate
|
104
113
|
|
105
|
-
# Run the experiment
|
114
|
+
# Run the experiment, returning the result.
|
106
115
|
exp.run
|
107
116
|
|
108
117
|
# Track an event on the experiment we've defined.
|
@@ -112,25 +121,25 @@ exp.track(:clicked_button)
|
|
112
121
|
</details>
|
113
122
|
|
114
123
|
<details>
|
115
|
-
<summary>You can define use custom classes...</summary>
|
124
|
+
<summary>You can define and use custom classes...</summary>
|
116
125
|
|
117
126
|
### Custom class
|
118
127
|
|
119
128
|
```ruby
|
120
|
-
class
|
129
|
+
class NotificationExperiment < Gitlab::Experiment
|
121
130
|
def initialize(variant_name = nil, **context, &block)
|
122
|
-
super(:
|
131
|
+
super(:notification_toggle, variant_name, **context, &block)
|
123
132
|
|
124
133
|
# Define the control and candidate variant.
|
125
|
-
use {
|
126
|
-
try {
|
134
|
+
use { render_toggle } # control
|
135
|
+
try { render_button } # candidate
|
127
136
|
end
|
128
137
|
end
|
129
138
|
|
130
|
-
exp =
|
131
|
-
# Context may be
|
132
|
-
# run or track.
|
133
|
-
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
|
134
143
|
end
|
135
144
|
|
136
145
|
# Run the experiment -- returning the result.
|
@@ -143,76 +152,203 @@ exp.track(:clicked_button)
|
|
143
152
|
</details>
|
144
153
|
|
145
154
|
<details>
|
146
|
-
<summary>You can also
|
155
|
+
<summary>You can also specify the variant to use...</summary>
|
147
156
|
|
148
|
-
### Specifying
|
157
|
+
### Specifying variant
|
149
158
|
|
150
|
-
|
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.
|
151
160
|
|
152
161
|
```ruby
|
153
|
-
experiment(:
|
154
|
-
e.use {
|
155
|
-
e.try {
|
162
|
+
experiment(:notification_toggle, :no_interface, actor: user) do |e|
|
163
|
+
e.use { render_toggle } # control
|
164
|
+
e.try { render_button } # candidate
|
156
165
|
e.try(:no_interface) { no_interface! } # variant
|
157
166
|
end
|
158
167
|
```
|
159
168
|
|
160
|
-
Or you can set the variant within the block
|
169
|
+
Or you can set the variant within the block. This allows using unique segmentation logic or variant resolution if you need it.
|
161
170
|
|
162
171
|
```ruby
|
163
|
-
experiment(:
|
172
|
+
experiment(:notification_toggle, actor: user) do |e|
|
173
|
+
# Variant selection must be done before calling run or track.
|
164
174
|
e.variant(:no_interface) # set the variant
|
165
175
|
# ...
|
166
176
|
end
|
167
177
|
```
|
168
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
|
+
|
169
189
|
</details>
|
170
190
|
|
171
|
-
|
191
|
+
### Return value
|
192
|
+
|
193
|
+
By default the return value is a `Gitlab::Experiment` instance. In simple cases you may want only the results of the experiment though. You can call `run` within the block to get the return value of the assigned variant.
|
194
|
+
|
195
|
+
```ruby
|
196
|
+
experiment(:notification_toggle) do |e|
|
197
|
+
e.use { 'A' }
|
198
|
+
e.try { 'B' }
|
199
|
+
e.run
|
200
|
+
end # => 'A'
|
201
|
+
```
|
202
|
+
|
203
|
+
### Including the DSL
|
204
|
+
|
205
|
+
By default, `Gitlab::Experiment` injects itself into the controller and view layers. This exposes the `experiment` method application wide in those layers.
|
206
|
+
|
207
|
+
Some experiments may extend outside of those layers, so you may want to include it elsewhere. For instance in a mailer, service object, background job, or similar.
|
208
|
+
|
209
|
+
Note: In a lot of these contexts you may not have a reference to the request (unless you pass it in, or provide access to it) which may be needed if you want to enable cookie behaviors and track that through to user conversion.
|
210
|
+
|
211
|
+
```ruby
|
212
|
+
class WelcomeMailer < ApplicationMailer
|
213
|
+
include Gitlab::Experiment::Dsl # include the `experiment` method
|
214
|
+
|
215
|
+
def welcome
|
216
|
+
@user = params[:user]
|
217
|
+
|
218
|
+
ex = experiment(:project_suggestions, actor: @user) do |e|
|
219
|
+
e.use { 'welcome' }
|
220
|
+
e.try { 'welcome_with_project_suggestions' }
|
221
|
+
end
|
222
|
+
|
223
|
+
mail(to: @user.email, subject: 'Welcome!', template: ex.run)
|
224
|
+
end
|
225
|
+
end
|
226
|
+
```
|
172
227
|
|
173
228
|
### Context migrations
|
174
229
|
|
175
|
-
There are times when we
|
230
|
+
There are times when we need to change context while an experiment is running. We make this possible by passing the migration data to the experiment.
|
231
|
+
|
232
|
+
Take for instance, that you might be using `version: 1` in your context currently. To migrate this to `version: 2`, provide the portion of the context you wish to change using a `migrated_with` option.
|
233
|
+
|
234
|
+
In providing the context migration data, we can resolve an experience and its events all the way back. This can also help in keeping our cache relevant.
|
235
|
+
|
236
|
+
```ruby
|
237
|
+
# Migrate just the `:version` portion of the previous context, `{ actor: project, version: 1 }`:
|
238
|
+
experiment(:my_experiment, actor: project, version: 2, migrated_with: { version: 1 })
|
239
|
+
```
|
240
|
+
|
241
|
+
You can add or remove context by providing a `migrated_from` option. This approach expects a full context replacement -- i.e. what it was before you added or removed the new context key.
|
176
242
|
|
177
|
-
|
243
|
+
If you wanted to introduce a `version` to your context, provide the full previous context.
|
178
244
|
|
179
245
|
```ruby
|
180
|
-
|
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 })
|
181
248
|
```
|
182
249
|
|
183
|
-
|
184
|
-
|
250
|
+
This can impact an experience if you:
|
251
|
+
|
252
|
+
1. haven't implemented the concept of migrations in your variant resolver
|
253
|
+
1. haven't enabled a reasonable caching mechanism
|
254
|
+
|
255
|
+
### When there isn't an actor (cookie fallback)
|
256
|
+
|
257
|
+
When there isn't an identifying key in the context (this is `actor` by default), we fall back to cookies to provide a consistent experience for the client viewing them.
|
258
|
+
|
259
|
+
Once we assign a certain variant to a context, we need to always provide the same experience. We achieve this by setting a cookie for the experiment in question, but only when needed.
|
260
|
+
|
261
|
+
This cookie is a temporary, randomized uuid and isn't associated with a user. When we can finally provide an actor, the context is auto migrated from the cookie to that actor.
|
262
|
+
|
263
|
+
To read and write cookies, we provide the `request` from within the controller and views. The cookie migration will happen automatically if the experiment is within those layers.
|
264
|
+
|
265
|
+
You'll need to provide the `request` as an option to the experiment if it's outside of the controller and views.
|
266
|
+
|
185
267
|
```ruby
|
186
|
-
experiment(:my_experiment,
|
268
|
+
experiment(:my_experiment, actor: user, request: request)
|
187
269
|
```
|
188
270
|
|
189
|
-
|
271
|
+
The cookie isn't set if the `actor` key isn't present at all in the context. Meaning that when no `actor` key is provided, the cookie will not be set.
|
272
|
+
|
273
|
+
```ruby
|
274
|
+
# actor is not present, so no cookie is set
|
275
|
+
experiment(:my_experiment, project: project)
|
276
|
+
|
277
|
+
# actor is present and is nil, so the cookie is set and used
|
278
|
+
experiment(:my_experiment, actor: nil, project: project)
|
279
|
+
|
280
|
+
# actor is present and set to a value, so no cookie is set
|
281
|
+
experiment(:my_experiment, actor: user, project: project)
|
282
|
+
```
|
283
|
+
|
284
|
+
For edge cases, you can pass the cookie through by assigning it yourself -- e.g. `actor: request.cookie_jar.signed['my_experiment_actor']`. The cookie name is the full experiment name (including any configured prefix) with `_actor` appended -- e.g. `gitlab_notification_toggle_actor` for the `:notification_toggle` experiment key with a configured prefix of `gitlab`.
|
285
|
+
|
286
|
+
## Configuration
|
287
|
+
|
288
|
+
This gem needs to be configured before being used in a meaningful way.
|
289
|
+
|
290
|
+
The default configuration will always render the control, so it's important to configure your own logic for resolving variants.
|
291
|
+
|
292
|
+
Yes, the most important aspect of the gem -- that of determining which variant to render and when -- is up to you. Consider using [Unleash](https://github.com/Unleash/unleash-client-ruby) or [Flipper](https://github.com/jnunemaker/flipper) for this.
|
293
|
+
|
294
|
+
```ruby
|
295
|
+
Gitlab::Experiment.configure do |config|
|
296
|
+
# The block here is evaluated within the scope of the experiment instance,
|
297
|
+
# which is why we are able to access things like name and context.
|
298
|
+
config.variant_resolver = lambda do |requested_variant|
|
299
|
+
# Return the requested variant if a specific one has been provided in code.
|
300
|
+
return requested_variant unless requested_variant.nil?
|
301
|
+
|
302
|
+
# Ask Unleash to determine the variant, given the context we've built,
|
303
|
+
# using the control as the fallback.
|
304
|
+
fallback = Unleash::Variant.new(name: 'control', enabled: true)
|
305
|
+
UNLEASH.get_variant(name, context.value, fallback)
|
306
|
+
end
|
307
|
+
end
|
308
|
+
```
|
190
309
|
|
191
|
-
|
310
|
+
More examples for configuration are available in the provided [rails initializer](lib/generators/gitlab/experiment/install/templates/initializer.rb).
|
192
311
|
|
193
|
-
|
312
|
+
### Client layer / JavaScript
|
194
313
|
|
195
|
-
This
|
314
|
+
This library doesn't attempt to provide any logic for the client layer.
|
196
315
|
|
197
|
-
|
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.
|
198
317
|
|
199
318
|
```ruby
|
200
|
-
|
201
|
-
|
202
|
-
|
319
|
+
Gitlab::Experiment.configure do |config|
|
320
|
+
config.publishing_behavior = lambda do |_result|
|
321
|
+
# Push the experiment knowledge into the front end. The signature contains
|
322
|
+
# the context key, and the variant that has been determined.
|
323
|
+
Gon.push({ experiment: { name => signature } }, true)
|
324
|
+
end
|
203
325
|
end
|
204
|
-
```
|
326
|
+
```
|
205
327
|
|
206
|
-
|
328
|
+
In the client you can now access `window.gon.experiment.notificationToggle`.
|
207
329
|
|
208
|
-
|
330
|
+
### Caching
|
209
331
|
|
210
|
-
|
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.
|
211
333
|
|
212
|
-
|
334
|
+
It's important to understand that using caching can drastically change or override your rollout strategy logic.
|
213
335
|
|
214
|
-
|
336
|
+
```ruby
|
337
|
+
Gitlab::Experiment.configure do |config|
|
338
|
+
config.cache = Rails.cache
|
339
|
+
end
|
340
|
+
```
|
215
341
|
|
216
342
|
## Tracking, anonymity and GDPR
|
217
343
|
|
218
|
-
We
|
344
|
+
We generally try not to track things like user identifying values in our experimentation. What we can and do track is the "experiment experience" (a.k.a. the context key).
|
345
|
+
|
346
|
+
We generate this key from the context passed to the experiment. This allows creating funnels without exposing any user information.
|
347
|
+
|
348
|
+
This library attempts to be non-user-centric, in that a context can contain things like a user or a project.
|
349
|
+
|
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.
|
351
|
+
|
352
|
+
Each of these approaches could be desirable given the objectives of your experiment.
|
353
|
+
|
354
|
+
### 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
|