gitlab-experiment 0.2.0 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fae9519317810c50decdff810249899d5c1855881544a20a31a2219cf9bda715
4
- data.tar.gz: 19987d9c24486e99424aa6cfcade34d3123dbe1fc4a6afb8dc6c4c6ad4f5a99b
3
+ metadata.gz: 6134f703a49eb7411ff59e1fa7778421890498a537419cd765a99c5e07ff1523
4
+ data.tar.gz: ea3736188b46c9527818109dc87839c33937e0c9086bae4464992046f425874a
5
5
  SHA512:
6
- metadata.gz: afe30984c7b7f96bb4ad1122c5a4f5ff48a45f8c7cfca5b1d2d04368ba3eef25c84776e3768588cde6cd717ee918b25e8158746ea9ea3e9bb20fe9ef39d20377
7
- data.tar.gz: 2492d43cbc76cfa20c1b785033fdc635ca75874d1052270c950a86acff17d04011c406bf1879bc5f21b06fb604c5ed259c149aecbea33efe2adae59ab12f6164
6
+ metadata.gz: ec048634699257e018d7e67f290dfa2dd5e2712d58b1ef25eeb1c9353c5a23ad2f7a8ec0484493e6bccaabc57801aa22ceb8632a5a8c355652ae4a5541a43286
7
+ data.tar.gz: 51f63fb0256c49f393887451723483233927a0a983c51ca80f98494634e156dab28d0f87f21319a4df750392bb8329923e94ab23091929b3b26904edb25ce5b3
data/README.md CHANGED
@@ -1,182 +1,354 @@
1
- # Experiment Guide
1
+ # GitLab Experiment
2
2
 
3
- Experiments can be conducted by any GitLab team, but are most often conducted by the teams from the [Growth Sub-department](https://about.gitlab.com/handbook/engineering/development/growth/).
3
+ <img alt="experiment" src="/uploads/60990b2dbf4c0406bbf8b7f998de2dea/experiment.png" align="right" width="40%">
4
4
 
5
- Experiments are run as A/B/n tests and are evaluated by the data the experiment generates. The team reviews the data, determines the variant that performed most effectively, and promotes that variant as the new default code path (or reverts back to the control). In all instances, an experiment will be cleaned up after it has generated enough data to determine performance and be considered resolved.
5
+ Here at GitLab, we run experiments as A/B/n tests and review the data the experiment generates. From that data, we determine the best performing variant and promote it as the new default code path. Or revert back to the control if no variant outperformed it.
6
6
 
7
- ## Process and tracking issue
7
+ This library provides a clean and elegant DSL to define, run, and track your GitLab experiment.
8
8
 
9
- Each experiment should have a related [Experiment tracking issue](https://gitlab.com/gitlab-org/gitlab/-/issues/new?issuable_template=Experiment%20tracking) created, which is intended to track the experiment from deployment and rollout through to resolution and cleanup.
9
+ When we discuss the behavior of this gem, we'll use terms like experiment, context, control, candidate, and variant. It's worth defining these terms so they're more understood.
10
10
 
11
- At the time an experiment is rolled out, the due date of the tracking issue should be specified. The timeline depends on the experiment and can be up to several weeks in the future.
11
+ - `experiment` is any deviation of code paths we want to run sometimes and not others.
12
+ - `context` is used to identify a consistent experience we'll provide in an experiment.
13
+ - `control` is the default, or "original" code path.
14
+ - `candidate` defines that there's one experimental code path.
15
+ - `variant(s)` is used when more than one experimental code path exists.
12
16
 
13
- With experiment resolution, the outcome of the experiment should be posted to the tracking issue with the reasoning for the decision. Any and all non-relevant experiment code should be removed and review concerns should be addressed during cleanup. After cleanup, the tracking issue can be closed.
17
+ Candidate and variant are the same concept, but simplify how we speak about experimental paths.<br clear="all">
18
+
19
+ ## Installation
20
+
21
+ Add the gem to your Gemfile and then `bundle install`.
22
+
23
+ ```ruby
24
+ gem 'gitlab-experiment'
25
+ ```
26
+
27
+ If you're using Rails, you can install the initializer. It provides basic configuration and documentation.
28
+
29
+ ```shell
30
+ $ rails generate gitlab-experiment:install
31
+ ```
14
32
 
15
33
  ## Implementing an experiment
16
34
 
17
- For the sake of our example, let's say we want to run an experiment for how to cancel a subscription. In our control (current world) we show a toggle that reads "Auto-renew", and in our experiment candidate we want to show a "Cancel subscription" button with a confirmation. Ultimately the behavior is the same, but the interface will be considerably different.
35
+ For the sake of an example let's make one up. Let's run an experiment on what we render for disabling desktop notifications.
36
+
37
+ In our control (current world) we show a simple toggle interface labeled, "Notifications." In our experiment we want a "Turn on/off desktop notifications" button with a confirmation.
18
38
 
19
- ### Defining the feature flag
39
+ The behavior will be the same, but the interface will be different and may involve more or fewer steps.
20
40
 
21
- Let's name our experiment `subscription_cancellation`. It's important to understand how this name is prefixed as `growth_experiment_subscription_cancellation` in our Unleash feature detection and in our Snowplow tracking calls. We prefix our experiments so we can consistently identify them as experiments and clean them up over time.
41
+ Our hypothesis is that this will make the action more clear and will help in making a choice about if that's what the user really wants to do.
22
42
 
23
- This means that you'll need to go to the [Feature Flags interface](https://gitlab.com/gitlab-org/gitlab/-/feature_flags) interface (for the project you're working on) and add a `growth_experiment_subscription_cancellation` feature flag and define which environment(s) it should be rolled out on for initial deployment, for instance, `gitlab-staging` or `customers-staging`.
43
+ We'll name our experiment `notification_toggle`. This name is prefixed based on configuration. If you've set `config.name_prefix = 'gitlab'`, the experiment name would be `gitlab_notification_toggle` elsewhere.
24
44
 
25
- You can include yourself and others in a list and assign that list using the `User List` rollout strategy, or you can add your user id to the experiment manually. You can use this to include or exclude yourself from an experiment for verification before rolling it out to others.
45
+ When you implement an experiment you'll need to provide a name, and a context. The name can show up in tracking calls, and potentially other aspects. The context determines the variant assigned, and should be consistent between calls. We'll discuss migrating context in later examples.
26
46
 
27
- ### Implementation
47
+ A context "key" represents the unique id of a context. It allows us to give the same experience between different calls to the experiment and can be used in caching.
28
48
 
29
- When you implement an experiment in code you'll need to provide the name that you've given it in the feature flags interface, and a context -- which usually will include something like a user / user id, but may also include several other aspects.
49
+ Now in our experiment we're going to render one of two views: the control will be our current view, and the candidate will be the new toggle button with a confirmation flow.
30
50
 
31
51
  ```ruby
32
52
  class SubscriptionsController < ApplicationController
33
- include Gitlab::GrowthExperiment::Interface
34
-
35
53
  def show
36
- experiment(:subscription_cancellation, user_id: user.id) do |e|
37
- e.use { render_toggle_button } # control
38
- e.try { render_cancel_button } # candidate
54
+ experiment(:notification_toggle, actor: user) do |e|
55
+ e.use { render_toggle } # control
56
+ e.try { render_button } # candidate
39
57
  end
40
58
  end
41
59
  end
42
60
  ```
43
61
 
44
- You can also provide different variants for the experience if you've defined variants in the Feature Flag interface (not yet available).
62
+ You can define the experiment using simple control/candidate paths, or provide named variants.
63
+
64
+ Handling multi-variant experiments is up to the configuration you provide around resolving variants. But in our example we may want to try with and without the confirmation. We can run any number of variations in our experiments this way.
45
65
 
46
66
  ```ruby
47
- experiment(:subscription_cancellation, user_id: user.id) do |e|
48
- e.use { render_toggle_button } # control
49
- e.try(:variant_one) { render_cancel_button(confirmation: true) }
50
- e.try(:variant_two) { render_cancel_button(confirmation: false) }
67
+ experiment(:notification_toggle, actor: user) do |e|
68
+ e.use { render_toggle } # control
69
+ e.try(:variant_one) { render_button(confirmation: true) }
70
+ e.try(:variant_two) { render_button(confirmation: false) }
51
71
  end
52
72
  ```
53
73
 
54
- Later, and elsewhere in code you can use the same `experiment` call to track events on the experiment. The important detail here is to use the same context between your calls to `experiment`. If the context is the same, we're able to consistently track the event in a way that associates it to which variant is being presented -- which may be based on the user we're presenting it to.
74
+ Understanding how an experiment can change behavior is important in evaluating its performance.
75
+
76
+ To this end, we track events that are important by calling the same experiment elsewhere in code. By using the same context, you'll have consistent behavior and the ability to track events to it.
55
77
 
56
78
  ```ruby
57
- exp = experiment(:subscription_cancellation, user_id: user.id)
58
- exp.track('clicked_button')
79
+ experiment(:notification_toggle, actor: user).track(:clicked_button)
59
80
  ```
60
81
 
61
82
  <details>
62
- <summary>You can use the more low level class or instance interfaces...</summary>
83
+ <summary>You can also use the more low level class or instance interfaces...</summary>
63
84
 
64
85
  ### Class level interface using `.run`
65
86
 
66
87
  ```ruby
67
- exp = Gitlab::GrowthExperiment.run(:subscription_cancellation, user_id: user.id) do |e|
68
- # context can be passed to `experiment`, `.run`, `new`, or after the fact like here.
69
- # context must be added before `#run` or `#track` calls.
70
- e.context(project_id: project.id)
71
-
72
- e.use { toggle_button_interface } # control
73
- e.try { cancel_button_interface } # candidate
88
+ exp = Gitlab::Experiment.run(:notification_toggle, actor: user) do |e|
89
+ # Context may be passed in the block, but must be finalized before calling
90
+ # run or track.
91
+ e.context(project: project) # add the project to the context
92
+
93
+ # Define the control and candidate variant.
94
+ e.use { render_toggle } # control
95
+ e.try { render_button } # candidate
74
96
  end
75
97
 
76
- # track an event on the experiment we've defined.
98
+ # Track an event on the experiment we've defined.
77
99
  exp.track(:clicked_button)
78
100
  ```
79
101
 
80
- While `Gitlab::GrowthExperiment.run` is what we document, you can also use `Gitlab::GrowthExperiment.experiment`.
81
-
82
102
  ### Instance level interface
83
103
 
84
104
  ```ruby
85
- exp = Gitlab::GrowthExperiment.new(:subscription_cancellation, user_id: user.id)
86
- # context can be passed to `.new`, or after the fact like here.
87
- # context must be added before `#run` or `#track` calls.
88
- exp.context(project_id: project.id)
89
- exp.use { toggle_button_interface } # control
90
- exp.try { cancel_button_interface } # candidate
105
+ exp = Gitlab::Experiment.new(:notification_toggle, actor: user)
106
+ # Additional context may be provided to the instance (exp) but must be
107
+ # finalized before calling run or track.
108
+ exp.context(project: project) # add the project id to the context
109
+
110
+ # Define the control and candidate variant.
111
+ exp.use { render_toggle } # control
112
+ exp.try { render_button } # candidate
113
+
114
+ # Run the experiment, returning the result.
91
115
  exp.run
92
116
 
93
- # track an event on the experiment we've defined.
117
+ # Track an event on the experiment we've defined.
94
118
  exp.track(:clicked_button)
95
119
  ```
96
120
 
97
121
  </details>
98
122
 
99
123
  <details>
100
- <summary>You can define use custom classes...</summary>
124
+ <summary>You can define and use custom classes...</summary>
101
125
 
102
126
  ### Custom class
103
127
 
104
128
  ```ruby
105
- class CancellationExperiment < Gitlab::GrowthExperiment
129
+ class NotificationExperiment < Gitlab::Experiment
106
130
  def initialize(variant_name = nil, **context, &block)
107
- super(:subscription_cancellation, variant_name, **context, &block)
131
+ super(:notification_toggle, variant_name, **context, &block)
132
+
133
+ # Define the control and candidate variant.
134
+ use { render_toggle } # control
135
+ try { render_button } # candidate
108
136
  end
109
137
  end
110
138
 
111
- exp = CancellationExperiment.run(user_id: user.id) do |e|
112
- # context can be passed to `.run`, or after the fact like here.
113
- # context must be added before `#run` or `#track` calls.
114
- e.context(project_id: project.id)
115
-
116
- e.use { toggle_button_interface } # control
117
- e.try { cancel_button_interface } # candidate
139
+ exp = NotificationExperiment.new(actor: user) do |e|
140
+ # Context may be provided within the block or to the instance (exp) but must
141
+ # be finalized before calling run or track.
142
+ e.context(project: project) # add the project id to the context
118
143
  end
119
144
 
120
- # track an event on the experiment we've defined.
145
+ # Run the experiment -- returning the result.
146
+ exp.run
147
+
148
+ # Track an event on the experiment we've defined.
121
149
  exp.track(:clicked_button)
122
150
  ```
123
151
 
124
152
  </details>
125
153
 
126
154
  <details>
127
- <summary>You can hard specify the variant to use...</summary>
155
+ <summary>You can also specify the variant to use...</summary>
128
156
 
129
- ### Specifying which variant to use
157
+ ### Specifying variant
130
158
 
131
- This should generally be discouraged, as it can change the experience users have during rollout, and may confuse generating reports from the tracking calls. It is possible however, and may be useful if you understand the implications.
159
+ You can hardcode the variant if you want. It's important to know what this might do to your data during rollout, so use this with consideration.
132
160
 
133
161
  ```ruby
134
- experiment(:subscription_cancellation, :no_interface, user_id: user.id) do |e|
135
- e.use { toggle_button_interface } # control
136
- e.try { cancel_button_interface } # candidate
162
+ experiment(:notification_toggle, :no_interface, actor: user) do |e|
163
+ e.use { render_toggle } # control
164
+ e.try { render_button } # candidate
137
165
  e.try(:no_interface) { no_interface! } # variant
138
166
  end
139
167
  ```
140
168
 
141
- Or you can set the variant within the block.
169
+ Or you can set the variant within the block. This allows using unique segmentation logic or variant resolution if you need it.
142
170
 
143
171
  ```ruby
144
- experiment(:subscription_cancellation, user_id: user.id) do |e|
145
- e.variant(:variant) # set the variant
172
+ experiment(:notification_toggle, actor: user) do |e|
173
+ # Variant selection must be done before calling run or track.
174
+ e.variant(:no_interface) # set the variant
146
175
  # ...
147
176
  end
148
177
  ```
149
178
 
179
+ Or it can be specified in the call to run if you call it from within the block.
180
+
181
+ ```ruby
182
+ experiment(:notification_toggle, actor: user) do |e|
183
+ # ...
184
+ # Variant selection can be specified when calling run.
185
+ e.run(:no_interface)
186
+ end
187
+ ```
188
+
150
189
  </details>
151
190
 
152
- The `experiment` method, and the underlying `Gitlab::GrowthExperiment` is an implementation on top of [Scientist](https://github.com/github/scientist). Generally speaking you can use the DSL that Scientist defines, but for experiments we use `experiment` instead of `science`, and specify the variant on initialization (or via `#variant` and not in the call to `#run`. The interface is otherwise the same, even though not every aspect of Scientist makes sense for experiments.
191
+ ### Return value
192
+
193
+ By default the return value is a `Gitlab::Experiment` instance. In simple cases you may want only the results of the experiment though. You can call `run` within the block to get the return value of the assigned variant.
194
+
195
+ ```ruby
196
+ experiment(:notification_toggle) do |e|
197
+ e.use { 'A' }
198
+ e.try { 'B' }
199
+ e.run
200
+ end # => 'A'
201
+ ```
202
+
203
+ ### Including the DSL
204
+
205
+ By default, `Gitlab::Experiment` injects itself into the controller and view layers. This exposes the `experiment` method application wide in those layers.
206
+
207
+ Some experiments may extend outside of those layers, so you may want to include it elsewhere. For instance in a mailer, service object, background job, or similar.
208
+
209
+ Note: In a lot of these contexts you may not have a reference to the request (unless you pass it in, or provide access to it) which may be needed if you want to enable cookie behaviors and track that through to user conversion.
210
+
211
+ ```ruby
212
+ class WelcomeMailer < ApplicationMailer
213
+ include Gitlab::Experiment::Dsl # include the `experiment` method
214
+
215
+ def welcome
216
+ @user = params[:user]
217
+
218
+ ex = experiment(:project_suggestions, actor: @user) do |e|
219
+ e.use { 'welcome' }
220
+ e.try { 'welcome_with_project_suggestions' }
221
+ end
222
+
223
+ mail(to: @user.email, subject: 'Welcome!', template: ex.run)
224
+ end
225
+ end
226
+ ```
153
227
 
154
228
  ### Context migrations
155
229
 
156
- There are times when we may need to add new values or change something that we're providing in context while an experiment is running. We make this possible by passing the `migrated_from` context key.
230
+ There are times when we need to change context while an experiment is running. We make this possible by passing the migration data to the experiment.
231
+
232
+ Take for instance, that you might be using `version: 1` in your context currently. To migrate this to `version: 2`, provide the portion of the context you wish to change using a `migrated_with` option.
233
+
234
+ In providing the context migration data, we can resolve an experience and its events all the way back. This can also help in keeping our cache relevant.
157
235
 
158
- Take for instance, that you might be using `version: 1` in your context. If you want to migrate this to `version: 2`, you just need to provide the context that you provided prior, in a `migrated_from` context key. In doing this, a given experiment experience can be resolved back through any number of migrations.
236
+ ```ruby
237
+ # Migrate just the `:version` portion of the previous context, `{ actor: project, version: 1 }`:
238
+ experiment(:my_experiment, actor: project, version: 2, migrated_with: { version: 1 })
239
+ ```
240
+
241
+ You can add or remove context by providing a `migrated_from` option. This approach expects a full context replacement -- i.e. what it was before you added or removed the new context key.
242
+
243
+ If you wanted to introduce a `version` to your context, provide the full previous context.
159
244
 
160
245
  ```ruby
161
- experiment(:my_experiment, version: 2, migrated_from: { version: 1 })
246
+ # Migrate the full context from `{ actor: project }` to `{ actor: project, version: 1 }`:
247
+ experiment(:my_experiment, actor: project, version: 1, migrated_from: { actor: project })
162
248
  ```
163
249
 
164
- It's important to understand that this can bucket a user in a new experience (depending on the rollout strategy being used and what is changing in the context), so you should investigate how this might impact your experiment before using it.
250
+ This can impact an experience if you:
251
+
252
+ 1. haven't implemented the concept of migrations in your variant resolver
253
+ 1. haven't enabled a reasonable caching mechanism
254
+
255
+ ### When there isn't an actor (cookie fallback)
165
256
 
166
- ### When there isn't a user
257
+ When there isn't an identifying key in the context (this is `actor` by default), we fall back to cookies to provide a consistent experience for the client viewing them.
167
258
 
168
- When there isn't a user, we typically have to fall back to another concept to provide a consistent experiment experience. What this means, is that once we bucket someone in a certain bucket, we always bucket them in that bucket.
259
+ Once we assign a certain variant to a context, we need to always provide the same experience. We achieve this by setting a cookie for the experiment in question, but only when needed.
260
+
261
+ This cookie is a temporary, randomized uuid and isn't associated with a user. When we can finally provide an actor, the context is auto migrated from the cookie to that actor.
262
+
263
+ To read and write cookies, we provide the `request` from within the controller and views. The cookie migration will happen automatically if the experiment is within those layers.
264
+
265
+ You'll need to provide the `request` as an option to the experiment if it's outside of the controller and views.
266
+
267
+ ```ruby
268
+ experiment(:my_experiment, actor: user, request: request)
269
+ ```
169
270
 
170
- We do this by using cookies.... [document more]
271
+ The cookie isn't set if the `actor` key isn't present at all in the context. Meaning that when no `actor` key is provided, the cookie will not be set.
272
+
273
+ ```ruby
274
+ # actor is not present, so no cookie is set
275
+ experiment(:my_experiment, project: project)
276
+
277
+ # actor is present and is nil, so the cookie is set and used
278
+ experiment(:my_experiment, actor: nil, project: project)
279
+
280
+ # actor is present and set to a value, so no cookie is set
281
+ experiment(:my_experiment, actor: user, project: project)
282
+ ```
283
+
284
+ For edge cases, you can pass the cookie through by assigning it yourself -- e.g. `actor: request.cookie_jar.signed['my_experiment_actor']`. The cookie name is the full experiment name (including any configured prefix) with `_actor` appended -- e.g. `gitlab_notification_toggle_actor` for the `:notification_toggle` experiment key with a configured prefix of `gitlab`.
285
+
286
+ ## Configuration
287
+
288
+ This gem needs to be configured before being used in a meaningful way.
289
+
290
+ The default configuration will always render the control, so it's important to configure your own logic for resolving variants.
291
+
292
+ Yes, the most important aspect of the gem -- that of determining which variant to render and when -- is up to you. Consider using [Unleash](https://github.com/Unleash/unleash-client-ruby) or [Flipper](https://github.com/jnunemaker/flipper) for this.
293
+
294
+ ```ruby
295
+ Gitlab::Experiment.configure do |config|
296
+ # The block here is evaluated within the scope of the experiment instance,
297
+ # which is why we are able to access things like name and context.
298
+ config.variant_resolver = lambda do |requested_variant|
299
+ # Return the requested variant if a specific one has been provided in code.
300
+ return requested_variant unless requested_variant.nil?
301
+
302
+ # Ask Unleash to determine the variant, given the context we've built,
303
+ # using the control as the fallback.
304
+ fallback = Unleash::Variant.new(name: 'control', enabled: true)
305
+ UNLEASH.get_variant(name, context.value, fallback)
306
+ end
307
+ end
308
+ ```
309
+
310
+ More examples for configuration are available in the provided [rails initializer](lib/generators/gitlab_experiment/install/templates/initializer.rb).
311
+
312
+ ### Client layer / JavaScript
313
+
314
+ This library doesn't attempt to provide any logic for the client layer.
315
+
316
+ Instead it allows you to do this yourself in configuration. Using [Gon](https://github.com/gazay/gon) to publish your experiment information to the client layer is pretty simple.
317
+
318
+ ```ruby
319
+ Gitlab::Experiment.configure do |config|
320
+ config.publishing_behavior = lambda do |_result|
321
+ # Push the experiment knowledge into the front end. The signature contains
322
+ # the context key, and the variant that has been determined.
323
+ Gon.push({ experiment: { name => signature } }, true)
324
+ end
325
+ end
326
+ ```
327
+
328
+ In the client you can now access `window.gon.experiment.notificationToggle`.
329
+
330
+ ### Caching
331
+
332
+ Caching can be enabled in configuration, and is implemented towards the `Rails.cache` / `ActiveSupport::Cache::Store` interface. When you enable caching, any variant resolution will be cached. Migrating the cache through context migrations is handled automatically, and this helps ensure an experiment experience remains consistent.
333
+
334
+ It's important to understand that using caching can drastically change or override your rollout strategy logic.
335
+
336
+ ```ruby
337
+ Gitlab::Experiment.configure do |config|
338
+ config.cache = Rails.cache
339
+ end
340
+ ```
171
341
 
172
342
  ## Tracking, anonymity and GDPR
173
343
 
174
- We intentionally don't, and shouldn't, track things like user ids. What we can and do track is what we consider an "experiment experience" key. This key is generated by the context we pass to the experiment implementation. If we consistently pass the same context to an experiment, we're able to consistently track events generated in that experience. A context can contain things like user, or project -- so, if you only included a user in the context that user would get the same experience across all projects they view, but if you include the currently viewed project in the context the user would potentially have a different experience on each of their projects. Each can be desirable given the objectives of the experiment.
344
+ We generally try not to track things like user identifying values in our experimentation. What we can and do track is the "experiment experience" (a.k.a. the context key).
345
+
346
+ We generate this key from the context passed to the experiment. This allows creating funnels without exposing any user information.
175
347
 
176
- ## Code quality expectations
348
+ This library attempts to be non-user-centric, in that a context can contain things like a user or a project.
177
349
 
178
- Since experimental code is inherently short lived, an intentionally stated goal is to iterate quickly to generate and evaluate performance data.
350
+ If you only include a user, that user would get the same experience across every project they view. If you only include the project, every user who views that project would get the same experience.
179
351
 
180
- This goal prioritizes iteration and resolution over code quality, which means that experiment code may not always meet our code standards guidelines, but should also not negatively impact the availability of GitLab nor contribute to bad data. Even though experiments will be deployed to a minority of users, we still expect a flawless experience for those users, therefore, good test coverage is still required.
352
+ Each of these approaches could be desirable given the objectives of your experiment.
181
353
 
182
- Reviewers and maintainers are encouraged to note when code doesn't meet our code standards guidelines. Please mention your concerns and include or link to them on the experiment tracking issue. The experiment author(s) are responsible for addressing these concerns when the experiment is resolved.
354
+ ### Make code not war
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+
5
+ module GitlabExperiment
6
+ module Generators
7
+ class InstallGenerator < Rails::Generators::Base
8
+ source_root File.expand_path(__dir__)
9
+
10
+ desc 'Installs the Gitlab Experiment initializer into your application.'
11
+
12
+ def copy_initializers
13
+ copy_file 'templates/initializer.rb', 'config/initializers/gitlab_experiment.rb'
14
+ end
15
+
16
+ def display_post_install
17
+ readme 'POST_INSTALL' if behavior == :invoke
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ Gitlab::Experiment.configure do |config|
4
+ # Prefix all experiment names with a given value. Use `nil` for none.
5
+ config.name_prefix = nil
6
+
7
+ # The logger is used to log various details of the experiments.
8
+ config.logger = Logger.new(STDOUT)
9
+
10
+ # The caching layer is expected to respond to fetch, like Rails.cache.
11
+ config.cache = nil
12
+
13
+ # Logic this project uses to resolve a variant for a given experiment.
14
+ #
15
+ # This can return an instance of any object that responds to `name`, or can
16
+ # return a variant name as a string, in which case the build in variant
17
+ # class will be used.
18
+ #
19
+ # This block will be executed within the scope of the experiment instance,
20
+ # so can easily access experiment methods, like getting the name or context.
21
+ config.variant_resolver = lambda do |requested_variant|
22
+ # Run the control, unless a variant was requested in code:
23
+ requested_variant || 'control'
24
+
25
+ # Run the candidate, unless a variant was requested, with a fallback:
26
+ #
27
+ # requested_variant || variant_names.first || 'control'
28
+
29
+ # Using Unleash to determine the variant:
30
+ #
31
+ # fallback = Unleash::Variant.new(name: requested_variant || 'control', enabled: true)
32
+ # Unleash.get_variant(name, context.value, fallback)
33
+
34
+ # Using Flipper to determine the variant:
35
+ #
36
+ # TODO: provide example.
37
+ # Variant.new(name: requested_variant || 'control')
38
+ end
39
+
40
+ # Tracking behavior can be implemented to link an event to an experiment.
41
+ #
42
+ # Similar to the variant_resolver, this is called within the scope of the
43
+ # experiment instance and so can access any methods on the experiment,
44
+ # such as name and signature.
45
+ config.tracking_behavior = lambda do |event, args|
46
+ # An example of using a generic logger to track events:
47
+ config.logger.info "Gitlab::Experiment[#{name}] #{event}: #{args.merge(signature: signature)}"
48
+
49
+ # Using something like snowplow to track events (in gitlab):
50
+ #
51
+ # Gitlab::Tracking.event(name, event, **args.merge(
52
+ # context: (args[:context] || []) << SnowplowTracker::SelfDescribingJson.new(
53
+ # 'iglu:com.gitlab/gitlab_experiment/jsonschema/0-2-0', signature
54
+ # )
55
+ # ))
56
+ end
57
+
58
+ # Called at the end of every experiment run, with the result.
59
+ #
60
+ # You may want to track that you've assigned a variant to a given context,
61
+ # or push the experiment into the client or publish results elsewhere, like
62
+ # into redis. Also called within the scope of the experiment instance.
63
+ config.publishing_behavior = lambda do |result|
64
+ # Track the event using our own configured tracking logic.
65
+ track(:assignment)
66
+
67
+ # Push the experiment knowledge into the front end. The signature contains
68
+ # the context key, and the variant that has been determined.
69
+ #
70
+ # Gon.push({ experiment: { name => signature } }, true)
71
+
72
+ # Log using our logging system, so the result (which can be large) can be
73
+ # reviewed later if we want to.
74
+ #
75
+ # Lograge::Event.log(experiment: name, result: result, signature: signature)
76
+ end
77
+
78
+ # Algorithm that consistently generates a hash key for a given hash map.
79
+ #
80
+ # Given a specific context hash map, we need to generate a consistent hash
81
+ # key. The logic in here will be used for generating cache keys, and may also
82
+ # be used when determining which variant may be presented.
83
+ config.context_hash_strategy = lambda do |context|
84
+ values = context.values.map { |v| (v.respond_to?(:to_global_id) ? v.to_global_id : v).to_s }
85
+ Digest::MD5.hexdigest((context.keys + values).join('|'))
86
+ end
87
+ end
@@ -2,15 +2,19 @@
2
2
 
3
3
  require 'scientist'
4
4
 
5
+ require 'gitlab/experiment/caching'
5
6
  require 'gitlab/experiment/configuration'
7
+ require 'gitlab/experiment/cookies'
6
8
  require 'gitlab/experiment/context'
7
9
  require 'gitlab/experiment/dsl'
8
10
  require 'gitlab/experiment/variant'
9
11
  require 'gitlab/experiment/version'
12
+ require 'gitlab/experiment/engine' if defined?(Rails::Engine)
10
13
 
11
14
  module Gitlab
12
15
  class Experiment
13
16
  include Scientist::Experiment
17
+ include Caching
14
18
 
15
19
  class << self
16
20
  def configure
@@ -28,11 +32,10 @@ module Gitlab
28
32
  def initialize(name, variant_name = nil, **context)
29
33
  @name = name
30
34
  @variant_name = variant_name
31
- @context = Context.new(self)
35
+ @excluded = []
36
+ @context = Context.new(self, context)
32
37
 
33
- context(context)
34
-
35
- ignore { true }
38
+ exclude { !@context.trackable? }
36
39
  compare { false }
37
40
 
38
41
  yield self if block_given?
@@ -46,19 +49,32 @@ module Gitlab
46
49
  end
47
50
 
48
51
  def variant(value = nil)
49
- @variant_name = value unless value.nil?
50
- instance_exec(@variant_name, &Configuration.variant_resolver)
52
+ return @variant_name = value unless value.nil?
53
+
54
+ result = instance_exec(@variant_name, &Configuration.variant_resolver)
55
+ result.respond_to?(:name) ? result : Variant.new(name: result.to_s)
56
+ end
57
+
58
+ def exclude(&block)
59
+ @excluded << block
51
60
  end
52
61
 
53
- def run
54
- @result ||= super(variant.name)
62
+ def run(variant_name = nil)
63
+ @result ||= begin
64
+ @variant_name = variant_name unless variant_name.nil?
65
+ @variant_name ||= :control if excluded?
66
+
67
+ super(cache { variant.name })
68
+ end
55
69
  end
56
70
 
57
- def publish(_result)
58
- track(:assignment)
71
+ def publish(result)
72
+ instance_exec(result, &Configuration.publishing_behavior)
59
73
  end
60
74
 
61
75
  def track(action, **event_args)
76
+ return if excluded?
77
+
62
78
  instance_exec(action, event_args, &Configuration.tracking_behavior)
63
79
  end
64
80
 
@@ -67,13 +83,21 @@ module Gitlab
67
83
  end
68
84
 
69
85
  def variant_names
70
- @variant_names = behaviors.keys.tap { |keys| keys.delete('control') }.map(&:to_sym)
86
+ @variant_names ||= behaviors.keys.map(&:to_sym) - [:control]
87
+ end
88
+
89
+ def signature
90
+ { variant: variant.name, experiment: name }.merge(context.signature)
71
91
  end
72
92
 
73
93
  def enabled?
74
94
  true
75
95
  end
76
96
 
97
+ def excluded?
98
+ @excluded.any? { |exclude| exclude.call(self) }
99
+ end
100
+
77
101
  protected
78
102
 
79
103
  def generate_result(variant_name)
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ class Experiment
5
+ module Caching
6
+ def cache(&block)
7
+ return yield unless (cache = Configuration.cache)
8
+
9
+ key, migrations = cache_strategy
10
+ migrated_cache(cache, migrations || [], key) or cache.fetch(key, &block)
11
+ end
12
+
13
+ private
14
+
15
+ def cache_strategy
16
+ [
17
+ "#{name}:#{signature[:key]}",
18
+ signature[:migration_keys]&.map { |key| "#{name}:#{key}" }
19
+ ]
20
+ end
21
+
22
+ def migrated_cache(cache, migrations, new_key)
23
+ migrations.find do |old_key|
24
+ next unless (value = cache.read(old_key))
25
+
26
+ cache.write(new_key, value)
27
+ cache.delete(old_key)
28
+ break value
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -10,41 +10,28 @@ module Gitlab
10
10
  include Singleton
11
11
 
12
12
  # Prefix all experiment names with a given value. Use `nil` for none.
13
- @name_prefix = 'gitlab_experiment'
13
+ @name_prefix = nil
14
14
 
15
15
  # The logger is used to log various details of the experiments.
16
16
  @logger = Logger.new(STDOUT)
17
17
 
18
+ # Cache layer. Expected to respond to fetch, like Rails.cache.
19
+ @cache = nil
20
+
18
21
  # Logic this project uses to resolve a variant for a given experiment.
19
22
  @variant_resolver = lambda do |requested_variant|
20
- # An example of running the control, unless a variant was requested:
21
- Variant.new(name: requested_variant || 'control')
22
-
23
- # Always run candidate, unless a variant was requested, with fallback:
24
- # Variant.new(name: variant_name || variant_names.first || 'control')
25
-
26
- # Using unleash to determine the variant:
27
- # fallback = Unleash::Variant.new(name: 'control', enabled: true)
28
- # Unleash.get_variant(name, context.value, fallback)
29
-
30
- # Using Flipper to determine the variant:
31
- # TODO: provide example
32
- # Variant.new(name: resolved)
23
+ requested_variant || 'control'
33
24
  end
34
25
 
35
26
  # Tracking behavior can be implemented to link an event to an experiment.
36
- @tracking_behavior = lambda do |action, event_args|
37
- # An example of using a generic logger to track events:
38
- (event_args[:context] ||= []) << context.signature.merge(group: variant.name)
39
- Configuration.logger.info "EXPERIMENT[#{name}] #{action}: #{event_args}"
40
-
41
- # Using snowplow to track events:
42
- # (event_args[:context] ||= []) << SnowplowTracker::SelfDescribingJson.new(
43
- # 'iglu:com.gitlab/gitlab_experiment/jsonschema/1-0-0',
44
- # experiment: context.signature.merge(group: variant.name)
45
- # )
46
- #
47
- # Tracking.event(name, action, **event_args)
27
+ @tracking_behavior = lambda do |event, args|
28
+ Configuration.logger.info "Gitlab::Experiment[#{name}] #{event}: #{args.merge(signature: signature)}"
29
+ end
30
+
31
+ # Called at the end of every experiment run, with the results. You may
32
+ # want to push the experiment into the client or push results elsewhere.
33
+ @publishing_behavior = lambda do |_result|
34
+ track(:assignment)
48
35
  end
49
36
 
50
37
  # Algorithm that consistently generates a hash key for a given hash map.
@@ -54,8 +41,8 @@ module Gitlab
54
41
  end
55
42
 
56
43
  class << self
57
- attr_accessor :name_prefix, :logger
58
- attr_accessor :variant_resolver, :tracking_behavior, :context_hash_strategy
44
+ attr_accessor :name_prefix, :logger, :cache
45
+ attr_accessor :variant_resolver, :tracking_behavior, :publishing_behavior, :context_hash_strategy
59
46
  end
60
47
  end
61
48
  end
@@ -3,26 +3,38 @@
3
3
  module Gitlab
4
4
  class Experiment
5
5
  class Context
6
- def initialize(experiment)
6
+ include Cookies
7
+
8
+ DNT_REGEXP = /^(true|t|yes|y|1|on)$/i.freeze
9
+
10
+ def initialize(experiment, **initial_value)
7
11
  @experiment = experiment
8
12
  @value = {}
9
- @migrations_from = []
10
- @migrations_with = []
13
+ @migrations = { merged: [], unmerged: [] }
14
+
15
+ value(initial_value)
16
+ end
17
+
18
+ def reinitialize(request)
19
+ @signature = nil # clear memoization
20
+ @request = request if request.respond_to?(:headers) && request.respond_to?(:cookie_jar)
11
21
  end
12
22
 
13
23
  def value(value = nil)
14
24
  return @value if value.nil?
15
25
 
16
26
  value = value.dup # dup so we don't mutate
17
- @signature = nil # clear memoized signature
27
+ reinitialize(value.delete(:request))
18
28
 
19
- @migrations_from << value.delete(:migrated_from) if value[:migrated_from]
20
- @migrations_with << value.delete(:migrated_with) if value[:migrated_with]
21
- @value.merge!(migrate_cookie_to_user_id(value))
29
+ @value.merge!(process_migrations(value))
30
+ end
31
+
32
+ def trackable?
33
+ !(@request && @request.headers['DNT'].to_s.match?(DNT_REGEXP))
22
34
  end
23
35
 
24
36
  def freeze
25
- signature # ensure we're done before being frozen
37
+ signature # finalize before freezing
26
38
  super
27
39
  end
28
40
 
@@ -32,34 +44,24 @@ module Gitlab
32
44
 
33
45
  private
34
46
 
35
- def migrate_cookie_to_user_id(value)
36
- return value unless (request = value.delete(:request))
37
- return value if request.headers['DNT'].to_s.match?(/^(true|t|yes|y|1|on)$/i)
38
-
39
- cookie_name = [@experiment.name, 'id'].join('_')
40
- cookie_value = request.cookie_jar.signed[cookie_name]
41
-
42
- if value[:user_id].blank? && cookie_value.blank?
43
- request.cookie_jar.permanent.signed[cookie_name] = {
44
- value: value[:user_id] = SecureRandom.uuid, secure: true, domain: :all, httponly: true
45
- }
46
- elsif cookie_value # we know via the cookie
47
- if value[:user_id].blank?
48
- value[:user_id] = cookie_value
49
- else
50
- @migrations_with << { user_id: cookie_value }
51
- request.cookie_jar.delete(cookie_name, domain: :all)
52
- end
53
- end
54
-
55
- value
47
+ def process_migrations(value)
48
+ add_migration(value.delete(:migrated_from))
49
+ add_migration(value.delete(:migrated_with), merge: true)
50
+
51
+ migrate_cookie(value, "#{@experiment.name}_id")
52
+ end
53
+
54
+ def add_migration(value, merge: false)
55
+ return unless value.is_a?(Hash)
56
+
57
+ @migrations[merge ? :merged : :unmerged] << value
56
58
  end
57
59
 
58
60
  def migration_keys
59
- return nil if @migrations_from.empty? && @migrations_with.empty?
61
+ return nil if @migrations[:unmerged].empty? && @migrations[:merged].empty?
60
62
 
61
- @migrations_from.map { |m| key_for(m) } +
62
- @migrations_with.map { |m| key_for(@value.merge(m)) }
63
+ @migrations[:unmerged].map { |m| key_for(m) } +
64
+ @migrations[:merged].map { |m| key_for(@value.merge(m)) }
63
65
  end
64
66
 
65
67
  def key_for(context)
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Gitlab
6
+ class Experiment
7
+ module Cookies
8
+ private
9
+
10
+ def migrate_cookie(hash, cookie_name)
11
+ return hash if cookie_jar.nil?
12
+
13
+ resolver = [hash, :actor, cookie_name, cookie_jar.signed[cookie_name]]
14
+ resolve_cookie(*resolver) or generate_cookie(*resolver)
15
+ end
16
+
17
+ def cookie_jar
18
+ @request&.cookie_jar
19
+ end
20
+
21
+ def resolve_cookie(hash, key, cookie_name, cookie)
22
+ return if cookie.to_s.empty? && hash[key].nil?
23
+ return hash if cookie.to_s.empty?
24
+ return hash.merge(key => cookie) if hash[key].nil?
25
+
26
+ add_migration(key => cookie)
27
+ cookie_jar.delete(cookie_name, domain: :all)
28
+
29
+ hash
30
+ end
31
+
32
+ def generate_cookie(hash, key, cookie_name, cookie)
33
+ return hash unless hash.key?(key)
34
+
35
+ cookie ||= SecureRandom.uuid
36
+ cookie_jar.permanent.signed[cookie_name] = {
37
+ value: cookie, secure: true, domain: :all, httponly: true
38
+ }
39
+
40
+ hash.merge(key => cookie)
41
+ end
42
+ end
43
+ end
44
+ end
@@ -4,6 +4,7 @@ module Gitlab
4
4
  class Experiment
5
5
  module Dsl
6
6
  def experiment(name, variant_name = nil, **context, &block)
7
+ context[:request] ||= request if respond_to?(:request)
7
8
  Experiment.run(name, variant_name, **context, &block)
8
9
  end
9
10
  end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ class Experiment
5
+ class Engine < ::Rails::Engine
6
+ def self.include_dsl
7
+ ActionController::Base.include(Dsl)
8
+ ActionController::Base.helper_method(:experiment)
9
+ end
10
+
11
+ config.after_initialize do
12
+ include_dsl if defined?(ActionController)
13
+ end
14
+ end
15
+ end
16
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Gitlab
4
4
  class Experiment
5
- VERSION = '0.2.0'
5
+ VERSION = '0.3.0'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,35 +1,35 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gitlab-experiment
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - GitLab
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-09-20 00:00:00.000000000 Z
11
+ date: 2020-10-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: scientist
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
- - - ">="
18
- - !ruby/object:Gem::Version
19
- version: 1.5.0
20
17
  - - "~>"
21
18
  - !ruby/object:Gem::Version
22
19
  version: '1.5'
20
+ - - ">="
21
+ - !ruby/object:Gem::Version
22
+ version: 1.5.0
23
23
  type: :runtime
24
24
  prerelease: false
25
25
  version_requirements: !ruby/object:Gem::Requirement
26
26
  requirements:
27
- - - ">="
28
- - !ruby/object:Gem::Version
29
- version: 1.5.0
30
27
  - - "~>"
31
28
  - !ruby/object:Gem::Version
32
29
  version: '1.5'
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 1.5.0
33
33
  description:
34
34
  email:
35
35
  - gitlab_rubygems@gitlab.com
@@ -39,10 +39,16 @@ extra_rdoc_files: []
39
39
  files:
40
40
  - LICENSE.txt
41
41
  - README.md
42
+ - lib/generators/gitlab_experiment/install/POST_INSTALL
43
+ - lib/generators/gitlab_experiment/install/install_generator.rb
44
+ - lib/generators/gitlab_experiment/install/templates/initializer.rb
42
45
  - lib/gitlab/experiment.rb
46
+ - lib/gitlab/experiment/caching.rb
43
47
  - lib/gitlab/experiment/configuration.rb
44
48
  - lib/gitlab/experiment/context.rb
49
+ - lib/gitlab/experiment/cookies.rb
45
50
  - lib/gitlab/experiment/dsl.rb
51
+ - lib/gitlab/experiment/engine.rb
46
52
  - lib/gitlab/experiment/variant.rb
47
53
  - lib/gitlab/experiment/version.rb
48
54
  homepage: https://gitlab.com/gitlab-org/gitlab-experiment
@@ -64,7 +70,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
64
70
  - !ruby/object:Gem::Version
65
71
  version: '0'
66
72
  requirements: []
67
- rubygems_version: 3.0.3
73
+ rubygems_version: 3.1.4
68
74
  signing_key:
69
75
  specification_version: 4
70
76
  summary: GitLab experiment library built on top of scientist.