gitlab-experiment 0.2.3 → 0.4.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1a35b23202fa542fa548093c65ec4580e764fdbda2549291a54bfa3ed98cec96
4
- data.tar.gz: 05f4c1ed8c5761ab03ed1e9d763d6e612f661cf9ee8736e46fb0e48b947ed52d
3
+ metadata.gz: 740552e72dc8655bde106bbca76a2b5be2776196ad957c780cf06d2bc3324a11
4
+ data.tar.gz: 19f8fbc4588b8efcff729464b5f3480a9d41a0429f8ea8153d097a0c9c948aae
5
5
  SHA512:
6
- metadata.gz: 9505898d8870749dbccab8fa98974948895a9898838aecc622e9f3243624a889076b248f6c0698ad8d44c63726447cc1b46638158614bb008015a82ce42a8d19
7
- data.tar.gz: 3484817693dbaf9254d16083c6fbb745fbe47adc7868bf924bd418d8f67735fd26ac00f7f486aecc4f9ec84647f58e7bfcb0ff15199370b2e782d68cf2a0b2a2
6
+ metadata.gz: b4f4b3f0a7c56087a0bccaf916d568a82ad02cd7784ba94e86254f3c11fabc106cf5da171d889d95818fb4229b34ab9712ddddf597c2dc6075d724c3c08af80b
7
+ data.tar.gz: aa6c559ca3e62e810ffa8efd7b1846da0beefd6ad91c10e425d7ed1477eb3073cb792b909d39199cb5847ce1b25d9bf19189793190f6e5a519c43468a341fe02
data/README.md CHANGED
@@ -1,9 +1,10 @@
1
- GitLab Experiment
2
- =================
1
+ # GitLab Experiment
2
+
3
+ <img alt="experiment" src="/uploads/60990b2dbf4c0406bbf8b7f998de2dea/experiment.png" align="right" width="40%">
3
4
 
4
5
  Here at GitLab, we run experiments as A/B/n tests and review the data the experiment generates. From that data, we determine the best performing variant and promote it as the new default code path. Or revert back to the control if no variant outperformed it.
5
6
 
6
- This library provides a clean and elegant DSL to define, run, and track your GitLab experiment.
7
+ This library provides a clean and elegant DSL (domain specific language) to define, run, and track your GitLab experiment.
7
8
 
8
9
  When we discuss the behavior of this gem, we'll use terms like experiment, context, control, candidate, and variant. It's worth defining these terms so they're more understood.
9
10
 
@@ -13,25 +14,7 @@ When we discuss the behavior of this gem, we'll use terms like experiment, conte
13
14
  - `candidate` defines that there's one experimental code path.
14
15
  - `variant(s)` is used when more than one experimental code path exists.
15
16
 
16
- Candidate and variant are the same concept, but simplify how we speak about experimental paths.
17
-
18
- ## Opinionated elevator pitch
19
-
20
- The last time I travelled I went through a process at the airport when I arrived. My passport was checked, I was asked a series of questions about my visit, and was ushered to a digital kiosk where I was prompted with a few more questions on a touch screen.
21
-
22
- It was Iceland, and for the record, it's a beautiful place and you should visit if you haven't yet.
23
-
24
- Anyway, at various stages of this process travellers are presented with a physical emoji button interface, and are encouraged to rate their satisfaction within the various stage.
25
-
26
- Running an experiment could be to change some aspect of the process for only some travelers, let's say by routing them down a different hallway. At various stages of both hallways we still have the emoji interfaces that can be happily tapped or angrily jabbed in their passing.
27
-
28
- After a while we can compare the results and can evaluate which hallway had the better overall experience, based on the ratings provided at the various stages.
29
-
30
- This library is about keeping track of which passports we send down which hallway, so we can consistently route them down the same hallway, and to know which hallway they're rating when they do.
31
-
32
- In this model we don’t need to know anything about the passport holder unless we decide to "ask", as we determine which hallway to send them down initially.
33
-
34
- This library doesn't provide a system of linking passports back to their passport holders, but it doesn't explicitly make doing so impossible. Doing so is often not a relevant detail on well defined and well executed experiments.
17
+ Candidate and variant are the same concept, but simplify how we speak about experimental paths.<br clear="all">
35
18
 
36
19
  ## Installation
37
20
 
@@ -44,18 +27,18 @@ gem 'gitlab-experiment'
44
27
  If you're using Rails, you can install the initializer. It provides basic configuration and documentation.
45
28
 
46
29
  ```shell
47
- $ rails generate gitlab-experiment:install
30
+ $ rails generate gitlab:experiment:install
48
31
  ```
49
32
 
50
33
  ## Implementing an experiment
51
34
 
52
35
  For the sake of an example let's make one up. Let's run an experiment on what we render for disabling desktop notifications.
53
36
 
54
- In our control (current world) we show a simple toggle interface that reads "Notifications". In our experiment we want a "Turn on/off desktop notifications" button with a confirmation.
37
+ In our control (current world) we show a simple toggle interface labeled, "Notifications." In our experiment we want a "Turn on/off desktop notifications" button with a confirmation.
38
+
39
+ The behavior will be the same, but the interface will be different and may involve more or fewer steps.
55
40
 
56
- The behavior will be the same, but the interface will be different and may involve more or less steps.
57
-
58
- This makes the action more clear and will help the user in making a choice about if that's what they want to do. Or that's what we're going to try to find out.
41
+ Our hypothesis is that this will make the action more clear and will help in making a choice about if that's what the user really wants to do.
59
42
 
60
43
  We'll name our experiment `notification_toggle`. This name is prefixed based on configuration. If you've set `config.name_prefix = 'gitlab'`, the experiment name would be `gitlab_notification_toggle` elsewhere.
61
44
 
@@ -63,12 +46,12 @@ When you implement an experiment you'll need to provide a name, and a context. T
63
46
 
64
47
  A context "key" represents the unique id of a context. It allows us to give the same experience between different calls to the experiment and can be used in caching.
65
48
 
66
- Now in our experiment we're going to render one of two views. Control will be our current view, and candidate will be the new toggle button with a confirmation flow.
67
-
49
+ Now in our experiment we're going to render one of two views: the control will be our current view, and the candidate will be the new toggle button with a confirmation flow.
50
+
68
51
  ```ruby
69
52
  class SubscriptionsController < ApplicationController
70
53
  def show
71
- experiment(:notification_toggle, user_id: user.id) do |e|
54
+ experiment(:notification_toggle, actor: user) do |e|
72
55
  e.use { render_toggle } # control
73
56
  e.try { render_button } # candidate
74
57
  end
@@ -81,7 +64,7 @@ You can define the experiment using simple control/candidate paths, or provide n
81
64
  Handling multi-variant experiments is up to the configuration you provide around resolving variants. But in our example we may want to try with and without the confirmation. We can run any number of variations in our experiments this way.
82
65
 
83
66
  ```ruby
84
- experiment(:notification_toggle, user_id: user.id) do |e|
67
+ experiment(:notification_toggle, actor: user) do |e|
85
68
  e.use { render_toggle } # control
86
69
  e.try(:variant_one) { render_button(confirmation: true) }
87
70
  e.try(:variant_two) { render_button(confirmation: false) }
@@ -93,7 +76,7 @@ Understanding how an experiment can change behavior is important in evaluating i
93
76
  To this end, we track events that are important by calling the same experiment elsewhere in code. By using the same context, you'll have consistent behavior and the ability to track events to it.
94
77
 
95
78
  ```ruby
96
- experiment(:notification_toggle, user_id: user.id).track(:clicked_button)
79
+ experiment(:notification_toggle, actor: user).track(:clicked_button)
97
80
  ```
98
81
 
99
82
  <details>
@@ -102,10 +85,10 @@ experiment(:notification_toggle, user_id: user.id).track(:clicked_button)
102
85
  ### Class level interface using `.run`
103
86
 
104
87
  ```ruby
105
- exp = Gitlab::Experiment.run(:notification_toggle, user_id: user.id) do |e|
88
+ exp = Gitlab::Experiment.run(:notification_toggle, actor: user) do |e|
106
89
  # Context may be passed in the block, but must be finalized before calling
107
90
  # run or track.
108
- e.context(project_id: project.id) # add the project id to the context
91
+ e.context(project: project) # add the project to the context
109
92
 
110
93
  # Define the control and candidate variant.
111
94
  e.use { render_toggle } # control
@@ -119,16 +102,16 @@ exp.track(:clicked_button)
119
102
  ### Instance level interface
120
103
 
121
104
  ```ruby
122
- exp = Gitlab::Experiment.new(:notification_toggle, user_id: user.id)
123
- # Context may be passed in the block, but must be finalized before calling
124
- # run or track.
125
- exp.context(project_id: project.id) # add the project id to the context
105
+ exp = Gitlab::Experiment.new(:notification_toggle, actor: user)
106
+ # Additional context may be provided to the instance (exp) but must be
107
+ # finalized before calling run or track.
108
+ exp.context(project: project) # add the project id to the context
126
109
 
127
110
  # Define the control and candidate variant.
128
111
  exp.use { render_toggle } # control
129
112
  exp.try { render_button } # candidate
130
113
 
131
- # Run the experiment -- returning the result.
114
+ # Run the experiment, returning the result.
132
115
  exp.run
133
116
 
134
117
  # Track an event on the experiment we've defined.
@@ -153,10 +136,10 @@ class NotificationExperiment < Gitlab::Experiment
153
136
  end
154
137
  end
155
138
 
156
- exp = NotificationExperiment.new(user_id: user.id) do |e|
157
- # Context may be passed in the block, but must be finalized before calling
158
- # run or track.
159
- e.context(project_id: project.id) # add the project id to the context
139
+ exp = NotificationExperiment.new(actor: user) do |e|
140
+ # Context may be provided within the block or to the instance (exp) but must
141
+ # be finalized before calling run or track.
142
+ e.context(project: project) # add the project id to the context
160
143
  end
161
144
 
162
145
  # Run the experiment -- returning the result.
@@ -176,7 +159,7 @@ exp.track(:clicked_button)
176
159
  You can hardcode the variant if you want. It's important to know what this might do to your data during rollout, so use this with consideration.
177
160
 
178
161
  ```ruby
179
- experiment(:notification_toggle, :no_interface, user_id: user.id) do |e|
162
+ experiment(:notification_toggle, :no_interface, actor: user) do |e|
180
163
  e.use { render_toggle } # control
181
164
  e.try { render_button } # candidate
182
165
  e.try(:no_interface) { no_interface! } # variant
@@ -186,12 +169,23 @@ end
186
169
  Or you can set the variant within the block. This allows using unique segmentation logic or variant resolution if you need it.
187
170
 
188
171
  ```ruby
189
- experiment(:notification_toggle, user_id: user.id) do |e|
172
+ experiment(:notification_toggle, actor: user) do |e|
173
+ # Variant selection must be done before calling run or track.
190
174
  e.variant(:no_interface) # set the variant
191
175
  # ...
192
176
  end
193
177
  ```
194
178
 
179
+ Or it can be specified in the call to run if you call it from within the block.
180
+
181
+ ```ruby
182
+ experiment(:notification_toggle, actor: user) do |e|
183
+ # ...
184
+ # Variant selection can be specified when calling run.
185
+ e.run(:no_interface)
186
+ end
187
+ ```
188
+
195
189
  </details>
196
190
 
197
191
  ### Return value
@@ -215,13 +209,13 @@ Some experiments may extend outside of those layers, so you may want to include
215
209
  Note: In a lot of these contexts you may not have a reference to the request (unless you pass it in, or provide access to it) which may be needed if you want to enable cookie behaviors and track that through to user conversion.
216
210
 
217
211
  ```ruby
218
- class UserMailer < ApplicationMailer
212
+ class WelcomeMailer < ApplicationMailer
219
213
  include Gitlab::Experiment::Dsl # include the `experiment` method
220
214
 
221
215
  def welcome
222
216
  @user = params[:user]
223
217
 
224
- ex = experiment(:project_suggestions, user_id: @user.id) do |e|
218
+ ex = experiment(:project_suggestions, actor: @user) do |e|
225
219
  e.use { 'welcome' }
226
220
  e.try { 'welcome_with_project_suggestions' }
227
221
  end
@@ -235,57 +229,72 @@ end
235
229
 
236
230
  There are times when we need to change context while an experiment is running. We make this possible by passing the migration data to the experiment.
237
231
 
238
- Take for instance, that you might be using `version: 1` in your context currently. To migrate this `version: 2`, provide the context to change using a `migrated_with` option.
232
+ Take for instance, that you might be using `version: 1` in your context currently. To migrate this to `version: 2`, provide the portion of the context you wish to change using a `migrated_with` option.
239
233
 
240
234
  In providing the context migration data, we can resolve an experience and its events all the way back. This can also help in keeping our cache relevant.
241
235
 
242
236
  ```ruby
243
- experiment(:my_experiment, user_id: 42, version: 2, migrated_with: { version: 1 })
237
+ # Migrate just the `:version` portion of the previous context, `{ actor: project, version: 1 }`:
238
+ experiment(:my_experiment, actor: project, version: 2, migrated_with: { version: 1 })
244
239
  ```
245
240
 
246
- You can add or remove context by providing a `migrated_from` option. This approach expects a full context replacement -- e.g. what it was before you added or removed the new context key.
241
+ You can add or remove context by providing a `migrated_from` option. This approach expects a full context replacement -- i.e. what it was before you added or removed the new context key.
247
242
 
248
243
  If you wanted to introduce a `version` to your context, provide the full previous context.
249
-
244
+
250
245
  ```ruby
251
- experiment(:my_experiment, user_id: 42, version: 1, migrated_from: { user_id: 42 })
246
+ # Migrate the full context from `{ actor: project }` to `{ actor: project, version: 1 }`:
247
+ experiment(:my_experiment, actor: project, version: 1, migrated_from: { actor: project })
252
248
  ```
253
249
 
254
- This can impact an experience if you haven't:
250
+ This can impact an experience if you:
255
251
 
256
- 1. implemented the concept of migrations in your variant resolver
252
+ 1. haven't implemented the concept of migrations in your variant resolver
257
253
  1. haven't enabled a reasonable caching mechanism
258
254
 
259
- ### When there isn't a user (cookies)
255
+ ### When there isn't an actor (cookie fallback)
260
256
 
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.
257
+ When there isn't an identifying key in the context (this is `actor` by default), we fall back to cookies to provide a consistent experience for the client viewing them.
262
258
 
263
- Once we assign a certain variant to a context, we need to always provide the same experience. We achieve this by setting a cookie for the experiment in question.
259
+ Once we assign a certain variant to a context, we need to always provide the same experience. We achieve this by setting a cookie for the experiment in question, but only when needed.
264
260
 
265
- This cookie is a randomized uuid and isn't associated with 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.
261
+ This cookie is a temporary, randomized uuid and isn't associated with a user. When we can finally provide an actor, the context is auto migrated from the cookie to that actor.
266
262
 
267
263
  To read and write cookies, we provide the `request` from within the controller and views. The cookie migration will happen automatically if the experiment is within those layers.
268
264
 
269
265
  You'll need to provide the `request` as an option to the experiment if it's outside of the controller and views.
270
266
 
271
267
  ```ruby
272
- experiment(:my_experiment, user_id: user&.id, request: request)
268
+ experiment(:my_experiment, actor: user, request: request)
273
269
  ```
274
270
 
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.
271
+ The cookie isn't set if the `actor` key isn't present at all in the context. Meaning that when no `actor` key is provided, the cookie will not be set.
272
+
273
+ ```ruby
274
+ # actor is not present, so no cookie is set
275
+ experiment(:my_experiment, project: project)
276
276
 
277
- 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.
277
+ # actor is present and is nil, so the cookie is set and used
278
+ experiment(:my_experiment, actor: nil, project: project)
279
+
280
+ # actor is present and set to a value, so no cookie is set
281
+ experiment(:my_experiment, actor: user, project: project)
282
+ ```
283
+
284
+ For edge cases, you can pass the cookie through by assigning it yourself -- e.g. `actor: request.cookie_jar.signed['my_experiment_actor']`. The cookie name is the full experiment name (including any configured prefix) with `_actor` appended -- e.g. `gitlab_notification_toggle_actor` for the `:notification_toggle` experiment key with a configured prefix of `gitlab`.
278
285
 
279
286
  ## Configuration
280
287
 
281
288
  This gem needs to be configured before being used in a meaningful way.
282
289
 
283
- The default configuration will always render the control. So it's important to configure your own logic for resolving variants.
290
+ The default configuration will always render the control, so it's important to configure your own logic for resolving variants.
284
291
 
285
- Yes, the most important aspect of the gem, 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.
292
+ Yes, the most important aspect of the gem -- that of determining which variant to render and when -- is up to you. Consider using [Unleash](https://github.com/Unleash/unleash-client-ruby) or [Flipper](https://github.com/jnunemaker/flipper) for this.
286
293
 
287
294
  ```ruby
288
295
  Gitlab::Experiment.configure do |config|
296
+ # The block here is evaluated within the scope of the experiment instance,
297
+ # which is why we are able to access things like name and context.
289
298
  config.variant_resolver = lambda do |requested_variant|
290
299
  # Return the requested variant if a specific one has been provided in code.
291
300
  return requested_variant unless requested_variant.nil?
@@ -298,20 +307,20 @@ Gitlab::Experiment.configure do |config|
298
307
  end
299
308
  ```
300
309
 
301
- More examples for configuration are available in the provided [rails initializer](lib/generators/gitlab_experiment/install/templates/initializer.rb).
310
+ More examples for configuration are available in the provided [rails initializer](lib/generators/gitlab/experiment/install/templates/initializer.rb).
302
311
 
303
312
  ### Client layer / JavaScript
304
313
 
305
314
  This library doesn't attempt to provide any logic for the client layer.
306
315
 
307
- Instead it allows you to do this yourself in configuration. Using Gon to publish your experiment information to the client layer is pretty simple.
316
+ Instead it allows you to do this yourself in configuration. Using [Gon](https://github.com/gazay/gon) to publish your experiment information to the client layer is pretty simple.
308
317
 
309
318
  ```ruby
310
319
  Gitlab::Experiment.configure do |config|
311
320
  config.publishing_behavior = lambda do |_result|
312
321
  # Push the experiment knowledge into the front end. The signature contains
313
322
  # the context key, and the variant that has been determined.
314
- Gon.push(experiment: { name => signature })
323
+ Gon.push({ experiment: { name => signature } }, true)
315
324
  end
316
325
  end
317
326
  ```
@@ -322,6 +331,8 @@ In the client you can now access `window.gon.experiment.notificationToggle`.
322
331
 
323
332
  Caching can be enabled in configuration, and is implemented towards the `Rails.cache` / `ActiveSupport::Cache::Store` interface. When you enable caching, any variant resolution will be cached. Migrating the cache through context migrations is handled automatically, and this helps ensure an experiment experience remains consistent.
324
333
 
334
+ It's important to understand that using caching can drastically change or override your rollout strategy logic.
335
+
325
336
  ```ruby
326
337
  Gitlab::Experiment.configure do |config|
327
338
  config.cache = Rails.cache
@@ -330,11 +341,11 @@ end
330
341
 
331
342
  ## Tracking, anonymity and GDPR
332
343
 
333
- We generally try not to track things like user identifying values in our experimentation. What we can and do track is the "experiment experience" (a.k.a the context key).
344
+ We generally try not to track things like user identifying values in our experimentation. What we can and do track is the "experiment experience" (a.k.a. the context key).
334
345
 
335
346
  We generate this key from the context passed to the experiment. This allows creating funnels without exposing any user information.
336
347
 
337
- This library attempts to be non user centric, in that a context can contain things like a user, or a project.
348
+ This library attempts to be non-user-centric, in that a context can contain things like a user or a project.
338
349
 
339
350
  If you only include a user, that user would get the same experience across every project they view. If you only include the project, every user who views that project would get the same experience.
340
351
 
@@ -0,0 +1,17 @@
1
+ Description:
2
+ Stubs out a new experiment and its variants. Pass the experiment name,
3
+ either CamelCased or under_scored, and a list of variants as arguments.
4
+
5
+ To create an experiment within a module, specify the experiment name as a
6
+ path like 'parent_module/experiment_name'.
7
+
8
+ This generates an experiment class in app/experiments and invokes feature
9
+ flag, and test framework generators.
10
+
11
+ Example:
12
+ `rails generate gitlab:experiment NullHypothesis control candidate alt_variant`
13
+
14
+ NullHypothesis experiment with default variants.
15
+ Experiment: app/experiments/null_hypothesis_experiment.rb
16
+ Feature Flag: config/feature_flags/experiment/null_hypothesis.yaml
17
+ Test: test/experiments/null_hypothesis_experiment_test.rb
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+
5
+ module Gitlab
6
+ module Generators
7
+ class ExperimentGenerator < Rails::Generators::NamedBase
8
+ source_root File.expand_path('templates/', __dir__)
9
+ check_class_collision suffix: 'Experiment'
10
+
11
+ argument :variants,
12
+ type: :array,
13
+ default: %w[control candidate],
14
+ banner: 'variant variant'
15
+
16
+ def create_experiment
17
+ template 'experiment.rb', File.join('app/experiments', class_path, "#{file_name}_experiment.rb")
18
+ end
19
+
20
+ hook_for :test_framework
21
+
22
+ private
23
+
24
+ def file_name
25
+ @_file_name ||= remove_possible_suffix(super)
26
+ end
27
+
28
+ def remove_possible_suffix(name)
29
+ name.sub(/_?exp[ei]riment$/i, "") # be somewhat forgiving with spelling
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+
5
+ module Gitlab
6
+ module Generators
7
+ module Experiment
8
+ class InstallGenerator < Rails::Generators::Base
9
+ source_root File.expand_path('templates', __dir__)
10
+
11
+ desc 'Installs the Gitlab::Experiment initializer and optional ApplicationExperiment into your application.'
12
+
13
+ class_option :skip_initializer,
14
+ type: :boolean,
15
+ default: false,
16
+ desc: 'Skip the initializer with default configuration'
17
+
18
+ class_option :skip_baseclass,
19
+ type: :boolean,
20
+ default: false,
21
+ desc: 'Skip the ApplicationExperiment base class'
22
+
23
+ def create_initializer
24
+ return if options[:skip_initializer]
25
+
26
+ template 'initializer.rb', 'config/initializers/gitlab_experiment.rb'
27
+ end
28
+
29
+ def create_baseclass
30
+ return if options[:skip_baseclass]
31
+
32
+ template 'application_experiment.rb', 'app/experiments/application_experiment.rb'
33
+ end
34
+
35
+ def display_post_install
36
+ readme 'POST_INSTALL' if behavior == :invoke
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,2 @@
1
+ Gitlab::Experiment has been installed. You may want to adjust the configuration
2
+ that's been provided in the Rails initializer.
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ApplicationExperiment < Gitlab::Experiment
4
+ end
@@ -5,7 +5,10 @@ Gitlab::Experiment.configure do |config|
5
5
  config.name_prefix = nil
6
6
 
7
7
  # The logger is used to log various details of the experiments.
8
- config.logger = Logger.new(STDOUT)
8
+ config.logger = Logger.new($stdout)
9
+
10
+ # The base class that should be instantiated for basic experiments.
11
+ config.base_class = 'ApplicationExperiment'
9
12
 
10
13
  # The caching layer is expected to respond to fetch, like Rails.cache.
11
14
  config.cache = nil
@@ -26,7 +29,7 @@ Gitlab::Experiment.configure do |config|
26
29
  #
27
30
  # requested_variant || variant_names.first || 'control'
28
31
 
29
- # Using unleash to determine the variant:
32
+ # Using Unleash to determine the variant:
30
33
  #
31
34
  # fallback = Unleash::Variant.new(name: requested_variant || 'control', enabled: true)
32
35
  # Unleash.get_variant(name, context.value, fallback)
@@ -40,19 +43,17 @@ Gitlab::Experiment.configure do |config|
40
43
  # Tracking behavior can be implemented to link an event to an experiment.
41
44
  #
42
45
  # Similar to the variant_resolver, this is called within the scope of the
43
- # experiment instance and so can access any methods on the experiment.
44
- #
45
- #
46
+ # experiment instance and so can access any methods on the experiment,
47
+ # such as name and signature.
46
48
  config.tracking_behavior = lambda do |event, args|
47
49
  # An example of using a generic logger to track events:
48
50
  config.logger.info "Gitlab::Experiment[#{name}] #{event}: #{args.merge(signature: signature)}"
49
51
 
50
- # Using something like snowplow to track events:
52
+ # Using something like snowplow to track events (in gitlab):
51
53
  #
52
54
  # Gitlab::Tracking.event(name, event, **args.merge(
53
55
  # context: (args[:context] || []) << SnowplowTracker::SelfDescribingJson.new(
54
- # 'iglu:com.gitlab/gitlab_experiment/jsonschema/0-2-0',
55
- # signature: signature
56
+ # 'iglu:com.gitlab/gitlab_experiment/jsonschema/0-2-0', signature
56
57
  # )
57
58
  # ))
58
59
  end
@@ -69,7 +70,7 @@ Gitlab::Experiment.configure do |config|
69
70
  # Push the experiment knowledge into the front end. The signature contains
70
71
  # the context key, and the variant that has been determined.
71
72
  #
72
- # Gon.push(experiment: { name => signature })
73
+ # Gon.push({ experiment: { name => signature } }, true)
73
74
 
74
75
  # Log using our logging system, so the result (which can be large) can be
75
76
  # reviewed later if we want to.
@@ -86,4 +87,10 @@ Gitlab::Experiment.configure do |config|
86
87
  values = context.values.map { |v| (v.respond_to?(:to_global_id) ? v.to_global_id : v).to_s }
87
88
  Digest::MD5.hexdigest((context.keys + values).join('|'))
88
89
  end
90
+
91
+ # The domain for which this cookie applies so you can restrict to the domain level.
92
+ #
93
+ # When not set, it uses the current host. If you want to provide specific hosts, you can
94
+ # provide them either via an array like `['www.gitlab.com', .gitlab.com']`, or set it to `:all`.
95
+ config.cookie_domain = :all
89
96
  end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ <% if namespaced? -%>
4
+ require_dependency "<%= namespaced_path %>/application_experiment"
5
+
6
+ <% end -%>
7
+ <% module_namespacing do -%>
8
+ class <%= class_name %>Experiment < ApplicationExperiment
9
+ <% variants.each do |variant| -%>
10
+ def <%= variant %>_behavior
11
+ end
12
+ <%= "\n" unless variant == variants.last -%>
13
+ <% end -%>
14
+ end
15
+ <% end -%>
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'generators/rspec'
4
+
5
+ module Rspec
6
+ module Generators
7
+ class ExperimentGenerator < Rspec::Generators::Base
8
+ source_root File.expand_path('templates/', __dir__)
9
+
10
+ def create_experiment_spec
11
+ template 'experiment_spec.rb', File.join('spec/experiments', class_path, "#{file_name}_experiment_spec.rb")
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails_helper'
4
+
5
+ <% module_namespacing do -%>
6
+ RSpec.describe <%= class_name %>Experiment do
7
+ pending "add some examples to (or delete) #{__FILE__}"
8
+ end
9
+ <% end -%>
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators/test_unit'
4
+
5
+ module TestUnit # :nodoc:
6
+ module Generators # :nodoc:
7
+ class ExperimentGenerator < TestUnit::Generators::Base # :nodoc:
8
+ source_root File.expand_path('templates/', __dir__)
9
+
10
+ check_class_collision suffix: 'Test'
11
+
12
+ def create_test_file
13
+ template 'experiment_test.rb', File.join('test/experiments', class_path, "#{file_name}_experiment_test.rb")
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_helper'
4
+
5
+ <% module_namespacing do -%>
6
+ class <%= class_name %>ExperimentTest < ActiveSupport::TestCase
7
+ # test "the truth" do
8
+ # assert true
9
+ # end
10
+ end
11
+ <% end -%>
@@ -1,9 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'scientist'
4
+ require 'active_support/callbacks'
5
+ require 'active_support/core_ext/object/blank'
6
+ require 'active_support/core_ext/string/inflections'
4
7
 
5
8
  require 'gitlab/experiment/caching'
9
+ require 'gitlab/experiment/callbacks'
6
10
  require 'gitlab/experiment/configuration'
11
+ require 'gitlab/experiment/cookies'
7
12
  require 'gitlab/experiment/context'
8
13
  require 'gitlab/experiment/dsl'
9
14
  require 'gitlab/experiment/variant'
@@ -14,37 +19,57 @@ module Gitlab
14
19
  class Experiment
15
20
  include Scientist::Experiment
16
21
  include Caching
22
+ include Callbacks
17
23
 
18
24
  class << self
19
25
  def configure
20
26
  yield Configuration
21
27
  end
22
28
 
23
- def run(name, variant_name = nil, **context, &block)
24
- instance = new(name, variant_name, **context, &block)
29
+ def run(name = nil, variant_name = nil, **context, &block)
30
+ raise ArgumentError, 'name is required' if name.nil? && base?
31
+
32
+ instance = constantize(name).new(name, variant_name, **context, &block)
25
33
  return instance unless block_given?
26
34
 
27
35
  instance.context.frozen? ? instance.run : instance.tap(&:run)
28
36
  end
37
+
38
+ def experiment_name(name = nil, suffix: true, suffix_word: 'experiment')
39
+ name = (name.presence || self.name).to_s.underscore.sub(%r{(?<char>[_/]|)#{suffix_word}$}, '')
40
+ name = "#{name}#{Regexp.last_match(:char) || '_'}#{suffix_word}"
41
+ suffix ? name : name.sub(/_#{suffix_word}$/, '')
42
+ end
43
+
44
+ def base?
45
+ self == Gitlab::Experiment || name == Configuration.base_class
46
+ end
47
+
48
+ private
49
+
50
+ def constantize(name = nil)
51
+ return self if name.nil?
52
+
53
+ experiment_name(name).classify.safe_constantize || Configuration.base_class.constantize
54
+ end
29
55
  end
30
56
 
31
- delegate :signature, :cache_strategy, to: :context
57
+ def initialize(name = nil, variant_name = nil, **context)
58
+ raise ArgumentError, 'name is required' if name.blank? && self.class.base?
32
59
 
33
- def initialize(name, variant_name = nil, **context)
34
- @name = name
60
+ @name = self.class.experiment_name(name, suffix: false)
35
61
  @variant_name = variant_name
36
- @context = Context.new(self)
37
-
38
- context(context)
62
+ @excluded = []
63
+ @context = Context.new(self, context)
39
64
 
40
- ignore { true }
65
+ exclude { !@context.trackable? }
41
66
  compare { false }
42
67
 
43
68
  yield self if block_given?
44
69
  end
45
70
 
46
71
  def context(value = nil)
47
- return @context if value.nil?
72
+ return @context if value.blank?
48
73
 
49
74
  @context.value(value)
50
75
  @context
@@ -54,11 +79,30 @@ module Gitlab
54
79
  return @variant_name = value unless value.nil?
55
80
 
56
81
  result = instance_exec(@variant_name, &Configuration.variant_resolver)
57
- result.respond_to?(:name) ? result : Variant.new(name: result.to_s)
82
+ result.respond_to?(:name) ? result : Variant.new(name: (result.presence || :control).to_s)
58
83
  end
59
84
 
60
- def run
61
- @result ||= super(cache { variant.name })
85
+ def exclude(&block)
86
+ @excluded << block
87
+ end
88
+
89
+ def run(variant_name = nil)
90
+ @result ||= begin
91
+ @variant_name = variant_name unless variant_name.nil?
92
+ @variant_name ||= :control if excluded?
93
+
94
+ chain = variant_assigned? ? :unsegmented_run : :segmented_run
95
+ run_callbacks(chain) do
96
+ variant_name = cache { variant.name }
97
+
98
+ method_name = "#{variant_name}_behavior"
99
+ if respond_to?(method_name)
100
+ behaviors[variant_name] ||= -> { send(method_name) } # rubocop:disable GitlabSecurity/PublicSend
101
+ end
102
+
103
+ super(variant_name)
104
+ end
105
+ end
62
106
  end
63
107
 
64
108
  def publish(result)
@@ -66,6 +110,8 @@ module Gitlab
66
110
  end
67
111
 
68
112
  def track(action, **event_args)
113
+ return if excluded?
114
+
69
115
  instance_exec(action, event_args, &Configuration.tracking_behavior)
70
116
  end
71
117
 
@@ -74,19 +120,36 @@ module Gitlab
74
120
  end
75
121
 
76
122
  def variant_names
77
- @variant_names ||= behaviors.keys.tap { |keys| keys.delete('control') }.map(&:to_sym)
123
+ @variant_names ||= behaviors.keys.map(&:to_sym) - [:control]
124
+ end
125
+
126
+ def signature
127
+ { variant: variant.name, experiment: name }.merge(context.signature)
78
128
  end
79
129
 
80
130
  def enabled?
81
131
  true
82
132
  end
83
133
 
84
- def identifying_key
85
- :user_id
134
+ def excluded?
135
+ @excluded.any? { |exclude| exclude.call(self) }
136
+ end
137
+
138
+ def variant_assigned?
139
+ !@variant_name.nil?
140
+ end
141
+
142
+ def id
143
+ "#{name}:#{signature[:key]}"
144
+ end
145
+ alias_method :session_id, :id
146
+
147
+ def flipper_id
148
+ "Experiment;#{id}"
86
149
  end
87
150
 
88
- def cache_key_for(key, migration: false)
89
- "#{name}:#{key}"
151
+ def key_for(hash)
152
+ instance_exec(hash, &Configuration.context_hash_strategy)
90
153
  end
91
154
 
92
155
  protected
@@ -10,6 +10,15 @@ module Gitlab
10
10
  migrated_cache(cache, migrations || [], key) or cache.fetch(key, &block)
11
11
  end
12
12
 
13
+ private
14
+
15
+ def cache_strategy
16
+ [
17
+ "#{name}:#{signature[:key]}",
18
+ signature[:migration_keys]&.map { |key| "#{name}:#{key}" }
19
+ ]
20
+ end
21
+
13
22
  def migrated_cache(cache, migrations, new_key)
14
23
  migrations.find do |old_key|
15
24
  next unless (value = cache.read(old_key))
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ class Experiment
5
+ module Callbacks
6
+ extend ActiveSupport::Concern
7
+ include ActiveSupport::Callbacks
8
+
9
+ included do
10
+ define_callbacks(
11
+ :unsegmented_run,
12
+ skip_after_callbacks_if_terminated: true
13
+ )
14
+
15
+ define_callbacks(
16
+ :segmented_run,
17
+ skip_after_callbacks_if_terminated: false,
18
+ terminator: lambda do |target, result_lambda|
19
+ result_lambda.call
20
+ target.variant_assigned?
21
+ end
22
+ )
23
+ end
24
+
25
+ class_methods do
26
+ def segment(*filter_list, variant:, **options, &block)
27
+ filters = filter_list.unshift(block).compact.map do |filter|
28
+ result_lambda = ActiveSupport::Callbacks::CallTemplate.build(filter, self).make_lambda
29
+ ->(target) { target.variant(variant) if result_lambda.call(target, nil) }
30
+ end
31
+
32
+ raise ArgumentError, 'no filters provided' if filters.empty?
33
+
34
+ set_callback(:segmented_run, :before, *filters, options, &block)
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -13,7 +13,10 @@ module Gitlab
13
13
  @name_prefix = nil
14
14
 
15
15
  # The logger is used to log various details of the experiments.
16
- @logger = Logger.new(STDOUT)
16
+ @logger = Logger.new($stdout)
17
+
18
+ # The base class that should be instantiated for basic experiments.
19
+ @base_class = 'Gitlab::Experiment'
17
20
 
18
21
  # Cache layer. Expected to respond to fetch, like Rails.cache.
19
22
  @cache = nil
@@ -35,14 +38,28 @@ module Gitlab
35
38
  end
36
39
 
37
40
  # Algorithm that consistently generates a hash key for a given hash map.
38
- @context_hash_strategy = lambda do |context|
39
- values = context.values.map { |v| (v.respond_to?(:to_global_id) ? v.to_global_id : v).to_s }
40
- Digest::MD5.hexdigest((context.keys + values).join('|'))
41
+ @context_hash_strategy = lambda do |hash_map|
42
+ values = hash_map.values.map { |v| (v.respond_to?(:to_global_id) ? v.to_global_id : v).to_s }
43
+ Digest::MD5.hexdigest(([name] + hash_map.keys + values).join('|'))
41
44
  end
42
45
 
46
+ # The domain for which this cookie applies so you can restrict to the domain level.
47
+ # When not set, it uses the current host. If you want to provide specific hosts, you can
48
+ # provide them either via an array like `['www.gitlab.com', .gitlab.com']`, or set it to `:all`.
49
+ @cookie_domain = :all
50
+
43
51
  class << self
44
- attr_accessor :name_prefix, :logger, :cache
45
- attr_accessor :variant_resolver, :tracking_behavior, :publishing_behavior, :context_hash_strategy
52
+ attr_accessor(
53
+ :name_prefix,
54
+ :logger,
55
+ :base_class,
56
+ :cache,
57
+ :variant_resolver,
58
+ :tracking_behavior,
59
+ :publishing_behavior,
60
+ :context_hash_strategy,
61
+ :cookie_domain
62
+ )
46
63
  end
47
64
  end
48
65
  end
@@ -3,95 +3,65 @@
3
3
  module Gitlab
4
4
  class Experiment
5
5
  class Context
6
+ include Cookies
7
+
6
8
  DNT_REGEXP = /^(true|t|yes|y|1|on)$/i.freeze
7
9
 
8
- def initialize(experiment)
10
+ def initialize(experiment, **initial_value)
9
11
  @experiment = experiment
10
12
  @value = {}
11
- @migrations_from = []
12
- @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)
13
21
  end
14
22
 
15
23
  def value(value = nil)
16
24
  return @value if value.nil?
17
25
 
18
26
  value = value.dup # dup so we don't mutate
19
- @signature = @cache_strategy = nil # clear memoization
27
+ reinitialize(value.delete(:request))
20
28
 
21
- @migrations_from << value.delete(:migrated_from) if value[:migrated_from]
22
- @migrations_with << value.delete(:migrated_with) if value[:migrated_with]
23
- @value.merge!(auto_migrate_cookie(value, value.delete(:request)))
29
+ @value.merge!(process_migrations(value))
24
30
  end
25
31
 
26
- def freeze
27
- cache_strategy # ensure we memoize before freezing
28
- super
32
+ def trackable?
33
+ !(@request && @request.headers['DNT'].to_s.match?(DNT_REGEXP))
29
34
  end
30
35
 
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
- ]
36
+ def freeze
37
+ signature # finalize before freezing
38
+ super
38
39
  end
39
40
 
40
41
  def signature
41
- @signature ||= {
42
- key: key_for(@value),
43
- migration_keys: migration_keys,
44
- variant: @experiment.variant.name
45
- }.compact
42
+ @signature ||= { key: @experiment.key_for(@value), migration_keys: migration_keys }.compact
46
43
  end
47
44
 
48
45
  private
49
46
 
50
- def auto_migrate_cookie(hash, request)
51
- return hash unless request&.respond_to?(:headers) && request&.respond_to?(:cookie_jar)
52
- return hash if request.headers['DNT'].to_s.match?(DNT_REGEXP)
47
+ def process_migrations(value)
48
+ add_migration(value.delete(:migrated_from))
49
+ add_migration(value.delete(:migrated_with), merge: true)
53
50
 
54
- resolver = cookie_resolver(request.cookie_jar, hash)
55
- resolve_cookie(*resolver) or generate_cookie(*resolver)
51
+ migrate_cookie(value, "#{@experiment.name}_id")
56
52
  end
57
53
 
58
- def cookie_resolver(jar, hash)
59
- [jar, hash, @experiment.identifying_key, jar.signed[cookie_name]].compact
60
- end
54
+ def add_migration(value, merge: false)
55
+ return unless value.is_a?(Hash)
61
56
 
62
- def cookie_name
63
- @cookie_name ||= [@experiment.name, 'id'].join('_')
64
- end
65
-
66
- def resolve_cookie(jar, hash, key, cookie = nil)
67
- return if cookie.blank? && hash[key].blank?
68
- return hash.merge(key => cookie) if hash[key].blank?
69
-
70
- @migrations_with << { key => cookie }
71
- jar.delete(cookie_name, domain: :all)
72
-
73
- hash
74
- end
75
-
76
- def generate_cookie(jar, hash, key, cookie = SecureRandom.uuid)
77
- return hash unless hash.key?(key)
78
-
79
- jar.permanent.signed[cookie_name] = {
80
- value: cookie, secure: true, domain: :all, httponly: true
81
- }
82
-
83
- hash.merge(key => cookie)
57
+ @migrations[merge ? :merged : :unmerged] << value
84
58
  end
85
59
 
86
60
  def migration_keys
87
- return nil if @migrations_from.empty? && @migrations_with.empty?
88
-
89
- @migrations_from.map { |m| key_for(m) } +
90
- @migrations_with.map { |m| key_for(@value.merge(m)) }
91
- end
61
+ return nil if @migrations[:unmerged].empty? && @migrations[:merged].empty?
92
62
 
93
- def key_for(context)
94
- Configuration.context_hash_strategy.call(context)
63
+ @migrations[:unmerged].map { |m| @experiment.key_for(m) } +
64
+ @migrations[:merged].map { |m| @experiment.key_for(@value.merge(m)) }
95
65
  end
96
66
  end
97
67
  end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Gitlab
6
+ class Experiment
7
+ module Cookies
8
+ private
9
+
10
+ def migrate_cookie(hash, cookie_name)
11
+ return hash if cookie_jar.nil?
12
+
13
+ resolver = [hash, :actor, cookie_name, cookie_jar.signed[cookie_name]]
14
+ resolve_cookie(*resolver) or generate_cookie(*resolver)
15
+ end
16
+
17
+ def cookie_jar
18
+ @request&.cookie_jar
19
+ end
20
+
21
+ def resolve_cookie(hash, key, cookie_name, cookie)
22
+ return if cookie.to_s.empty? && hash[key].nil?
23
+ return hash if cookie.to_s.empty?
24
+ return hash.merge(key => cookie) if hash[key].nil?
25
+
26
+ add_migration(key => cookie)
27
+ cookie_jar.delete(cookie_name, domain: domain)
28
+
29
+ hash
30
+ end
31
+
32
+ def generate_cookie(hash, key, cookie_name, cookie)
33
+ return hash unless hash.key?(key)
34
+
35
+ cookie ||= SecureRandom.uuid
36
+ cookie_jar.permanent.signed[cookie_name] = {
37
+ value: cookie, secure: true, domain: domain, httponly: true
38
+ }
39
+
40
+ hash.merge(key => cookie)
41
+ end
42
+
43
+ def domain
44
+ Configuration.cookie_domain
45
+ end
46
+ end
47
+ end
48
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Gitlab
4
4
  class Experiment
5
- VERSION = '0.2.3'
5
+ VERSION = '0.4.1'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,35 +1,49 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gitlab-experiment
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.3
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - GitLab
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-09-27 00:00:00.000000000 Z
11
+ date: 2020-11-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
- name: scientist
14
+ name: activesupport
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
17
  - - ">="
18
18
  - !ruby/object:Gem::Version
19
- version: 1.5.0
20
- - - "~>"
21
- - !ruby/object:Gem::Version
22
- version: '1.5'
19
+ version: '3.0'
23
20
  type: :runtime
24
21
  prerelease: false
25
22
  version_requirements: !ruby/object:Gem::Requirement
26
23
  requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '3.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: scientist
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.5'
27
34
  - - ">="
28
35
  - !ruby/object:Gem::Version
29
36
  version: 1.5.0
37
+ type: :runtime
38
+ prerelease: false
39
+ version_requirements: !ruby/object:Gem::Requirement
40
+ requirements:
30
41
  - - "~>"
31
42
  - !ruby/object:Gem::Version
32
43
  version: '1.5'
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: 1.5.0
33
47
  description:
34
48
  email:
35
49
  - gitlab_rubygems@gitlab.com
@@ -39,13 +53,23 @@ extra_rdoc_files: []
39
53
  files:
40
54
  - LICENSE.txt
41
55
  - README.md
42
- - lib/generators/gitlab_experiment/install/POST_INSTALL
43
- - lib/generators/gitlab_experiment/install/install_generator.rb
44
- - lib/generators/gitlab_experiment/install/templates/initializer.rb
56
+ - lib/generators/gitlab/experiment/USAGE
57
+ - lib/generators/gitlab/experiment/experiment_generator.rb
58
+ - lib/generators/gitlab/experiment/install/install_generator.rb
59
+ - lib/generators/gitlab/experiment/install/templates/POST_INSTALL
60
+ - lib/generators/gitlab/experiment/install/templates/application_experiment.rb.tt
61
+ - lib/generators/gitlab/experiment/install/templates/initializer.rb.tt
62
+ - lib/generators/gitlab/experiment/templates/experiment.rb.tt
63
+ - lib/generators/rspec/experiment/experiment_generator.rb
64
+ - lib/generators/rspec/experiment/templates/experiment_spec.rb.tt
65
+ - lib/generators/test_unit/experiment/experiment_generator.rb
66
+ - lib/generators/test_unit/experiment/templates/experiment_test.rb.tt
45
67
  - lib/gitlab/experiment.rb
46
68
  - lib/gitlab/experiment/caching.rb
69
+ - lib/gitlab/experiment/callbacks.rb
47
70
  - lib/gitlab/experiment/configuration.rb
48
71
  - lib/gitlab/experiment/context.rb
72
+ - lib/gitlab/experiment/cookies.rb
49
73
  - lib/gitlab/experiment/dsl.rb
50
74
  - lib/gitlab/experiment/engine.rb
51
75
  - lib/gitlab/experiment/variant.rb
@@ -69,7 +93,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
69
93
  - !ruby/object:Gem::Version
70
94
  version: '0'
71
95
  requirements: []
72
- rubygems_version: 3.0.3
96
+ rubygems_version: 3.1.4
73
97
  signing_key:
74
98
  specification_version: 4
75
99
  summary: GitLab experiment library built on top of scientist.
@@ -1,21 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'rails/generators'
4
-
5
- module GitlabExperiment
6
- module Generators
7
- class InstallGenerator < Rails::Generators::Base
8
- source_root File.expand_path(__dir__)
9
-
10
- desc 'Installs the Gitlab Experiment initializer into your application.'
11
-
12
- def copy_initializers
13
- copy_file 'templates/initializer.rb', 'config/initializers/gitlab_experiment.rb'
14
- end
15
-
16
- def display_post_install
17
- readme 'POST_INSTALL' if behavior == :invoke
18
- end
19
- end
20
- end
21
- end