gitlab-experiment 0.6.5 → 0.7.0

Sign up to get free protection for your applications and to get access to all the features.
data/README.md CHANGED
@@ -3,9 +3,11 @@ GitLab Experiment
3
3
 
4
4
  <img alt="experiment" src="/uploads/60990b2dbf4c0406bbf8b7f998de2dea/experiment.png" align="right" width="40%">
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. You can read our [Experiment Guide](https://docs.gitlab.com/ee/development/experiment_guide/) docs if you're curious about how we use this gem internally.
6
+ This README represents the current main branch and may not be applicable to the release you're using in your project. Please refer to the correct release branch if you'd like to review documentation relevant for that release.
7
7
 
8
- This library provides a clean and elegant DSL (domain specific language) to define, run, and track your GitLab experiment.
8
+ 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 code path and promote it as the new default code path, or revert back to the original code path. You can read our [Experiment Guide](https://docs.gitlab.com/ee/development/experiment_guide/) documentation if you're curious about how we use this gem internally at GitLab.
9
+
10
+ This library provides a clean and elegant DSL (domain specific language) to define, run, and track your experiments.
9
11
 
10
12
  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.
11
13
 
@@ -14,8 +16,12 @@ When we discuss the behavior of this gem, we'll use terms like experiment, conte
14
16
  - `control` is the default, or "original" code path.
15
17
  - `candidate` defines that there's one experimental code path.
16
18
  - `variant(s)` is used when more than one experimental code path exists.
19
+ - `behaviors` is used to reference all possible code paths of an experiment.
20
+
21
+ Candidate and variant are the same concept, but simplify how we speak about experimental paths -- both are widely referred to as the "experiment group". If you use "control and candidate," the assumption is an A/B test, and if you say "variants" the assumption is a multiple variant experiment.
17
22
 
18
- Candidate and variant are the same concept, but simplify how we speak about experimental paths -- this concept is also widely referred to as the "experiment group".<br clear="all">
23
+ Behaviors is a general term for all code paths -- if that's control and candidate, or control and variants. It's all of them.
24
+ <br clear="all">
19
25
 
20
26
  [[_TOC_]]
21
27
 
@@ -35,253 +41,320 @@ $ rails generate gitlab:experiment:install
35
41
 
36
42
  ## Implementing an experiment
37
43
 
38
- For the sake of an example let's make one up. Let's run an experiment on what we render for disabling desktop notifications.
39
-
40
- 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.
41
-
42
- The behavior will be the same, but the interface will be different and may involve more or fewer steps.
44
+ For the sake of having a simple example let's define an experiment around a button color, even if it's not how we'd likely implement a real experiment it makes for a clean example.
43
45
 
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.
46
+ Currently our button is blue, but we want to test out a red variant. Our hypothesis is that a red button will make it more visible but also might appear like a warning button -- this is why we're testing our hypothesis.
45
47
 
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.
48
+ So let's name our experiment `pill_color`, and use the generator to get some files:
47
49
 
48
- 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.
50
+ ```shell
51
+ $ rails generate gitlab:experiment pill_color
52
+ ```
49
53
 
50
- 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. This is how an experiment remains "sticky" to a given context.
54
+ This generator will give us an `app/experiments/pill_color_experiment.rb` file, which we can use to define our experiment class and register our default variant behaviors. The generator also provides a bunch of useful comments in this file unless you `--skip-comments`, so feel free to play around with that.
51
55
 
52
- 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.
56
+ Let's fill out our control and candidate behaviors with some default values to start. For our example it'll just be some made up class names where we've defined our colors.
53
57
 
54
58
  ```ruby
55
- class SubscriptionsController < ApplicationController
56
- def show
57
- experiment(:notification_toggle, actor: user) do |e|
58
- e.use { render_toggle } # control
59
- e.try { render_button } # candidate
60
- end
61
- end
59
+ class PillColorExperiment < ApplicationExperiment
60
+ control { 'blue' } # register and define our default control value
61
+ candidate { 'red' } # register and define our experimental candidate value
62
62
  end
63
63
  ```
64
64
 
65
- You can define the experiment using simple control/candidate paths, or provide named variants.
66
-
67
- Handling [multivariate](https://en.wikipedia.org/wiki/Multivariate_statistics) 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.
68
-
69
- ```ruby
70
- experiment(:notification_toggle, actor: user) do |e|
71
- e.use { render_toggle } # control
72
- e.try(:variant_one) { render_button(confirmation: true) }
73
- e.try(:variant_two) { render_button(confirmation: false) }
74
- end
75
- ```
65
+ Now that we've defined our default behaviors, we can utilize our experiment elsewhere by calling the `experiment` method. When you run (or publish) an experiment you'll need to provide a name and a context. The name is how our `PillColorExperiment` class is resolved and will also show up in event data downstream. The context is a hash that's used to determine the variant assigned and should be consistent between calls.
76
66
 
77
- You can specify what the experiment should be "sticky" to by providing a `:sticky_to` option. By default this will be the entire context provided, but this can be overridden manually if needed.
67
+ For our experiment we're going to make it "sticky" to the current user -- and if there isn't a current user, we want to assign and use a cookie value instead. This happens automatically if you use the [`actor` keyword](#cookies-and-the-actor-keyword) in your context. We use that in a lot of our examples, but it's by no means how everything should be done.
78
68
 
79
- ```ruby
80
- experiment(:notification_toggle, actor: user, project: project, sticky_to: project) #...
69
+ ```haml
70
+ %button{ class: experiment(:pill_color, actor: current_user).run } Click Me!
81
71
  ```
82
72
 
83
- Understanding how an experiment can change behavior is important in evaluating its performance.
73
+ Now when our view is rendered, the class attribute of the button will be pulled from the experiment and will be sticky to the user that's viewing it.
84
74
 
85
- 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.
75
+ You can also provide behavior overrides when you run the experiment. Let's use a view helper for this example because it'll be cleaner than putting our override blocks into the view.
86
76
 
87
77
  ```ruby
88
- experiment(:notification_toggle, actor: user).track(:clicked_button)
78
+ def pill_color_experiment
79
+ experiment(:pill_color, actor: current_user) do |e|
80
+ e.candidate { 'purple' } # override the candidate default of 'red'
81
+ end
82
+ end
89
83
  ```
90
84
 
91
- ### Custom experiments
85
+ Now we can run the experiment using that helper method. Instead of a red button, users who are assigned the candidate will be given a purple button. Users in the control group will still see the blue button. Using experiments in this way permits a default experiment to be defined while also allowing the experiment to be customized in the places where its run, using the scope, variables and helper methods that are available to us where we want to run the experiment.
92
86
 
93
- 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`).
87
+ ```haml
88
+ %button{ class: pill_color_experiment.run } Click Me!
89
+ ```
94
90
 
95
- 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.
91
+ Understanding how an experiment can change user behavior or engagement is important in evaluating its performance. To this end, you'll probably want to track events that are important elsewhere in code. By using the same context you can provide a consistent experience and have the ability to anonymously track events to that experience.
96
92
 
97
- You can generate a custom experiment by running:
93
+ ```ruby
94
+ experiment(:pill_color, actor: current_user).track(:clicked)
95
+ ```
98
96
 
99
- ```shell
100
- $ rails generate gitlab:experiment NotificationToggle control candidate
101
- ```
97
+ ## Advanced experimentation
102
98
 
103
- This will generate a file in `app/experiments/notification_toggle_experiment.rb`, as well as a test file for you to further expand on.
99
+ Now let's create a more complex experiment with multiple variants. This time we'll start with a grey button, and have red and blue variants.
104
100
 
105
- Here are some examples of what you can introduce once you have a custom experiment defined.
101
+ We'll also do more advanced segmentation when the experiment is run, exclude certain cases, and register callbacks that will be executed after our experiment is run. Here are some examples of what we can start to do in our experiment classes:
106
102
 
107
103
  ```ruby
108
- class NotificationToggleExperiment < ApplicationExperiment
109
- # Exclude any users that aren't me.
104
+ class PillColorExperiment < Gitlab::Experiment # OR ApplicationExperiment
105
+ # Register our behaviors.
106
+ control # register our control, which will by default call #control_behavior
107
+ variant(:red) # register the red variant that will call #red_behavior
108
+ variant(:blue) # register the blue variant that will call #blue_behavior
109
+
110
+ # Exclude any users that are named "Richard".
110
111
  exclude :users_named_richard
111
112
 
112
- # Segment any account older than 2 weeks into the candidate, without
113
- # asking the variant resolver to decide which variant to provide.
114
- segment :old_account?, variant: :candidate
113
+ # Segment any account older than 2 weeks into the red variant without asking
114
+ # the rollout strategy to assign a variant.
115
+ segment :old_account?, variant: :red
116
+
117
+ # After the experiment has been run, we want to log some performance metrics.
118
+ after_run { log_performance_metrics }
115
119
 
116
- # Define the default control behavior, which can be overridden at
117
- # experiment time.
120
+ private
121
+
122
+ # Define the default control behavior, which can be overridden on runs.
118
123
  def control_behavior
119
- # render_toggle
124
+ 'grey'
120
125
  end
121
126
 
122
- # Define the default candidate behavior, which can be overridden
123
- # at experiment time.
124
- def candidate_behavior
125
- # render_button
127
+ # Define the default red behavior, which can be overridden on runs.
128
+ def red_behavior
129
+ 'red'
126
130
  end
127
131
 
128
- private
132
+ # Define the default blue behavior, which can be overridden on runs.
133
+ def blue_behavior
134
+ 'blue'
135
+ end
129
136
 
137
+ # Define our special exclusion logic.
130
138
  def users_named_richard
131
- context.actor.first_name == 'Richard'
139
+ context.try(:actor)&.first_name == 'Richard' # use try for nil actors
132
140
  end
133
141
 
142
+ # Define our segmentation logic.
134
143
  def old_account?
135
- context.actor.created_at < 2.weeks.ago
144
+ context.try(:actor) && context.actor.created_at < 2.weeks.ago
145
+ end
146
+
147
+ # Let's assume that we've tracked this and want to push it into some system.
148
+ def log_performance_metrics
149
+ # ...hypothetical implementation
136
150
  end
137
151
  end
152
+ ```
153
+
154
+ You can play around with our new `PillColorExperiment` using a console or irb session. In our example we've used an actual `User` model with a first name and timestamps. Feel free to use something more appropriate for your project in your exploration, or just not pass in `actor` at all.
155
+
156
+ ```ruby
157
+ include Gitlab::Experiment::Dsl
138
158
 
139
159
  # The class will be looked up based on the experiment name provided.
140
- exp = experiment(:notification_toggle, actor: user)
141
- exp # => instance of NotificationToggleExperiment
160
+ ex = experiment(:pill_color, actor: User.first) # => #<PillColorExperiment:0x...>
142
161
 
143
162
  # Run the experiment -- returning the result.
144
- exp.run
163
+ ex.run # => "grey" (the value defined in our control)
145
164
 
146
- # Track an event on the experiment we've defined.
147
- exp.track(:clicked_button)
148
- ```
165
+ # Track an event on the experiment we've defined, using the logic we've defined
166
+ # in configuration.
167
+ ex.track(:clicked) # => true
149
168
 
150
- 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.
151
-
152
- ```ruby
153
- experiment(:notification_toggle, actor: user) do |e|
154
- e.use { render_special_toggle } # override default control behavior
155
- end
169
+ # Publish the experiment without running it, using the logic we've defined in
170
+ # configuration.
171
+ ex.publish # => {:variant=>"control", :experiment=>"pill_color", :key=>"45f595...", :excluded=>false}
156
172
  ```
157
173
 
158
174
  <details>
159
- <summary>You can also specify the variant to use for segmentation...</summary>
175
+ <summary>You can also specify the variant manually...</summary>
160
176
 
161
- 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.
177
+ 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.
162
178
 
163
- Any time a specific variant is provided (including `:control`) it will be cached for that context, if caching is enabled.
179
+ Caching: It's important to understand what this might do to your data during rollout, so use this with careful consideration. Any time a specific variant is assigned manually, or through segmentation (including `:control`) it will be cached for that context. That means that if you manually assign `:control`, that context will never be moved out of the control unless you do it programmatically elsewhere.
164
180
 
165
181
  ```ruby
166
- experiment(:notification_toggle, :no_interface, actor: user) do |e|
167
- e.use { render_toggle } # control
168
- e.try { render_button } # candidate
169
- e.try(:no_interface) { no_interface! } # no_interface variant
170
- end
182
+ include Gitlab::Experiment::Dsl
183
+
184
+ # Assign the candidate manually.
185
+ ex = experiment(:pill_color, :red, actor: User.first) # => #<PillColorExperiment:0x..>
186
+
187
+ # Run the experiment -- returning the result.
188
+ ex.run # => "red"
189
+
190
+ # If caching is enabled this will remain sticky between calls.
191
+ experiment(:pill_color, actor: User.first).run # => "red"
171
192
  ```
172
193
 
173
- Or you can set the variant within the block. This allows using unique segmentation logic or variant resolution if you need it.
194
+ </details>
195
+
196
+ ### Exclusion rules
197
+
198
+ Exclusion rules let us determine if a context should even be considered as something to include in an experiment. If we're excluding something, it means that we don't want to run the experiment in that case. This can be useful if you only want to run experiments on new users for instance.
174
199
 
175
200
  ```ruby
176
- experiment(:notification_toggle, actor: user) do |e|
177
- # Variant selection must be done before calling run or track.
178
- e.variant(:no_interface) # set the variant
179
- # ...
201
+ class PillColorExperiment < Gitlab::Experiment # OR ApplicationExperiment
202
+ # ...registered behaviors
203
+
204
+ exclude :old_account?, ->{ context.actor.first_name == 'Richard' }
180
205
  end
181
206
  ```
182
207
 
183
- Or it can be specified in the call to run if you call it from within the block.
208
+ In the previous example, we'll exclude all users named `'Richard'` as well as any account older than 2 weeks old. Not only will they be immediately given the control behavior, but no events will be tracked in these cases either.
209
+
210
+ Exclusion rules are executed in the order they're defined. The first exclusion rule to produce a truthy result will halt execution of further exclusion checks.
211
+
212
+ Note: Although tracking calls will be ignored on all exclusions, you may want to check exclusion yourself in expensive custom logic by calling the `should_track?` or `excluded?` methods.
213
+
214
+ Note: When using exclusion rules it's important to understand that the control assignment is cached, which improves future experiment run performance but can be a gotcha around caching.
215
+
216
+ Note: Exclusion rules aren't the best way to determine if an experiment is enabled. There's an `enabled?` method that can be overridden to have a high-level way of determining if an experiment should be running and tracking at all. This `enabled?` check should be as efficient as possible because it's the first early opt out path an experiment can implement. This can be seen in [How it works](#how-it-works).
217
+
218
+ ### Segmentation rules
219
+
220
+ Segmentation, or assigning certain variants in certain cases, is important to running experiments. This can be useful if you want to push a given population into a specific variant because you've already determined that variant is successful for that population.
184
221
 
185
222
  ```ruby
186
- experiment(:notification_toggle, actor: user) do |e|
187
- # ...
188
- # Variant selection can be specified when calling run.
189
- e.run(:no_interface)
223
+ class PillColorExperiment < Gitlab::Experiment # OR ApplicationExperiment
224
+ # ...registered behaviors
225
+
226
+ segment(variant: :red) { context.actor.first_name == 'Richard' }
227
+ segment :old_account?, variant: :blue
190
228
  end
191
229
  ```
192
230
 
193
- </details>
231
+ In the previous example, any user named `'Richard'` would always receive the experience defined in the red variant. As well, any account older than 2 weeks would get the alternate experience defined in the blue variant.
194
232
 
195
- ### Segmentation rules
233
+ 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. This means that for our example, any user named `'Richard'` regardless of account age, will always be provided the experience as defined in the red variant.
196
234
 
197
- This library comes with the capability to segment contexts into a specific variant, before asking the variant resolver which variant to provide.
235
+ ### Run callbacks
198
236
 
199
- Segmentation can be achieved by using a custom experiment class and specifying the segmentation rules at a class level.
237
+ Callbacks can be registered for when you want to execute logic before, after, or around when an experiment is run. These callbacks won't be called unless the experiment is actually run, meaning that exclusion rules take precedence.
200
238
 
201
239
  ```ruby
202
- class ExampleExperiment < ApplicationExperiment
203
- segment(variant: :variant_one) { context.actor.first_name == 'Richard' }
204
- segment :old_account?, variant: :variant_two
240
+ class PillColorExperiment < Gitlab::Experiment # OR ApplicationExperiment
241
+ # ...registered behaviors
205
242
 
206
- private
207
-
208
- def old_account?
209
- context.actor.created_at < 2.weeks.ago
210
- end
243
+ after_run :log_performance_metrics, -> { publish_to_database }
211
244
  end
212
245
  ```
213
246
 
214
- In the previous examples, any user named `'Richard'` would always receive the experience defined in "variant_one". As well, any account older than 2 weeks old would get the alternate experience defined in "variant_two".
247
+ In the previous example, we're going to call the `log_performance_method`, and do a hypothetical publish to the database. If you want to do an `around_run`, you just need to call the block:
215
248
 
216
- 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.
249
+ ```ruby
250
+ class PillColorExperiment < Gitlab::Experiment # OR ApplicationExperiment
251
+ # ...registered behaviors
217
252
 
218
- This means that any user named `'Richard'`, regardless of account age, will always be provided the experience as defined in "variant_one". If you wanted the opposite logic, you can flip the order.
253
+ around_run do |experiment, block|
254
+ puts "- before #{experiment.name} run"
255
+ block.call
256
+ puts "- after #{experiment.name} run"
257
+ end
258
+ end
259
+ ```
219
260
 
220
- ### Exclusion rules
261
+ ### Rollout strategies
221
262
 
222
- Exclusion rules are similar to segmentation rules, but are intended to determine if a context should even be considered as something we should track events towards. Exclusion means we don't care about the events in relation to the given context.
263
+ While a default rollout strategy can be defined in configuration, it's useful to be able to override this per experiment if needed. You can do this by specifying a specific `default_rollout` override in your experiment class.
223
264
 
224
265
  ```ruby
225
- class ExampleExperiment < ApplicationExperiment
226
- exclude :old_account?, ->{ context.actor.first_name == 'Richard' }
227
-
228
- private
266
+ class PillColorExperiment < Gitlab::Experiment # OR ApplicationExperiment
267
+ # ...registered behaviors
229
268
 
230
- def old_account?
231
- context.actor.created_at < 2.weeks.ago
232
- end
269
+ default_rollout :random # randomly assign one of the registered behaviors
233
270
  end
234
271
  ```
235
272
 
236
- The previous examples will exclude all users named `'Richard'` as well as any account older than 2 weeks old. Not only will they be given the control behavior, but no events will be tracked in these cases as well.
273
+ Obviously random assignment might not be the best rollout strategy for you, but you can define your own rollout strategies, or use one of the ones provided in the gem. You can read more about configuring the default rollout and how to write your own rollout strategies in the [configuration documentation](#rollout-strategies-1) for it.
237
274
 
238
- You may need to check exclusion in custom tracking logic by calling `should_track?`:
275
+ ## How it works
276
+
277
+ The way experiments work is best described using the following decision tree diagram. When an experiment is run, the following logic is executed to resolve what experience should be provided, given how the experiment is defined, and using the context passed to the experiment call.
278
+
279
+ ```mermaid
280
+ graph TD
281
+ GP[General Pool/Population] --> Running?[Rollout Enabled?]
282
+ Running? -->|Yes| Cached?[Cached? / Pre-segmented?]
283
+ Running? -->|No| Excluded[Control / No Tracking]
284
+ Cached? -->|No| Excluded?
285
+ Cached? -->|Yes| Cached[Cached Value]
286
+ Excluded? -->|Yes / Cached| Excluded
287
+ Excluded? -->|No| Segmented?
288
+ Segmented? -->|Yes / Cached| VariantA
289
+ Segmented? -->|No| Rollout[Rollout Resolve]
290
+ Rollout --> Control
291
+ Rollout -->|Cached| VariantA
292
+ Rollout -->|Cached| VariantB
293
+ Rollout -->|Cached| VariantN
294
+
295
+ classDef included fill:#380d75,color:#ffffff,stroke:none
296
+ classDef excluded fill:#fca121,stroke:none
297
+ classDef cached fill:#2e2e2e,color:#ffffff,stroke:none
298
+ classDef default fill:#fff,stroke:#6e49cb
299
+
300
+ class VariantA,VariantB,VariantN included
301
+ class Control,Excluded excluded
302
+ class Cached cached
303
+ ```
304
+
305
+ ## Technical details
306
+
307
+ This library is intended to be powerful and easy to use, which can lead to some complex underpinnings in the implementation. Some of those implementation details are important to understand at a technical level when considering how you want to design your experiments.
308
+
309
+ ### Including the DSL
310
+
311
+ By default, `Gitlab::Experiment` injects itself into the controller, view, and mailer layers. This exposes the `experiment` method application wide in those layers. Some experiments may extend outside of those layers however, so you may want to include it elsewhere. For instance in an irb session or the rails console, or in all your service objects, background jobs, or similar.
239
312
 
240
313
  ```ruby
241
- def expensive_tracking_logic
242
- return unless should_track?
243
-
244
- track(:my_event, value: expensive_method_call)
314
+ class ApplicationJob < ActiveJob::Base
315
+ include Gitlab::Experiment::Dsl # include the `experiment` method for all jobs
245
316
  end
246
317
  ```
247
318
 
248
- Note: Exclusion rules aren't the best way to determine if an experiment is enabled. There's an `enabled?` method that can be overridden to have a high-level way of determining if an experiment should be running and tracking at all. This `enabled?` check should be as efficient as possible because it's the first early opt out path an experiment can implement.
319
+ ### Experiment stickiness
249
320
 
250
- ### Return value
321
+ Internally, experiments have what's referred to as the context "key" that represents the unique and anonymous id of a given context. This allows us to assign the same variant between different calls to the experiment, is used in caching and can be used in event data downstream. This context "key" is how an experiment remains "sticky" to a given context, and is an important aspect to understand.
251
322
 
252
- 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.
323
+ You can specify what the experiment should be sticky to by providing the `:sticky_to` option. By default this will be the entire context, but this can be overridden manually if needed.
324
+
325
+ In a fabricated example, we might want to provide the user and a project to an experiment, but we want it to remain sticky to the project that the user is viewing and not the user viewing that project. This is a very powerful concept that you're encouraged to play around with to understand what it means, and how you can run complex experiments that aren't user centric.
253
326
 
254
327
  ```ruby
255
- experiment(:notification_toggle) do |e|
256
- e.use { 'A' }
257
- e.try { 'B' }
258
- e.run
259
- end # => 'A'
328
+ experiment(:example, actor: current_user, project: project, sticky_to: project)
260
329
  ```
261
330
 
262
- ### Including the DSL
331
+ ### Experiment signature
263
332
 
264
- By default, `Gitlab::Experiment` injects itself into the controller and view layers. This exposes the `experiment` method application wide in those layers.
333
+ The best way to understand the details of an experiment is through its signature. An example signature can be retrieved by calling the `signature` method, and looks like the following:
265
334
 
266
- 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.
335
+ ```ruby
336
+ experiment(:example).signature # => {:variant=>"control", :experiment=>"example", :key=>"4d7aee..."}
337
+ ```
267
338
 
268
- 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.
339
+ An experiment signature is useful when tracking events and when using experiments on the client layer. The signature can also contain the optional `migration_keys`, and `excluded` properties.
269
340
 
270
- ```ruby
271
- class WelcomeMailer < ApplicationMailer
272
- include Gitlab::Experiment::Dsl # include the `experiment` method
341
+ ### Return value
273
342
 
274
- def welcome
275
- @user = params[:user]
343
+ By default the return value of calling `experiment` is a `Gitlab::Experiment` instance, or whatever class the experiment is resolved to, which likely inherits from `Gitlab::Experiment`. In simple cases you may want only the results of running the experiment though. You can call `run` within the block to get the return value of the assigned variant.
276
344
 
277
- ex = experiment(:project_suggestions, actor: @user) do |e|
278
- e.use { 'welcome' }
279
- e.try { 'welcome_with_project_suggestions' }
280
- end
345
+ ```ruby
346
+ # Normally an experiment instance.
347
+ experiment(:example) do |e|
348
+ e.control { 'A' }
349
+ e.candidate { 'B' }
350
+ end # => #<Gitlab::Experiment:0x...>
281
351
 
282
- mail(to: @user.email, subject: 'Welcome!', template: ex.run)
283
- end
284
- end
352
+ # But calling `run` causes the return value to be the result.
353
+ experiment(:example) do |e|
354
+ e.control { 'A' }
355
+ e.candidate { 'B' }
356
+ e.run
357
+ end # => 'A'
285
358
  ```
286
359
 
287
360
  ### Context migrations
@@ -293,8 +366,11 @@ Take for instance, that you might be using `version: 1` in your context currentl
293
366
  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.
294
367
 
295
368
  ```ruby
296
- # Migrate just the `:version` portion of the previous context, `{ actor: project, version: 1 }`:
297
- experiment(:example, actor: project, version: 2, migrated_with: { version: 1 })
369
+ # First implementation.
370
+ experiment(:example, actor: current_user, version: 1)
371
+
372
+ # Migrate just the `:version` portion.
373
+ experiment(:example, actor: current_user, version: 2, migrated_with: { version: 1 })
298
374
  ```
299
375
 
300
376
  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.
@@ -302,248 +378,292 @@ You can add or remove context by providing a `migrated_from` option. This approa
302
378
  If you wanted to introduce a `version` to your context, provide the full previous context.
303
379
 
304
380
  ```ruby
305
- # Migrate the full context from `{ actor: project }` to `{ actor: project, version: 1 }`:
306
- experiment(:example, actor: project, version: 1, migrated_from: { actor: project })
307
- ```
308
-
309
- This can impact an experience if you:
381
+ # First implementation.
382
+ experiment(:example, actor: current_user)
310
383
 
311
- 1. haven't implemented the concept of migrations in your variant resolver
312
- 1. haven't enabled a reasonable caching mechanism
384
+ # Migrate the full context of `{ actor: current_user }` to `{ actor: current_user, version: 1 }`.
385
+ experiment(:example, actor: current_user, version: 1, migrated_from: { actor: current_user })
386
+ ```
313
387
 
314
- ### When there isn't an actor (cookie fallback)
388
+ When you migrate context, this information is included in the signature of the experiment. This can be used downstream in event handling and reporting to resolve a series of events back to a single experience, while also keeping everything anonymous.
315
389
 
316
- 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.
390
+ An example of our experiment signature when we migrate would include the `migration_keys` property:
317
391
 
318
- 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.
392
+ ```ruby
393
+ ex = experiment(:example, version: 1)
394
+ ex.signature # => {:key=>"20d69a...", ...}
319
395
 
320
- 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.
396
+ ex = experiment(:example, version: 2, migrated_from: { version: 1 })
397
+ ex.signature # => {:key=>"9e9d93...", :migration_keys=>["20d69a..."], ...}
398
+ ```
321
399
 
322
- 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.
400
+ ### Cookies and the actor keyword
323
401
 
324
- You'll need to provide the `request` as an option to the experiment if it's outside of the controller and views.
402
+ We use cookies to auto migrate an unknown value into a known value, often in the case of the current user. The implementation of this uses the same concept outlined above with context migrations, but will happen automatically for you if you use the `actor` keyword.
325
403
 
326
- ```ruby
327
- experiment(:example, actor: user, request: request)
328
- ```
404
+ When you use the `actor: current_user` pattern in your context, the nil case is handled by setting a special cookie for the experiment and then deleting the cookie, and migrating the context key to the one generated from the user when they've signed in.
329
405
 
330
- 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.
406
+ 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.
331
407
 
332
408
  ```ruby
333
- # actor is not present, so no cookie is set
409
+ # The actor key is not present, so no cookie is set.
334
410
  experiment(:example, project: project)
335
411
 
336
- # actor is present and is nil, so the cookie is set and used
412
+ # The actor key is present but nil, so the cookie is set and used.
337
413
  experiment(:example, actor: nil, project: project)
338
414
 
339
- # actor is present and set to a value, so no cookie is set
340
- experiment(:example, actor: user, project: project)
415
+ # The actor key is present and isn't nil, so the cookie value (if found) is
416
+ # migrated forward and the cookie is deleted.
417
+ experiment(:example, actor: current_user, project: project)
341
418
  ```
342
419
 
343
- For edge cases, you can pass the cookie through by assigning it yourself -- e.g. `actor: request.cookie_jar.signed['example_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`.
420
+ Note: The cookie is deleted when resolved, but can be assigned again if the `actor` is ever nil again. A good example of this scenario would be on a sign in page. When a potential user arrives, they would never be known, so a cookie would be set for them, and then resolved/removed as soon as they signed in. This process would repeat each time they arrived while not being signed in and can complicate reporting unless it's handled well in the data layers.
344
421
 
345
- ## How it works
422
+ Note: 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. You'll need to provide the `request` as an option to the experiment if it's outside of the controller and views.
346
423
 
347
- The way the gem works is best described using the following decision tree illustration. When an experiment is run, the following logic is executed to resolve what experience should be provided, given how the experiment is defined, and the context provided.
348
-
349
- ```mermaid
350
- graph TD
351
- GP[General Pool/Population] --> Enabled?
352
- Enabled? -->|Yes| Cached?[Cached? / Pre-segmented?]
353
- Enabled? -->|No| Excluded[Control / No Tracking]
354
- Cached? -->|No| Excluded?
355
- Cached? -->|Yes| Cached[Cached Value]
356
- Excluded? -->|Yes / Cached| Excluded
357
- Excluded? -->|No| Segmented?
358
- Segmented? -->|Yes / Cached| VariantA
359
- Segmented? -->|No| Included?[Experiment Group?]
360
- Included? -->|Yes| Rollout
361
- Included? -->|No| Control
362
- Rollout -->|Cached| VariantA
363
- Rollout -->|Cached| VariantB
364
- Rollout -->|Cached| VariantC
424
+ ```ruby
425
+ experiment(:example, actor: current_user, request: request)
426
+ ```
365
427
 
366
- classDef included fill:#380d75,color:#ffffff,stroke:none
367
- classDef excluded fill:#fca121,stroke:none
368
- classDef cached fill:#2e2e2e,color:#ffffff,stroke:none
369
- classDef default fill:#fff,stroke:#6e49cb
428
+ Note: For edge cases, you can pass the cookie through by assigning it yourself -- e.g. `actor: request.cookie_jar.signed['example_id']`. The cookie name is the full experiment name (including any configured prefix) with `_id` appended -- e.g. `pill_color_id` for the `PillColorExperiment`.
370
429
 
371
- class VariantA,VariantB,VariantC included
372
- class Control,Excluded excluded
373
- class Cached cached
430
+ ### Client layer
431
+
432
+ Experiments that have been run (or published) during the request lifecycle can be pushed into to the client layer by injecting the published experiments into javascript in a layout or view using something like:
433
+
434
+ ```haml
435
+ = javascript_tag(nonce: content_security_policy_nonce) do
436
+ window.experiments = #{raw ApplicationExperiment.published_experiments.to_json};
374
437
  ```
375
438
 
439
+ The `window.experiments` object can then be used in your client implementation to determine experimental behavior at that layer as well. For instance, we can now access the `window.experiments.pill_color` object to get the variant that was assigned, if the context was excluded, and to use the context key in our client side events.
440
+
376
441
  ## Configuration
377
442
 
378
- This gem needs to be configured before being used in a meaningful way.
443
+ The gem is meant to be configured when installed and is ambiguous about how it should track events, and what to do when publishing experiments. Some of the more important aspects are left up to you to implement the logic that's right for your project.
379
444
 
380
- The default configuration will always render the control, so it's important to configure your own logic for resolving variants.
445
+ Simple documentation can be found in the provided [initializer](lib/generators/gitlab/experiment/install/templates/initializer.rb.tt).
381
446
 
382
- 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.
447
+ Read on for comprehensive documentation on some of the more complex configuration options.
448
+
449
+ ### Caching
450
+
451
+ 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 when something other than nil. Migrating the cache through context migrations is handled automatically, and this helps ensure an experiment experience remains consistent.
452
+
453
+ It's important to understand that using caching can drastically change or negate your specific rollout strategy logic.
383
454
 
384
455
  ```ruby
385
456
  Gitlab::Experiment.configure do |config|
386
- # The block here is evaluated within the scope of the experiment instance,
387
- # which is why we are able to access things like name and context.
388
- config.variant_resolver = lambda do |requested_variant|
389
- # Return the requested variant if a specific one has been provided in code.
390
- return requested_variant unless requested_variant.nil?
391
-
392
- # Ask Unleash to determine the variant, given the context we've built,
393
- # using the control as the fallback.
394
- fallback = Unleash::Variant.new(name: 'control', enabled: true)
395
- UNLEASH.get_variant(name, context.value, fallback)
396
- end
457
+ config.cache = Rails.cache
397
458
  end
398
459
  ```
399
460
 
400
- More examples for configuration are available in the provided [rails initializer](lib/generators/gitlab/experiment/install/templates/initializer.rb.tt).
461
+ The gem includes the following cache stores, which are documented in the implementation:
462
+
463
+ - [`RedisHashStore`](lib/gitlab/experiment/cache/redis_hash_store.rb): Useful if using redis
464
+
465
+ ### Rollout strategies
401
466
 
402
- ### Client layer / JavaScript
467
+ There are some basic rollout strategies that come with the gem, and you can use these directly, or you can use them to help build your own custom rollout strategies. Each is documented more thoroughly in its implementation file.
403
468
 
404
- This library doesn't attempt to provide any logic for the client layer.
469
+ - [`Base`](lib/gitlab/experiment/rollout.rb): Useful for building custom rollout strategies, not super useful by itself
470
+ - [`Percent`](lib/gitlab/experiment/rollout/percent.rb): A comprehensive percent based strategy, it's configured as the default
471
+ - [`Random`](lib/gitlab/experiment/rollout/random.rb): Random assignment can be useful on some experimentation
472
+ - [`RoundRobin`](lib/gitlab/experiment/rollout/round_robin.rb): Cycles through assignment using the cache to keep track of what was last assigned
405
473
 
406
- 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.
474
+ The included rollout strategies are great, but you might want to write your own for your own project, which might already have nice tooling for toggling feature flags.
475
+
476
+ It's an important aspect of this library to be flexible with the approach you choose when determining if an experiment is enabled, and what variant should be assigned.
477
+
478
+ To that end, let's go ahead through an example of how to write up a custom rollout strategy that uses the [Flipper](https://github.com/jnunemaker/flipper) gem to manage rollout. For simpliticy in our example, we're going to start by inheriting from `Gitlab::Experiment::Rollout::Percent` because it's already useful.
407
479
 
408
480
  ```ruby
409
- Gitlab::Experiment.configure do |config|
410
- config.publishing_behavior = lambda do |_result|
411
- # Push the experiment knowledge into the front end. The signature contains
412
- # the context key, and the variant that has been determined.
413
- Gon.push({ experiment: { name => signature } }, true)
481
+ # We put it in this module namespace so we can get easy resolution when
482
+ # using `default_rollout :flipper` in our usage later.
483
+ module Gitlab::Experiment::Rollout
484
+ class Flipper < Percent
485
+ def enabled?
486
+ ::Flipper.enabled?(experiment.name, self)
487
+ end
488
+
489
+ def flipper_id
490
+ "Experiment;#{id}"
491
+ end
414
492
  end
415
493
  end
416
494
  ```
417
495
 
418
- In the client you can now access `window.gon.experiment.notificationToggle`.
496
+ So, Flipper needs something that responds to `flipper_id`, and since our experiment "id" (which is also our context key) is unique and consistent, we're going to give that to Flipper to manage things like percentage of actors etc. You might want to consider something more complex here if you're using things that can be flipper actors in your experiment context.
419
497
 
420
- ### Caching
498
+ Anyway, now you can use your custom `Flipper` rollout strategy by instantiating it in configuration:
421
499
 
422
- 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.
500
+ ```ruby
501
+ Gitlab::Experiment.configure do |config|
502
+ config.default_rollout = Gitlab::Experiment::Rollout::Flipper.new(
503
+ include_control: true # specify to include control, which we want to do
504
+ )
505
+ end
506
+ ```
423
507
 
424
- It's important to understand that using caching can drastically change or override your rollout strategy logic.
508
+ Or if you don't want to make that change globally, you can use it in specific experiment classes:
425
509
 
426
510
  ```ruby
427
- Gitlab::Experiment.configure do |config|
428
- config.cache = Rails.cache
511
+ class PillColorExperiment < Gitlab::Experiment # OR ApplicationExperiment
512
+ # ...registered behaviors
513
+
514
+ default_rollout :flipper,
515
+ include_control: true, # optionally specify to include control
516
+ distribution: { control: 26, red: 37, blue: 37 } # optionally specify distribution
429
517
  end
430
518
  ```
431
519
 
432
- ### Middleware
520
+ Now, enabling or disabling the Flipper feature flag will control if the experiment is enabled or not. If the experiment is enabled, as determined by our custom rollout strategy, the standard resolutuon logic will be executed, and a variant (or control) will be assigned.
521
+
522
+ ```ruby
523
+ experiment(:pill_color).enabled? # => false
524
+ experiment(:pill_color).assigned.name # => "control"
525
+
526
+ # Now we can enable the feature flag to enable the experiment.
527
+ Flipper.enable(:pill_color) # => true
433
528
 
434
- There are times when you'll need to do link tracking in email templates, or markdown content -- or other places you won't be able to implement tracking. For these cases, gitlab-experiment comes with middleware that will redirect to a given URL while also tracking that the URL was visited.
529
+ experiment(:pill_color).enabled? # => true
530
+ experiment(:pill_color).assigned.name # => "red"
531
+ ```
435
532
 
436
- In Rails this middleware is mounted automatically, with a base path of what's been configured for `mount_at`. If this path is empty the middleware won't be mounted at all.
533
+ ### Middleware
437
534
 
438
- Once mounted, the redirect URLs can be generated using the Rails route helpers. If not using Rails, mount the middleware and generate these URLs yourself.
535
+ There are times when you'll need to do link tracking in email templates, or markdown content -- or other places you won't be able to implement tracking. For these cases a middleware layer that can redirect to a given URL while also tracking that the URL was visited has been provided.
536
+
537
+ In Rails this middleware is mounted automatically, with a base path of what's been configured for `mount_at`. If this path is nil, the middleware won't be mounted at all.
439
538
 
440
539
  ```ruby
441
540
  Gitlab::Experiment.configure do |config|
442
541
  config.mount_at = '/experiment'
542
+
543
+ # Only redirect on permitted domains.
544
+ config.redirect_url_validator = ->(url) { (url = URI.parse(url)) && url.host == 'gitlab.com' }
443
545
  end
546
+ ```
444
547
 
445
- ex = experiment(:example, foo: :bar)
548
+ Once configured to be mounted, the experiment tracking redirect URLs can be generated using the Rails route helpers.
446
549
 
447
- # using rails path/url helpers
448
- experiment_redirect_path(ex, 'https//docs.gitlab.com/') # => /experiment/example:[context_key]?https//docs.gitlab.com/
550
+ ```ruby
551
+ ex = experiment(:example)
449
552
 
450
- # manually
451
- "#{Gitlab::Experiment.configure.mount_at}/#{ex.to_param}?https//docs.gitlab.com/"
452
- ```
553
+ # Generating the path/url using the path and url helper.
554
+ experiment_redirect_path(ex, url: 'https//gitlab.com/docs') # => "/experiment/example:20d69a...?https//gitlab.com/docs"
555
+ experiment_redirect_url(ex, url: 'https//gitlab.com/docs') # => "https://gitlab.com/experiment/example:20d69a...?https//gitlab.com/docs"
453
556
 
454
- URLS that match the base path will be handled by the middleware and will redirect to the provided redirect path.
557
+ # Manually generating a url is a bit less clean, but is possible.
558
+ "#{Gitlab::Experiment::Configuration.mount_at}/#{ex.to_param}?https//docs.gitlab.com/"
559
+ ```
455
560
 
456
561
  ## Testing (rspec support)
457
562
 
458
- This gem comes with some rspec helpers and custom matchers. These are in flux at the time of writing.
459
-
460
- First, require the rspec support file:
563
+ This gem comes with some rspec helpers and custom matchers. To get the experiment specific rspec support, require the rspec support file:
461
564
 
462
565
  ```ruby
463
566
  require 'gitlab/experiment/rspec'
464
567
  ```
465
568
 
466
- This mixes in some of the basics, but the matchers and other aspects need to be included. This happens automatically for files in `spec/experiments`, but for other files and specs you want to include it in, you can specify the `:experiment` type:
569
+ Any file in `spec/experiments` path will automatically get the experiment specific support, but it can also be included in other specs by adding the `:experiment` label:
467
570
 
468
571
  ```ruby
469
- it "tests", :experiment do
572
+ describe MyExampleController do
573
+ context "with my experiment", :experiment do
574
+ # experiment helpers and matchers will be available here.
575
+ end
470
576
  end
471
577
  ```
472
578
 
473
579
  ### Stub helpers
474
580
 
475
- You can stub experiments using `stub_experiments`. Pass it a hash using experiment names as the keys and the variants you want each to resolve to as the values:
581
+ You can stub experiment variant resolution using the `stub_experiments` helper. Pass a hash of experiment names and the variant each should resolve to.
476
582
 
477
583
  ```ruby
478
- # Ensures the experiments named `:example` & `:example2` are both
479
- # "enabled" and that each will resolve to the given variant
480
- # (`:my_variant` & `:control` respectively).
481
- stub_experiments(example: :my_variant, example2: :control)
584
+ it "stubs experiments to resolve to a specific variant" do
585
+ stub_experiments(pill_color: :red)
482
586
 
483
- experiment(:example) do |e|
484
- e.enabled? # => true
485
- e.variant.name # => 'my_variant'
486
- end
487
-
488
- experiment(:example2) do |e|
489
- e.enabled? # => true
490
- e.variant.name # => 'control'
491
- end
587
+ experiment(:pill_color) do |e|
588
+ expect(e).to be_enabled
589
+ expect(e.assigned.name).to eq('red')
590
+ end
591
+ end
492
592
  ```
493
593
 
494
- ### Exclusion and segmentation matchers
495
-
496
- You can also easily test the exclusion and segmentation matchers.
594
+ In special cases you can use a boolean `true` instead of a variant name. This allows the rollout strategy to resolve the variant however it wants to, but is otherwise just making sure the experiment is considered enabled.
497
595
 
498
596
  ```ruby
499
- class ExampleExperiment < ApplicationExperiment
500
- exclude { context.actor.first_name == 'Richard' }
501
- segment(variant: :candidate) { context.actor.username == 'jejacks0n' }
597
+ it "stubs experiments while allowing the rollout strategy to assign the variant" do
598
+ stub_experiments(pill_color: true) # only stubs enabled?
599
+
600
+ experiment(:pill_color) do |e|
601
+ expect(e).to be_enabled
602
+ # expect(e.assigned.name).to eq([whatever the rollout strategy assigns])
603
+ end
502
604
  end
605
+ ```
503
606
 
504
- excluded = double(username: 'rdiggitty', first_name: 'Richard')
505
- segmented = double(username: 'jejacks0n', first_name: 'Jeremy')
607
+ ### Registered behaviors matcher
506
608
 
507
- # exclude matcher
508
- expect(experiment(:example)).to exclude(actor: excluded)
509
- expect(experiment(:example)).not_to exclude(actor: segmented)
609
+ It's useful to test our registered behaviors, as well as their return values when we implement anything complex in them. The `register_behavior` matcher is useful for this.
510
610
 
511
- # segment matcher
512
- expect(experiment(:example)).to segment(actor: segmented).into(:candidate)
513
- expect(experiment(:example)).not_to segment(actor: excluded)
611
+ ```ruby
612
+ it "tests our registered behaviors" do
613
+ expect(experiment(:pill_color)).to register_behavior(:control)
614
+ .with('grey') # with a default return value of "grey"
615
+ expect(experiment(:pill_color)).to register_behavior(:red)
616
+ expect(experiment(:pill_color)).to register_behavior(:blue)
617
+ end
514
618
  ```
515
619
 
516
- ### Tracking matcher
517
-
518
- Tracking events is a major aspect of experimentation, and because of this we try to provide a flexible way to ensure your tracking calls are covered.
620
+ ### Exclusion and segmentation matchers
519
621
 
520
- You can do this on the instance level or at an "any instance" level. At an instance level this is pretty straight forward:
622
+ You can also easily test your experiment classes using the `exclude`, `segment` metchers.
521
623
 
522
624
  ```ruby
523
- subject = experiment(:example)
625
+ let(:excluded) { double(first_name: 'Richard', created_at: Time.current) }
626
+ let(:segmented) { double(first_name: 'Jeremy', created_at: 3.weeks.ago) }
524
627
 
525
- expect(subject).to track(:my_event)
628
+ it "tests the exclusion rules" do
629
+ expect(experiment(:pill_color)).to exclude(actor: excluded)
630
+ expect(experiment(:pill_color)).not_to exclude(actor: segmented)
631
+ end
526
632
 
527
- subject.track(:my_event)
633
+ it "tests the segmentation rules" do
634
+ expect(experiment(:pill_color)).to segment(actor: segmented)
635
+ .into(:red) # into a specific variant
636
+ expect(experiment(:pill_color)).not_to segment(actor: excluded)
637
+ end
528
638
  ```
529
639
 
530
- You can use the `on_any_instance` chain method to specify that it could happen on any instance of the experiment. This can be useful if you're calling `experiment(:example).track` downstream:
640
+ ### Tracking matcher
641
+
642
+ Tracking events is a major aspect of experimentation, and because of this we try to provide a flexible way to ensure your tracking calls are covered.
531
643
 
532
644
  ```ruby
533
- expect(experiment(:example)).to track(:my_event).on_any_instance
645
+ before do
646
+ stub_experiments(pill_color: true) # stub the experiment so tracking is permitted
647
+ end
648
+
649
+ it "tests that we track an event on a specific instance" do
650
+ expect(subject = experiment(:pill_color)).to track(:clicked)
534
651
 
535
- experiment(:example).track(:my_event)
652
+ subject.track(:clicked)
653
+ end
536
654
  ```
537
655
 
538
- And here's a full example of the methods that can be chained onto the `track` matcher:
656
+ You can use the `on_next_instance` chain method to specify that the tracking call could happen on the next instance of the experiment. This can be useful if you're calling `experiment(:example).track` downstream and don't have access to that instance. Here's a full example of the methods that can be chained onto the `track` matcher:
539
657
 
540
658
  ```ruby
541
- expect(experiment(:example)).to track(:my_event, value: 1, property: '_property_')
542
- .on_any_instance
543
- .with_context(foo: :bar)
544
- .for(:variant_name)
659
+ it "tests that we track an event with specific details" do
660
+ expect(experiment(:pill_color)).to track(:clicked, value: 1, property: '_property_')
661
+ .on_next_instance # any time in the future
662
+ .with_context(foo: :bar) # with the expected context
663
+ .for(:red) # and assigned the correct variant
545
664
 
546
- experiment(:example, :variant_name, foo: :bar).track(:my_event, value: 1, property: '_property_')
665
+ experiment(:pill_color, :red, foo: :bar).track(:clicked, value: 1, property: '_property_')
666
+ end
547
667
  ```
548
668
 
549
669
  ## Tracking, anonymity and GDPR
@@ -560,16 +680,13 @@ Each of these approaches could be desirable given the objectives of your experim
560
680
 
561
681
  ## Development
562
682
 
563
- After checking out the repo, run `bundle install` to install dependencies.
564
- Then, run `bundle exec rake` to run the tests. You can also run `bundle exec pry` for an
565
- interactive prompt that will allow you to experiment.
683
+ After cloning the repo, run `bundle install` to install dependencies.
684
+
685
+ Run `bundle exec rake` to run the tests. You can also run `bundle exec pry` for an interactive prompt that will allow you to experiment.
566
686
 
567
687
  ## Contributing
568
688
 
569
- Bug reports and merge requests are welcome on GitLab at
570
- https://gitlab.com/gitlab-org/gitlab-experiment. This project is intended to be a
571
- safe, welcoming space for collaboration, and contributors are expected to adhere
572
- to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
689
+ Bug reports and merge requests are welcome on GitLab at https://gitlab.com/gitlab-org/ruby/gems/gitlab-experiment. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
573
690
 
574
691
  ## Release process
575
692
 
@@ -577,13 +694,10 @@ Please refer to the [Release Process](docs/release_process.md).
577
694
 
578
695
  ## License
579
696
 
580
- The gem is available as open source under the terms of the
581
- [MIT License](http://opensource.org/licenses/MIT).
697
+ The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
582
698
 
583
699
  ## Code of conduct
584
700
 
585
- Everyone interacting in the `Gitlab::Experiment` project’s codebases, issue trackers,
586
- chat rooms and mailing lists is expected to follow the
587
- [code of conduct](CODE_OF_CONDUCT.md).
701
+ Everyone interacting in the `Gitlab::Experiment` project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](CODE_OF_CONDUCT.md).
588
702
 
589
703
  ***Make code not war***