gitlab-experiment 0.2.2 → 0.2.3

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: '08b63f640a90ed463a4b2bf58c2e0b1ef041bbd8f80c2d43bf88960ba9aac2a9'
4
- data.tar.gz: 01a8a227c467d097b9dbb9dc9644a1d70012fb3989f2e189e3e63a41e7f39cfd
3
+ metadata.gz: 1a35b23202fa542fa548093c65ec4580e764fdbda2549291a54bfa3ed98cec96
4
+ data.tar.gz: 05f4c1ed8c5761ab03ed1e9d763d6e612f661cf9ee8736e46fb0e48b947ed52d
5
5
  SHA512:
6
- metadata.gz: b161004e60bbbdb724aeb685013ce4b787d0ced9526bdd241e52be327adbc3461ea60eb3a279f25b4f89d46ee37825ae1d571501d0196a8505a9ff4eb8b3d214
7
- data.tar.gz: 8730e755e09ec85fa88b2721af42ec0919d6534449a91d82bc0d2f6ae0055f70e4ad783d045d20dc09c44496c63465656abfe33d6759e3956a153d793f57a2ab
6
+ metadata.gz: 9505898d8870749dbccab8fa98974948895a9898838aecc622e9f3243624a889076b248f6c0698ad8d44c63726447cc1b46638158614bb008015a82ce42a8d19
7
+ data.tar.gz: 3484817693dbaf9254d16083c6fbb745fbe47adc7868bf924bd418d8f67735fd26ac00f7f486aecc4f9ec84647f58e7bfcb0ff15199370b2e782d68cf2a0b2a2
data/README.md CHANGED
@@ -1,26 +1,47 @@
1
1
  GitLab Experiment
2
2
  =================
3
3
 
4
- Here at GitLab, 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).
4
+ Here at GitLab, we run experiments as A/B/n tests and review the data the experiment generates. From that data, we determine the best performing variant and promote it as the new default code path. Or revert back to the control if no variant outperformed it.
5
5
 
6
- When we discuss the behavior of this gem, we'll use terms like experiment, control, candidate, and variant. It's worth defining these terms so they're more understood.
6
+ This library provides a clean and elegant DSL to define, run, and track your GitLab experiment.
7
+
8
+ When we discuss the behavior of this gem, we'll use terms like experiment, context, control, candidate, and variant. It's worth defining these terms so they're more understood.
7
9
 
8
10
  - `experiment` is any deviation of code paths we want to run sometimes and not others.
11
+ - `context` is used to identify a consistent experience we'll provide in an experiment.
9
12
  - `control` is the default, or "original" code path.
10
- - `candidate` is used if there's one experimental code path.
13
+ - `candidate` defines that there's one experimental code path.
11
14
  - `variant(s)` is used when more than one experimental code path exists.
12
15
 
13
- Candidate and variant are referencing the same concept, but can simplify how we speak about the available code paths of a given experiment.
16
+ Candidate and variant are the same concept, but simplify how we speak about experimental paths.
17
+
18
+ ## Opinionated elevator pitch
19
+
20
+ The last time I travelled I went through a process at the airport when I arrived. My passport was checked, I was asked a series of questions about my visit, and was ushered to a digital kiosk where I was prompted with a few more questions on a touch screen.
21
+
22
+ It was Iceland, and for the record, it's a beautiful place and you should visit if you haven't yet.
23
+
24
+ Anyway, at various stages of this process travellers are presented with a physical emoji button interface, and are encouraged to rate their satisfaction within the various stage.
25
+
26
+ Running an experiment could be to change some aspect of the process for only some travelers, let's say by routing them down a different hallway. At various stages of both hallways we still have the emoji interfaces that can be happily tapped or angrily jabbed in their passing.
27
+
28
+ After a while we can compare the results and can evaluate which hallway had the better overall experience, based on the ratings provided at the various stages.
29
+
30
+ This library is about keeping track of which passports we send down which hallway, so we can consistently route them down the same hallway, and to know which hallway they're rating when they do.
31
+
32
+ In this model we don’t need to know anything about the passport holder unless we decide to "ask", as we determine which hallway to send them down initially.
33
+
34
+ This library doesn't provide a system of linking passports back to their passport holders, but it doesn't explicitly make doing so impossible. Doing so is often not a relevant detail on well defined and well executed experiments.
14
35
 
15
36
  ## Installation
16
37
 
17
- Add the gem to your Gemfile and then bundle install.
38
+ Add the gem to your Gemfile and then `bundle install`.
18
39
 
19
40
  ```ruby
20
41
  gem 'gitlab-experiment'
21
42
  ```
22
43
 
23
- If you're using Rails, you can install an initializer that provides basic configuration by running the install generator.
44
+ If you're using Rails, you can install the initializer. It provides basic configuration and documentation.
24
45
 
25
46
  ```shell
26
47
  $ rails generate gitlab-experiment:install
@@ -28,46 +49,51 @@ $ rails generate gitlab-experiment:install
28
49
 
29
50
  ## Implementing an experiment
30
51
 
31
- For the sake of our example, let's say we want to run an experiment for the interface we render for subscription cancellation.
52
+ For the sake of an example let's make one up. Let's run an experiment on what we render for disabling desktop notifications.
53
+
54
+ In our control (current world) we show a simple toggle interface that reads "Notifications". In our experiment we want a "Turn on/off desktop notifications" button with a confirmation.
32
55
 
33
- In our control (current world) we show a simple toggle interface that reads "Auto-renew", and in our experiment candidate we want to show a "Cancel subscription" button with a confirmation dialog. Ultimately the behavior will be the same, but the interface will be considerably different and may involve more or less steps.
56
+ The behavior will be the same, but the interface will be different and may involve more or less steps.
34
57
 
35
- We hypothesize that making it more clear what the action is will help people in making a choice about taking that action or not.
58
+ This makes the action more clear and will help the user in making a choice about if that's what they want to do. Or that's what we're going to try to find out.
36
59
 
37
- We'll name our experiment `subscription_cancellation`. It's important to understand this name may be prefixed based on your configuration, so if you've set `config.name_prefix = "gitlab"` it would be `gitlab_subscription_cancellation`.
60
+ 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.
38
61
 
39
- When you implement and run an experiment you'll need to provide the name you've given it, and a context hash. The context hash is used to determine which variant to provide, and is expected to be consistent between calls to your experiment, so a context hash key can be generated and cached -- which is used to consistently render the same experience given the same context.
62
+ 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.
40
63
 
41
- In our experiment we're going to render one of two views. Control will be our current one, and candidate will be the new view with a cancel button and javascript confirm call.
64
+ A context "key" represents the unique id of a context. It allows us to give the same experience between different calls to the experiment and can be used in caching.
65
+
66
+ Now in our experiment we're going to render one of two views. Control will be our current view, and candidate will be the new toggle button with a confirmation flow.
42
67
 
43
68
  ```ruby
44
69
  class SubscriptionsController < ApplicationController
45
- include Gitlab::Experiment::Dsl
46
-
47
70
  def show
48
- experiment(:subscription_cancellation, user_id: user.id) do |e|
49
- e.use { render_toggle_button } # control
50
- e.try { render_cancel_button } # candidate
71
+ experiment(:notification_toggle, user_id: user.id) do |e|
72
+ e.use { render_toggle } # control
73
+ e.try { render_button } # candidate
51
74
  end
52
75
  end
53
76
  end
54
77
  ```
55
78
 
56
- You can also provide different variant names if you want to. In our case we might want to include the confirmation dialog step or not, to see which one behaves better within our wider experiment.
79
+ You can define the experiment using simple control/candidate paths, or provide named variants.
80
+
81
+ Handling multi-variant experiments is up to the configuration you provide around resolving variants. But in our example we may want to try with and without the confirmation. We can run any number of variations in our experiments this way.
57
82
 
58
83
  ```ruby
59
- experiment(:subscription_cancellation, user_id: user.id) do |e|
60
- e.use { render_toggle_button } # control
61
- e.try(:variant_one) { render_cancel_button(confirmation: true) }
62
- e.try(:variant_two) { render_cancel_button(confirmation: false) }
84
+ experiment(:notification_toggle, user_id: user.id) do |e|
85
+ e.use { render_toggle } # control
86
+ e.try(:variant_one) { render_button(confirmation: true) }
87
+ e.try(:variant_two) { render_button(confirmation: false) }
63
88
  end
64
89
  ```
65
90
 
66
- Later, and elsewhere in code you can use the same `experiment` call to track events on the experiment. You can use this consistent event concept to build out funnels from the data that's being tracked. 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.
91
+ Understanding how an experiment can change behavior is important in evaluating its performance.
92
+
93
+ To this end, we track events that are important by calling the same experiment elsewhere in code. By using the same context, you'll have consistent behavior and the ability to track events to it.
67
94
 
68
95
  ```ruby
69
- exp = experiment(:subscription_cancellation, user_id: user.id)
70
- exp.track('clicked_button')
96
+ experiment(:notification_toggle, user_id: user.id).track(:clicked_button)
71
97
  ```
72
98
 
73
99
  <details>
@@ -76,14 +102,14 @@ exp.track('clicked_button')
76
102
  ### Class level interface using `.run`
77
103
 
78
104
  ```ruby
79
- exp = Gitlab::Experiment.run(:subscription_cancellation, user_id: user.id) do |e|
80
- # Context may be passed in the block, but must be finalized before calling
105
+ exp = Gitlab::Experiment.run(:notification_toggle, user_id: user.id) do |e|
106
+ # Context may be passed in the block, but must be finalized before calling
81
107
  # run or track.
82
108
  e.context(project_id: project.id) # add the project id to the context
83
- e.variant(:candidate) # always run the candidate
109
+
84
110
  # Define the control and candidate variant.
85
- e.use { toggle_button_interface } # control
86
- e.try { cancel_button_interface } # candidate
111
+ e.use { render_toggle } # control
112
+ e.try { render_button } # candidate
87
113
  end
88
114
 
89
115
  # Track an event on the experiment we've defined.
@@ -93,14 +119,14 @@ exp.track(:clicked_button)
93
119
  ### Instance level interface
94
120
 
95
121
  ```ruby
96
- exp = Gitlab::Experiment.new(:subscription_cancellation, user_id: user.id)
97
- # Context may be passed in the block, but must be finalized before calling
122
+ exp = Gitlab::Experiment.new(:notification_toggle, user_id: user.id)
123
+ # Context may be passed in the block, but must be finalized before calling
98
124
  # run or track.
99
125
  exp.context(project_id: project.id) # add the project id to the context
100
126
 
101
127
  # Define the control and candidate variant.
102
- exp.use { toggle_button_interface } # control
103
- exp.try { cancel_button_interface } # candidate
128
+ exp.use { render_toggle } # control
129
+ exp.try { render_button } # candidate
104
130
 
105
131
  # Run the experiment -- returning the result.
106
132
  exp.run
@@ -112,23 +138,23 @@ exp.track(:clicked_button)
112
138
  </details>
113
139
 
114
140
  <details>
115
- <summary>You can define use custom classes...</summary>
141
+ <summary>You can define and use custom classes...</summary>
116
142
 
117
143
  ### Custom class
118
144
 
119
145
  ```ruby
120
- class CancellationExperiment < Gitlab::Experiment
146
+ class NotificationExperiment < Gitlab::Experiment
121
147
  def initialize(variant_name = nil, **context, &block)
122
- super(:subscription_cancellation, variant_name, **context, &block)
148
+ super(:notification_toggle, variant_name, **context, &block)
123
149
 
124
150
  # Define the control and candidate variant.
125
- use { toggle_button_interface } # control
126
- try { cancel_button_interface } # candidate
151
+ use { render_toggle } # control
152
+ try { render_button } # candidate
127
153
  end
128
154
  end
129
155
 
130
- exp = CancellationExperiment.new(user_id: user.id) do |e|
131
- # Context may be passed in the block, but must be finalized before calling
156
+ exp = NotificationExperiment.new(user_id: user.id) do |e|
157
+ # Context may be passed in the block, but must be finalized before calling
132
158
  # run or track.
133
159
  e.context(project_id: project.id) # add the project id to the context
134
160
  end
@@ -143,24 +169,24 @@ exp.track(:clicked_button)
143
169
  </details>
144
170
 
145
171
  <details>
146
- <summary>You can also hard specify the variant to use...</summary>
172
+ <summary>You can also specify the variant to use...</summary>
147
173
 
148
- ### Specifying which variant to use
174
+ ### Specifying variant
149
175
 
150
- 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.
176
+ You can hardcode the variant if you want. It's important to know what this might do to your data during rollout, so use this with consideration.
151
177
 
152
178
  ```ruby
153
- experiment(:subscription_cancellation, :no_interface, user_id: user.id) do |e|
154
- e.use { toggle_button_interface } # control
155
- e.try { cancel_button_interface } # candidate
179
+ experiment(:notification_toggle, :no_interface, user_id: user.id) do |e|
180
+ e.use { render_toggle } # control
181
+ e.try { render_button } # candidate
156
182
  e.try(:no_interface) { no_interface! } # variant
157
183
  end
158
184
  ```
159
185
 
160
- Or you can set the variant within the block -- potentially using some unique or different segmentation strategy that you've written specifically for the experiment at hand.
186
+ Or you can set the variant within the block. This allows using unique segmentation logic or variant resolution if you need it.
161
187
 
162
188
  ```ruby
163
- experiment(:subscription_cancellation, user_id: user.id) do |e|
189
+ experiment(:notification_toggle, user_id: user.id) do |e|
164
190
  e.variant(:no_interface) # set the variant
165
191
  # ...
166
192
  end
@@ -168,51 +194,150 @@ end
168
194
 
169
195
  </details>
170
196
 
171
- The `experiment` method, and the underlying `Gitlab::Experiment` instance 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.
197
+ ### Return value
198
+
199
+ 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.
200
+
201
+ ```ruby
202
+ experiment(:notification_toggle) do |e|
203
+ e.use { 'A' }
204
+ e.try { 'B' }
205
+ e.run
206
+ end # => 'A'
207
+ ```
208
+
209
+ ### Including the DSL
210
+
211
+ By default, `Gitlab::Experiment` injects itself into the controller and view layers. This exposes the `experiment` method application wide in those layers.
212
+
213
+ 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.
214
+
215
+ Note: In a lot of these contexts you may not have a reference to the request (unless you pass it in, or provide access to it) which may be needed if you want to enable cookie behaviors and track that through to user conversion.
216
+
217
+ ```ruby
218
+ class UserMailer < ApplicationMailer
219
+ include Gitlab::Experiment::Dsl # include the `experiment` method
220
+
221
+ def welcome
222
+ @user = params[:user]
223
+
224
+ ex = experiment(:project_suggestions, user_id: @user.id) do |e|
225
+ e.use { 'welcome' }
226
+ e.try { 'welcome_with_project_suggestions' }
227
+ end
228
+
229
+ mail(to: @user.email, subject: 'Welcome!', template: ex.run)
230
+ end
231
+ end
232
+ ```
172
233
 
173
234
  ### Context migrations
174
235
 
175
- There are times when we may need to change something that we're providing in the context while an experiment is running, or even add or remove contexts. We make this possible by passing the migration data to the experiment.
236
+ There are times when we need to change context while an experiment is running. We make this possible by passing the migration data to the experiment.
237
+
238
+ Take for instance, that you might be using `version: 1` in your context currently. To migrate this `version: 2`, provide the context to change using a `migrated_with` option.
176
239
 
177
- Take for instance, that you might be using `version: 1` in your context currently. If you want to migrate this to `version: 2`, you just need to provide the context that you want to change using a `migrated_with` option. In doing this, a given experience (variant rendered/events tracked) can be resolved back through any number of migrations, and can be cached/resolved by using the context key value that was already already generated.
240
+ 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.
178
241
 
179
242
  ```ruby
180
243
  experiment(:my_experiment, user_id: 42, version: 2, migrated_with: { version: 1 })
181
244
  ```
182
245
 
183
- If you're adding or removing a new a value from the context, you'll need to use `migrated_from`, which expects a full context replacement -- e.g. what it was before you added or removed the new context key. For instance, if you wanted to introduce the `version: 1` concept to your context, you would need to use something like the following.
246
+ You can add or remove context by providing a `migrated_from` option. This approach expects a full context replacement -- e.g. what it was before you added or removed the new context key.
247
+
248
+ If you wanted to introduce a `version` to your context, provide the full previous context.
184
249
 
185
250
  ```ruby
186
251
  experiment(:my_experiment, user_id: 42, version: 1, migrated_from: { user_id: 42 })
187
252
  ```
188
253
 
189
- It's important to understand that this can bucket a user in a new experience (depending on the logic in determining in the variant), so you should investigate how this might impact your experiment before using it and that your experiment is implemented in a way that supports migrations.
254
+ This can impact an experience if you haven't:
255
+
256
+ 1. implemented the concept of migrations in your variant resolver
257
+ 1. haven't enabled a reasonable caching mechanism
190
258
 
191
259
  ### When there isn't a user (cookies)
192
260
 
193
- When there isn't a user we typically have to fall back to another concept to provide a consistent experience. What this means, is that once we assign someone a certain variant, we want to always give them the same experience, and we do this by setting a cookie for the experiment we're currently checking on. This cookie value is a random uuid, which we auto migrate to a user_id when our experiment context is finally provided that information. This means that you really only need to provide the request as an option if you're wanting to have an experiment that flows from not-signed-in (or not registered) users to eventually signed-in users.
261
+ When there isn't an identifying key in the context (this is `user_id` by default), we fall back to cookies to provide a consistent experience.
262
+
263
+ Once we assign a certain variant to a context, we need to always provide the same experience. We achieve this by setting a cookie for the experiment in question.
194
264
 
195
- This is considered a temporary cookie value, and isn't used for tracking purposes other than to give a given "user" (in this case it's actually the browser), a consistent experience after we've assigned one.
265
+ This cookie is a randomized uuid and isn't associated with the user. When we can finally provide an identifying key, the context is auto migrated from the cookie to that identifying key. The cookie is a temporary value, and isn't used for tracking.
196
266
 
197
- To read and write cookies, we allow for passing the `request` as an option. This allows us to read, write, and even clean up a cookie when appropriate. We've provided this by default in the ActionController interface though, so this is primarily useful if you're trying to have an experiment span controller and model layers.
267
+ To read and write cookies, we provide the `request` from within the controller and views. The cookie migration will happen automatically if the experiment is within those layers.
268
+
269
+ You'll need to provide the `request` as an option to the experiment if it's outside of the controller and views.
198
270
 
199
271
  ```ruby
200
- experiment(:subscription_cancellation, user_id: user.id, request: request) do |e|
201
- e.use { render_toggle_button } # control
202
- e.try { render_cancel_button } # candidate
203
- end
204
- ```
272
+ experiment(:my_experiment, user_id: user&.id, request: request)
273
+ ```
274
+
275
+ The cookie isn't set if the identifying key isn't present in the context. In this case, if there was no `user_id` key provided, the cookie wouldn't be set.
205
276
 
206
- If needed, for edge cases (like in a background job), you can manually pass in the cookie value. Passing it in as `user_id: request.cookie_jar.signed['subscription_cancellation_id']`. The cookie name is the experiment name (prefixed if configured to be) with `_id` appended.
277
+ For edge cases, you can pass the cookie through by assigning it yourself -- e.g. `user_id: request.cookie_jar.signed['my_experiment_id']`. The cookie name is the experiment name (prefixed if configured) with `_id` appended.
207
278
 
208
279
  ## Configuration
209
280
 
210
- The gem is meant to be configured before being used. The default configuration will always render the control behavior, so it's important to implement your own logic for this or you will always get the control of an experiment.
281
+ This gem needs to be configured before being used in a meaningful way.
211
282
 
212
- The most important aspect of the gem, determining which variant to render and when, is up to you, and you may want to consider using [Unleash](https://github.com/Unleash/unleash-client-ruby) (which has the concept of multi-variants built in), or [Flipper](https://github.com/jnunemaker/flipper) in helping with this.
283
+ The default configuration will always render the control. So it's important to configure your own logic for resolving variants.
213
284
 
214
- Examples for configuration are available in the provided install generator, or in the source code configuration.rb file itself.
285
+ Yes, the most important aspect of the gem, determining which variant to render and when, is up to you. Consider using [Unleash](https://github.com/Unleash/unleash-client-ruby) or [Flipper](https://github.com/jnunemaker/flipper) for this.
286
+
287
+ ```ruby
288
+ Gitlab::Experiment.configure do |config|
289
+ config.variant_resolver = lambda do |requested_variant|
290
+ # Return the requested variant if a specific one has been provided in code.
291
+ return requested_variant unless requested_variant.nil?
292
+
293
+ # Ask Unleash to determine the variant, given the context we've built,
294
+ # using the control as the fallback.
295
+ fallback = Unleash::Variant.new(name: 'control', enabled: true)
296
+ UNLEASH.get_variant(name, context.value, fallback)
297
+ end
298
+ end
299
+ ```
300
+
301
+ More examples for configuration are available in the provided [rails initializer](lib/generators/gitlab_experiment/install/templates/initializer.rb).
302
+
303
+ ### Client layer / JavaScript
304
+
305
+ This library doesn't attempt to provide any logic for the client layer.
306
+
307
+ Instead it allows you to do this yourself in configuration. Using Gon to publish your experiment information to the client layer is pretty simple.
308
+
309
+ ```ruby
310
+ Gitlab::Experiment.configure do |config|
311
+ config.publishing_behavior = lambda do |_result|
312
+ # Push the experiment knowledge into the front end. The signature contains
313
+ # the context key, and the variant that has been determined.
314
+ Gon.push(experiment: { name => signature })
315
+ end
316
+ end
317
+ ```
318
+
319
+ In the client you can now access `window.gon.experiment.notificationToggle`.
320
+
321
+ ### Caching
322
+
323
+ Caching can be enabled in configuration, and is implemented towards the `Rails.cache` / `ActiveSupport::Cache::Store` interface. When you enable caching, any variant resolution will be cached. Migrating the cache through context migrations is handled automatically, and this helps ensure an experiment experience remains consistent.
324
+
325
+ ```ruby
326
+ Gitlab::Experiment.configure do |config|
327
+ config.cache = Rails.cache
328
+ end
329
+ ```
215
330
 
216
331
  ## Tracking, anonymity and GDPR
217
332
 
218
- We intentionally don't, and shouldn't, track things like user ids on our experiments. What we can and do track is what we consider an "experiment experience" key. This key is generated from the context we pass to the experiment and has the concept of migrating through different versions of context. 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.
333
+ We generally try not to track things like user identifying values in our experimentation. What we can and do track is the "experiment experience" (a.k.a the context key).
334
+
335
+ We generate this key from the context passed to the experiment. This allows creating funnels without exposing any user information.
336
+
337
+ This library attempts to be non user centric, in that a context can contain things like a user, or a project.
338
+
339
+ If you only include a user, that user would get the same experience across every project they view. If you only include the project, every user who views that project would get the same experience.
340
+
341
+ Each of these approaches could be desirable given the objectives of your experiment.
342
+
343
+ ### Make code not war
@@ -7,6 +7,9 @@ Gitlab::Experiment.configure do |config|
7
7
  # The logger is used to log various details of the experiments.
8
8
  config.logger = Logger.new(STDOUT)
9
9
 
10
+ # The caching layer is expected to respond to fetch, like Rails.cache.
11
+ config.cache = nil
12
+
10
13
  # Logic this project uses to resolve a variant for a given experiment.
11
14
  #
12
15
  # This can return an instance of any object that responds to `name`, or can
@@ -16,53 +19,62 @@ Gitlab::Experiment.configure do |config|
16
19
  # This block will be executed within the scope of the experiment instance,
17
20
  # so can easily access experiment methods, like getting the name or context.
18
21
  config.variant_resolver = lambda do |requested_variant|
19
- # An example of running the control, unless a variant was requested:
22
+ # Run the control, unless a variant was requested in code:
20
23
  requested_variant || 'control'
21
24
 
22
- # Always run candidate, unless a variant was requested, with fallback:
23
- # variant_name || variant_names.first || 'control'
25
+ # Run the candidate, unless a variant was requested, with a fallback:
26
+ #
27
+ # requested_variant || variant_names.first || 'control'
24
28
 
25
29
  # Using unleash to determine the variant:
26
- # TODO: this isn't entirely accurate.
30
+ #
27
31
  # fallback = Unleash::Variant.new(name: requested_variant || 'control', enabled: true)
28
- # UNLEASH.get_variant(name, context.value, fallback)
32
+ # Unleash.get_variant(name, context.value, fallback)
29
33
 
30
34
  # Using Flipper to determine the variant:
35
+ #
31
36
  # TODO: provide example.
32
- # Variant.new(name: resolved)
37
+ # Variant.new(name: requested_variant || 'control')
33
38
  end
34
39
 
35
40
  # Tracking behavior can be implemented to link an event to an experiment.
36
41
  #
37
- # Similar to the variant resolver, this is called within the scope of the
42
+ # Similar to the variant_resolver, this is called within the scope of the
38
43
  # experiment instance and so can access any methods on the experiment.
39
- config.tracking_behavior = lambda do |action, event_args|
44
+ #
45
+ #
46
+ config.tracking_behavior = lambda do |event, args|
40
47
  # An example of using a generic logger to track events:
41
- (event_args[:context] ||= []) << context.signature.merge(group: variant.name)
42
- config.logger.info "Gitlab::Experiment[#{name}] #{action}: #{event_args}"
48
+ config.logger.info "Gitlab::Experiment[#{name}] #{event}: #{args.merge(signature: signature)}"
43
49
 
44
50
  # Using something like snowplow to track events:
45
- # (event_args[:context] ||= []) << SnowplowTracker::SelfDescribingJson.new(
46
- # 'iglu:com.gitlab/gitlab_experiment/jsonschema/1-0-0',
47
- # experiment: context.signature.merge(group: variant.name)
48
- # )
49
51
  #
50
- # Tracking.event(name, action, **event_args)
52
+ # Gitlab::Tracking.event(name, event, **args.merge(
53
+ # context: (args[:context] || []) << SnowplowTracker::SelfDescribingJson.new(
54
+ # 'iglu:com.gitlab/gitlab_experiment/jsonschema/0-2-0',
55
+ # signature: signature
56
+ # )
57
+ # ))
51
58
  end
52
59
 
53
60
  # Called at the end of every experiment run, with the result.
54
61
  #
55
62
  # You may want to track that you've assigned a variant to a given context,
56
63
  # or push the experiment into the client or publish results elsewhere, like
57
- # into redis.
58
- config.publishing_behavior = lambda do |_result|
59
- track(:assignment) # this will call the config.tracking_behavior
60
-
61
- # Log the results, so we can inspect them if we wanted to later.
62
- # LogRage.log(result: _result)
64
+ # into redis. Also called within the scope of the experiment instance.
65
+ config.publishing_behavior = lambda do |result|
66
+ # Track the event using our own configured tracking logic.
67
+ track(:assignment)
63
68
 
64
- # Push the experiment knowledge into the client using Gon.
69
+ # Push the experiment knowledge into the front end. The signature contains
70
+ # the context key, and the variant that has been determined.
71
+ #
65
72
  # Gon.push(experiment: { name => signature })
73
+
74
+ # Log using our logging system, so the result (which can be large) can be
75
+ # reviewed later if we want to.
76
+ #
77
+ # Lograge::Event.log(experiment: name, result: result, signature: signature)
66
78
  end
67
79
 
68
80
  # Algorithm that consistently generates a hash key for a given hash map.
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'scientist'
4
4
 
5
+ require 'gitlab/experiment/caching'
5
6
  require 'gitlab/experiment/configuration'
6
7
  require 'gitlab/experiment/context'
7
8
  require 'gitlab/experiment/dsl'
@@ -12,6 +13,7 @@ require 'gitlab/experiment/engine' if defined?(Rails::Engine)
12
13
  module Gitlab
13
14
  class Experiment
14
15
  include Scientist::Experiment
16
+ include Caching
15
17
 
16
18
  class << self
17
19
  def configure
@@ -26,7 +28,7 @@ module Gitlab
26
28
  end
27
29
  end
28
30
 
29
- delegate :signature, to: :context
31
+ delegate :signature, :cache_strategy, to: :context
30
32
 
31
33
  def initialize(name, variant_name = nil, **context)
32
34
  @name = name
@@ -49,13 +51,14 @@ module Gitlab
49
51
  end
50
52
 
51
53
  def variant(value = nil)
52
- @variant_name = value unless value.nil?
54
+ return @variant_name = value unless value.nil?
55
+
53
56
  result = instance_exec(@variant_name, &Configuration.variant_resolver)
54
57
  result.respond_to?(:name) ? result : Variant.new(name: result.to_s)
55
58
  end
56
59
 
57
60
  def run
58
- @result ||= super(variant.name)
61
+ @result ||= super(cache { variant.name })
59
62
  end
60
63
 
61
64
  def publish(result)
@@ -71,13 +74,21 @@ module Gitlab
71
74
  end
72
75
 
73
76
  def variant_names
74
- @variant_names = behaviors.keys.tap { |keys| keys.delete('control') }.map(&:to_sym)
77
+ @variant_names ||= behaviors.keys.tap { |keys| keys.delete('control') }.map(&:to_sym)
75
78
  end
76
79
 
77
80
  def enabled?
78
81
  true
79
82
  end
80
83
 
84
+ def identifying_key
85
+ :user_id
86
+ end
87
+
88
+ def cache_key_for(key, migration: false)
89
+ "#{name}:#{key}"
90
+ end
91
+
81
92
  protected
82
93
 
83
94
  def generate_result(variant_name)
@@ -0,0 +1,24 @@
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
+ def migrated_cache(cache, migrations, new_key)
14
+ migrations.find do |old_key|
15
+ next unless (value = cache.read(old_key))
16
+
17
+ cache.write(new_key, value)
18
+ cache.delete(old_key)
19
+ break value
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -15,20 +15,23 @@ module Gitlab
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
23
  requested_variant || 'control'
21
24
  end
22
25
 
23
26
  # Tracking behavior can be implemented to link an event to an experiment.
24
- @tracking_behavior = lambda do |action, event_args|
25
- Configuration.logger.info "Gitlab::Experiment[#{name}] #{action}: #{event_args.merge(signature: signature)}"
27
+ @tracking_behavior = lambda do |event, args|
28
+ Configuration.logger.info "Gitlab::Experiment[#{name}] #{event}: #{args.merge(signature: signature)}"
26
29
  end
27
30
 
28
31
  # Called at the end of every experiment run, with the results. You may
29
32
  # want to push the experiment into the client or push results elsewhere.
30
33
  @publishing_behavior = lambda do |_result|
31
- track(:assignment) # this will call the config.tracking_behavior
34
+ track(:assignment)
32
35
  end
33
36
 
34
37
  # Algorithm that consistently generates a hash key for a given hash map.
@@ -38,7 +41,7 @@ module Gitlab
38
41
  end
39
42
 
40
43
  class << self
41
- attr_accessor :name_prefix, :logger
44
+ attr_accessor :name_prefix, :logger, :cache
42
45
  attr_accessor :variant_resolver, :tracking_behavior, :publishing_behavior, :context_hash_strategy
43
46
  end
44
47
  end
@@ -16,18 +16,27 @@ module Gitlab
16
16
  return @value if value.nil?
17
17
 
18
18
  value = value.dup # dup so we don't mutate
19
- @signature = nil # clear memoized signature
19
+ @signature = @cache_strategy = nil # clear memoization
20
20
 
21
21
  @migrations_from << value.delete(:migrated_from) if value[:migrated_from]
22
22
  @migrations_with << value.delete(:migrated_with) if value[:migrated_with]
23
- @value.merge!(migrate_cookie_to_user_id(value))
23
+ @value.merge!(auto_migrate_cookie(value, value.delete(:request)))
24
24
  end
25
25
 
26
26
  def freeze
27
- signature # ensure we're done before being frozen
27
+ cache_strategy # ensure we memoize before freezing
28
28
  super
29
29
  end
30
30
 
31
+ def cache_strategy
32
+ @cache_strategy ||= [
33
+ @experiment.cache_key_for(signature[:key]),
34
+ signature[:migration_keys]&.map do |key|
35
+ @experiment.cache_key_for(key, migration: true)
36
+ end
37
+ ]
38
+ end
39
+
31
40
  def signature
32
41
  @signature ||= {
33
42
  key: key_for(@value),
@@ -38,16 +47,18 @@ module Gitlab
38
47
 
39
48
  private
40
49
 
41
- def migrate_cookie_to_user_id(hash)
42
- return hash unless (request = hash.delete(:request))
43
- return hash unless request.respond_to?(:headers) && request.respond_to?(:cookie_jar)
50
+ def auto_migrate_cookie(hash, request)
51
+ return hash unless request&.respond_to?(:headers) && request&.respond_to?(:cookie_jar)
44
52
  return hash if request.headers['DNT'].to_s.match?(DNT_REGEXP)
45
53
 
46
- jar = request.cookie_jar
47
- resolver = [jar, hash, :user_id, jar.signed[cookie_name]].compact
54
+ resolver = cookie_resolver(request.cookie_jar, hash)
48
55
  resolve_cookie(*resolver) or generate_cookie(*resolver)
49
56
  end
50
57
 
58
+ def cookie_resolver(jar, hash)
59
+ [jar, hash, @experiment.identifying_key, jar.signed[cookie_name]].compact
60
+ end
61
+
51
62
  def cookie_name
52
63
  @cookie_name ||= [@experiment.name, 'id'].join('_')
53
64
  end
@@ -56,13 +67,15 @@ module Gitlab
56
67
  return if cookie.blank? && hash[key].blank?
57
68
  return hash.merge(key => cookie) if hash[key].blank?
58
69
 
59
- @migrations_with << { user_id: cookie }
70
+ @migrations_with << { key => cookie }
60
71
  jar.delete(cookie_name, domain: :all)
61
72
 
62
73
  hash
63
74
  end
64
75
 
65
76
  def generate_cookie(jar, hash, key, cookie = SecureRandom.uuid)
77
+ return hash unless hash.key?(key)
78
+
66
79
  jar.permanent.signed[cookie_name] = {
67
80
  value: cookie, secure: true, domain: :all, httponly: true
68
81
  }
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Gitlab
4
4
  class Experiment
5
- VERSION = '0.2.2'
5
+ VERSION = '0.2.3'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gitlab-experiment
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: 0.2.3
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-24 00:00:00.000000000 Z
11
+ date: 2020-09-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: scientist
@@ -43,6 +43,7 @@ files:
43
43
  - lib/generators/gitlab_experiment/install/install_generator.rb
44
44
  - lib/generators/gitlab_experiment/install/templates/initializer.rb
45
45
  - lib/gitlab/experiment.rb
46
+ - lib/gitlab/experiment/caching.rb
46
47
  - lib/gitlab/experiment/configuration.rb
47
48
  - lib/gitlab/experiment/context.rb
48
49
  - lib/gitlab/experiment/dsl.rb