gitlab-experiment 0.2.4 → 0.4.2

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: a6284349854da93da979695a6493df95c0247616e10d5adc047d56d5dbdbf324
4
- data.tar.gz: 1e4456945313fce9fbf03250299e3a0eb1b406035e4cce0d0559eee1fb3cfe53
3
+ metadata.gz: 865d51c081670824fc7ffb48d4bc764fd5370606d3aa63b2196421b5570506e7
4
+ data.tar.gz: a007f872a3d56ee81c9925117f603e4930d1b7d689078edb074a2ba5567833f2
5
5
  SHA512:
6
- metadata.gz: '08d5c04e817c2679d115a845df588a4b81358e1b7ac7ddc8282a4cdaa2a878f6a4111b318fa3155671fdd05c0cb950ab076210518142e5f8a04db89e7b164104'
7
- data.tar.gz: 118de1ae6b9995edee4058a1b756030ee6659199bf19a29cf2c1446ddb87a1a8ff29f479cf9ff20f7af47ff7706bbc97256929ed800f803fc381450bcedbf82c
6
+ metadata.gz: 03d401a71b952b74519a21fa851a87ee9e104ccd19011af37ac41c36bec5fd63db3e68c62c05beb34ab296d354e00e15f013dd34a547c3b4c5c94474b89096cc
7
+ data.tar.gz: 2bb0260e40d4689446c1317273d85140fae9f56394821fda96c7bbc5d1acbe441f3e4024bf00ec05812f64f5588897137fc8061e790d871c5719742f6cedf516
data/README.md CHANGED
@@ -1,10 +1,11 @@
1
- # GitLab Experiment
1
+ GitLab Experiment
2
+ =================
2
3
 
3
4
  <img alt="experiment" src="/uploads/60990b2dbf4c0406bbf8b7f998de2dea/experiment.png" align="right" width="40%">
4
5
 
5
6
  Here at GitLab, we run experiments as A/B/n tests and review the data the experiment generates. From that data, we determine the best performing variant and promote it as the new default code path. Or revert back to the control if no variant outperformed it.
6
7
 
7
- This library provides a clean and elegant DSL to define, run, and track your GitLab experiment.
8
+ This library provides a clean and elegant DSL (domain specific language) to define, run, and track your GitLab experiment.
8
9
 
9
10
  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
11
 
@@ -16,6 +17,8 @@ When we discuss the behavior of this gem, we'll use terms like experiment, conte
16
17
 
17
18
  Candidate and variant are the same concept, but simplify how we speak about experimental paths.<br clear="all">
18
19
 
20
+ [[_TOC_]]
21
+
19
22
  ## Installation
20
23
 
21
24
  Add the gem to your Gemfile and then `bundle install`.
@@ -24,10 +27,10 @@ Add the gem to your Gemfile and then `bundle install`.
24
27
  gem 'gitlab-experiment'
25
28
  ```
26
29
 
27
- If you're using Rails, you can install the initializer. It provides basic configuration and documentation.
30
+ If you're using Rails, you can install the initializer which provides basic configuration, documentation, and the base experiment class that all your experiments can inherit from.
28
31
 
29
32
  ```shell
30
- $ rails generate gitlab-experiment:install
33
+ $ rails generate gitlab:experiment:install
31
34
  ```
32
35
 
33
36
  ## Implementing an experiment
@@ -38,7 +41,7 @@ In our control (current world) we show a simple toggle interface labeled, "Notif
38
41
 
39
42
  The behavior will be the same, but the interface will be different and may involve more or fewer steps.
40
43
 
41
- Our hypothesis is that this will make the action more clear and will help the user in making a choice about if that's what they really want to do.
44
+ Our hypothesis is that this will make the action more clear and will help in making a choice about if that's what the user really wants to do.
42
45
 
43
46
  We'll name our experiment `notification_toggle`. This name is prefixed based on configuration. If you've set `config.name_prefix = 'gitlab'`, the experiment name would be `gitlab_notification_toggle` elsewhere.
44
47
 
@@ -51,7 +54,7 @@ Now in our experiment we're going to render one of two views: the control will b
51
54
  ```ruby
52
55
  class SubscriptionsController < ApplicationController
53
56
  def show
54
- experiment(:notification_toggle, user_id: user.id) do |e|
57
+ experiment(:notification_toggle, actor: user) do |e|
55
58
  e.use { render_toggle } # control
56
59
  e.try { render_button } # candidate
57
60
  end
@@ -64,7 +67,7 @@ You can define the experiment using simple control/candidate paths, or provide n
64
67
  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.
65
68
 
66
69
  ```ruby
67
- experiment(:notification_toggle, user_id: user.id) do |e|
70
+ experiment(:notification_toggle, actor: user) do |e|
68
71
  e.use { render_toggle } # control
69
72
  e.try(:variant_one) { render_button(confirmation: true) }
70
73
  e.try(:variant_two) { render_button(confirmation: false) }
@@ -76,75 +79,87 @@ Understanding how an experiment can change behavior is important in evaluating i
76
79
  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.
77
80
 
78
81
  ```ruby
79
- experiment(:notification_toggle, user_id: user.id).track(:clicked_button)
82
+ experiment(:notification_toggle, actor: user).track(:clicked_button)
80
83
  ```
81
84
 
82
- <details>
83
- <summary>You can also use the more low level class or instance interfaces...</summary>
85
+ ### Custom experiments
84
86
 
85
- ### Class level interface using `.run`
87
+ You can craft more advanced behaviors by defining custom experiments at a higher level. To do this you can define a class that inherits from `ApplicationExperiment` (or `Gitlab::Experiment`).
86
88
 
87
- ```ruby
88
- exp = Gitlab::Experiment.run(:notification_toggle, user_id: user.id) do |e|
89
- # Context may be passed in the block, but must be finalized before calling
90
- # run or track.
91
- e.context(project_id: project.id) # add the project id to the context
89
+ Let's say you want to do more advanced segmentation, or provide default behavior for the variants on the experiment we've already outlined above -- that way if the variants aren't defined in the block at the time the experiment is run, these methods will be used.
92
90
 
93
- # Define the control and candidate variant.
94
- e.use { render_toggle } # control
95
- e.try { render_button } # candidate
96
- end
91
+ You can generate a custom experiment by running:
97
92
 
98
- # Track an event on the experiment we've defined.
99
- exp.track(:clicked_button)
100
- ```
93
+ ```shell
94
+ $ rails generate gitlab:experiment NotificationToggle control candidate
95
+ ```
96
+
97
+ This will generate a file in `app/experiments/notification_toggle_experiment.rb`, as well as a test file for you to further expand on.
101
98
 
102
- ### Instance level interface
99
+ Here are some examples of what you can introduce once you have a custom experiment defined.
103
100
 
104
101
  ```ruby
105
- exp = Gitlab::Experiment.new(:notification_toggle, user_id: user.id)
106
- # Additional context may be provided to the instance (exp) but must be
107
- # finalized before calling run or track.
108
- exp.context(project_id: project.id) # add the project id to the context
102
+ class NotificationToggleExperiment < ApplicationExperiment
103
+ # Segment any account less than 2 weeks old into the candidate, without
104
+ # asking the variant resolver to decide which variant to provide.
105
+ segment :account_age, variant: :candidate
106
+
107
+ # Define the default control behavior, which can be overridden at
108
+ # experiment time.
109
+ def control_behavior
110
+ render_toggle
111
+ end
109
112
 
110
- # Define the control and candidate variant.
111
- exp.use { render_toggle } # control
112
- exp.try { render_button } # candidate
113
+ # Define the default candidate behavior, which can be overridden
114
+ # at experiment time.
115
+ def candidate_behavior
116
+ render_button
117
+ end
118
+
119
+ private
120
+
121
+ def account_age
122
+ context.actor && context.actor.created_at < 2.weeks.ago
123
+ end
124
+ end
125
+
126
+ # The class will be looked up based on the experiment name provided.
127
+ exp = experiment(:notification_toggle, actor: user)
128
+ exp # => instance of NotificationToggleExperiment
113
129
 
114
- # Run the experiment, returning the result.
130
+ # Run the experiment -- returning the result.
115
131
  exp.run
116
132
 
117
133
  # Track an event on the experiment we've defined.
118
134
  exp.track(:clicked_button)
119
135
  ```
120
136
 
121
- </details>
137
+ You can now also do things very similar to the simple examples and override the default variant behaviors defined in the custom experiment -- keeping in mind that this should be carefully considered within the scope of your experiment.
138
+
139
+ ```ruby
140
+ experiment(:notification_toggle, actor: user) do |e|
141
+ e.use { render_special_toggle } # override default control behavior
142
+ end
143
+ ```
122
144
 
123
145
  <details>
124
- <summary>You can define and use custom classes...</summary>
146
+ <summary>You can also use the lower level class interface...</summary>
125
147
 
126
- ### Custom class
148
+ ### Using the `.run` approach
127
149
 
128
- ```ruby
129
- class NotificationExperiment < Gitlab::Experiment
130
- def initialize(variant_name = nil, **context, &block)
131
- super(:notification_toggle, variant_name, **context, &block)
150
+ This is useful if you haven't included the DSL and so don't have access to the `experiment` method, but still want to execute an experiment. This is ultimately what the `experiment` method calls through to, and the method signatures are the same.
132
151
 
133
- # Define the control and candidate variant.
134
- use { render_toggle } # control
135
- try { render_button } # candidate
136
- end
137
- end
152
+ ```ruby
153
+ exp = Gitlab::Experiment.run(:notification_toggle, actor: user) do |e|
154
+ # Context may be passed in the block, but must be finalized before calling
155
+ # run or track.
156
+ e.context(project: project) # add the project to the context
138
157
 
139
- exp = NotificationExperiment.new(user_id: user.id) 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_id: project.id) # add the project id to the context
158
+ # Define the control and candidate variant.
159
+ e.use { render_toggle } # control
160
+ e.try { render_button } # candidate
143
161
  end
144
162
 
145
- # Run the experiment -- returning the result.
146
- exp.run
147
-
148
163
  # Track an event on the experiment we've defined.
149
164
  exp.track(:clicked_button)
150
165
  ```
@@ -152,14 +167,14 @@ exp.track(:clicked_button)
152
167
  </details>
153
168
 
154
169
  <details>
155
- <summary>You can also specify the variant to use...</summary>
170
+ <summary>You can also specify the variant to use for segmentation...</summary>
156
171
 
157
172
  ### Specifying variant
158
173
 
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.
174
+ Generally, defining segmentation rules is a better way to approach routing into specific variants, but it's possible to explicitly specify the variant when running an experiment. It's important to know what this might do to your data during rollout, so use this with careful consideration.
160
175
 
161
176
  ```ruby
162
- experiment(:notification_toggle, :no_interface, user_id: user.id) do |e|
177
+ experiment(:notification_toggle, :no_interface, actor: user) do |e|
163
178
  e.use { render_toggle } # control
164
179
  e.try { render_button } # candidate
165
180
  e.try(:no_interface) { no_interface! } # variant
@@ -169,7 +184,7 @@ end
169
184
  Or you can set the variant within the block. This allows using unique segmentation logic or variant resolution if you need it.
170
185
 
171
186
  ```ruby
172
- experiment(:notification_toggle, user_id: user.id) do |e|
187
+ experiment(:notification_toggle, actor: user) do |e|
173
188
  # Variant selection must be done before calling run or track.
174
189
  e.variant(:no_interface) # set the variant
175
190
  # ...
@@ -179,7 +194,7 @@ end
179
194
  Or it can be specified in the call to run if you call it from within the block.
180
195
 
181
196
  ```ruby
182
- experiment(:notification_toggle, user_id: user.id) do |e|
197
+ experiment(:notification_toggle, actor: user) do |e|
183
198
  # ...
184
199
  # Variant selection can be specified when calling run.
185
200
  e.run(:no_interface)
@@ -188,6 +203,25 @@ end
188
203
 
189
204
  </details>
190
205
 
206
+ ### Segmentation rules
207
+
208
+ This library comes with the capability to segment contexts into a specific variant, before asking the variant resolver which variant to provide.
209
+
210
+ Segmentation can be achieved by using a custom experiment class and specifying the segmentation rules at a class level.
211
+
212
+ ```ruby
213
+ class NotificationToggleExperiment < ApplicationExperiment
214
+ segment(variant: :variant_one) { context.actor.username == 'jejacks0n' }
215
+ segment(variant: :variant_two) { context.actor.created_at < 2.weeks.ago }
216
+ end
217
+ ```
218
+
219
+ In the previous examples, any user with the username `'jejacks0n'` would always receive the experience defined in "variant_one". As well, any account less than 2 weeks old would get the alternate experience defined in "variant_two".
220
+
221
+ When an experiment is run, the segmentation rules are executed in the order they're defined. The first segmentation rule to produce a truthy result is the one which gets used to assign the variant. The remaining segmentation rules are skipped.
222
+
223
+ This means that any user with the name `'jejacks0n'`, regardless of account age, will always be provided the experience as defined in "variant_one".
224
+
191
225
  ### Return value
192
226
 
193
227
  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.
@@ -209,13 +243,13 @@ Some experiments may extend outside of those layers, so you may want to include
209
243
  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
244
 
211
245
  ```ruby
212
- class UserMailer < ApplicationMailer
246
+ class WelcomeMailer < ApplicationMailer
213
247
  include Gitlab::Experiment::Dsl # include the `experiment` method
214
248
 
215
249
  def welcome
216
250
  @user = params[:user]
217
251
 
218
- ex = experiment(:project_suggestions, user_id: @user.id) do |e|
252
+ ex = experiment(:project_suggestions, actor: @user) do |e|
219
253
  e.use { 'welcome' }
220
254
  e.try { 'welcome_with_project_suggestions' }
221
255
  end
@@ -234,8 +268,8 @@ Take for instance, that you might be using `version: 1` in your context currentl
234
268
  In providing the context migration data, we can resolve an experience and its events all the way back. This can also help in keeping our cache relevant.
235
269
 
236
270
  ```ruby
237
- # Migrate just the `:version` portion of the previous context, `{ user_id: 42, version: 1 }`:
238
- experiment(:my_experiment, user_id: 42, version: 2, migrated_with: { version: 1 })
271
+ # Migrate just the `:version` portion of the previous context, `{ actor: project, version: 1 }`:
272
+ experiment(:my_experiment, actor: project, version: 2, migrated_with: { version: 1 })
239
273
  ```
240
274
 
241
275
  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.
@@ -243,8 +277,8 @@ You can add or remove context by providing a `migrated_from` option. This approa
243
277
  If you wanted to introduce a `version` to your context, provide the full previous context.
244
278
 
245
279
  ```ruby
246
- # Migrate the full context from `{ user_id: 42 }` to `{ user_id: 42, version: 1 }`:
247
- experiment(:my_experiment, user_id: 42, version: 1, migrated_from: { user_id: 42 })
280
+ # Migrate the full context from `{ actor: project }` to `{ actor: project, version: 1 }`:
281
+ experiment(:my_experiment, actor: project, version: 1, migrated_from: { actor: project })
248
282
  ```
249
283
 
250
284
  This can impact an experience if you:
@@ -252,36 +286,36 @@ This can impact an experience if you:
252
286
  1. haven't implemented the concept of migrations in your variant resolver
253
287
  1. haven't enabled a reasonable caching mechanism
254
288
 
255
- ### When there isn't a user (cookies)
289
+ ### When there isn't an actor (cookie fallback)
256
290
 
257
- 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.
291
+ When there isn't an identifying key in the context (this is `actor` by default), we fall back to cookies to provide a consistent experience for the client viewing them.
258
292
 
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.
293
+ 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
294
 
261
- 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.
295
+ 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
296
 
263
297
  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
298
 
265
299
  You'll need to provide the `request` as an option to the experiment if it's outside of the controller and views.
266
300
 
267
301
  ```ruby
268
- experiment(:my_experiment, user_id: user&.id, request: request)
302
+ experiment(:my_experiment, actor: user, request: request)
269
303
  ```
270
304
 
271
- The cookie isn't set if the identifying key isn't present at all in the context. Using the default identifying key, when there is no `user_id` key provided, the cookie will not be set.
305
+ 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
306
 
273
307
  ```ruby
274
- # no user_id context key is present, so no cookie is set
275
- experiment(:my_experiment, project_id: @project.id)
308
+ # actor is not present, so no cookie is set
309
+ experiment(:my_experiment, project: project)
276
310
 
277
- # user_id context key is present, but is set to nil, so the cookie is set & used instead
278
- experiment(:my_experiment, user_id: nil, project_id: @project.id)
311
+ # actor is present and is nil, so the cookie is set and used
312
+ experiment(:my_experiment, actor: nil, project: project)
279
313
 
280
- # user_id context key is present and set to a value, so no cookie is set
281
- experiment(:my_experiment, user_id: @user.id, project_id: @project.id)
314
+ # actor is present and set to a value, so no cookie is set
315
+ experiment(:my_experiment, actor: user, project: project)
282
316
  ```
283
317
 
284
- 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 full experiment name (including any configured prefix) with `_id` appended -- e.g. `gitlab_notification_toggle_id` for the `:notification_toggle` experiment key with a configured prefix of `gitlab`.
318
+ 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
319
 
286
320
  ## Configuration
287
321
 
@@ -307,7 +341,7 @@ Gitlab::Experiment.configure do |config|
307
341
  end
308
342
  ```
309
343
 
310
- More examples for configuration are available in the provided [rails initializer](lib/generators/gitlab_experiment/install/templates/initializer.rb).
344
+ More examples for configuration are available in the provided [rails initializer](lib/generators/gitlab/experiment/install/templates/initializer.rb).
311
345
 
312
346
  ### Client layer / JavaScript
313
347
 
@@ -351,4 +385,32 @@ If you only include a user, that user would get the same experience across every
351
385
 
352
386
  Each of these approaches could be desirable given the objectives of your experiment.
353
387
 
354
- ### Make code not war
388
+ ## Development
389
+
390
+ After checking out the repo, run `bundle install` to install dependencies.
391
+ Then, run `bundle exec rspec` to run the tests. You can also run `bundle exec pry` for an
392
+ interactive prompt that will allow you to experiment.
393
+
394
+ ## Contributing
395
+
396
+ Bug reports and merge requests are welcome on GitLab at
397
+ https://gitlab.com/gitlab-org/gitlab-experiment. This project is intended to be a
398
+ safe, welcoming space for collaboration, and contributors are expected to adhere
399
+ to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
400
+
401
+ ## Release Process
402
+
403
+ Please refer to the [Release Process](docs/release_process.md).
404
+
405
+ ## License
406
+
407
+ The gem is available as open source under the terms of the
408
+ [MIT License](http://opensource.org/licenses/MIT).
409
+
410
+ ## Code of Conduct
411
+
412
+ Everyone interacting in the `Gitlab::Experiment` project’s codebases, issue trackers,
413
+ chat rooms and mailing lists is expected to follow the
414
+ [code of conduct](CODE_OF_CONDUCT.md).
415
+
416
+ ***Make code not war***
@@ -0,0 +1,17 @@
1
+ Description:
2
+ Stubs out a new experiment and its variants. Pass the experiment name,
3
+ either CamelCased or under_scored, and a list of variants as arguments.
4
+
5
+ To create an experiment within a module, specify the experiment name as a
6
+ path like 'parent_module/experiment_name'.
7
+
8
+ This generates an experiment class in app/experiments and invokes feature
9
+ flag, and test framework generators.
10
+
11
+ Example:
12
+ `rails generate gitlab:experiment NullHypothesis control candidate alt_variant`
13
+
14
+ NullHypothesis experiment with default variants.
15
+ Experiment: app/experiments/null_hypothesis_experiment.rb
16
+ Feature Flag: config/feature_flags/experiment/null_hypothesis.yaml
17
+ Test: test/experiments/null_hypothesis_experiment_test.rb
@@ -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,19 +5,30 @@ 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
9
 
10
- # The caching layer is expected to respond to fetch, like Rails.cache.
10
+ # The base class that should be instantiated for basic experiments. It should
11
+ # be a string, so we can constantize it later.
12
+ config.base_class = 'ApplicationExperiment'
13
+
14
+ # The caching layer is expected to respond to fetch, like Rails.cache for
15
+ # instance -- or anything that adheres to ActiveSupport::Cache::Store.
11
16
  config.cache = nil
12
17
 
18
+ # The domain to use on cookies.
19
+ #
20
+ # When not set, it uses the current host. If you want to provide specific
21
+ # hosts, you use `:all`, or provide an array like
22
+ # `['www.gitlab.com', '.gitlab.com']`.
23
+ config.cookie_domain = :all
24
+
13
25
  # Logic this project uses to resolve a variant for a given experiment.
14
26
  #
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.
27
+ # Should return a symbol or string that represents the variant that should
28
+ # be assigned.
18
29
  #
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.
30
+ # This block is executed within the scope of the experiment and so can access
31
+ # experiment methods, like `name`, `context`, and `signature`.
21
32
  config.variant_resolver = lambda do |requested_variant|
22
33
  # Run the control, unless a variant was requested in code:
23
34
  requested_variant || 'control'
@@ -25,23 +36,12 @@ Gitlab::Experiment.configure do |config|
25
36
  # Run the candidate, unless a variant was requested, with a fallback:
26
37
  #
27
38
  # 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
39
  end
39
40
 
40
41
  # Tracking behavior can be implemented to link an event to an experiment.
41
42
  #
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.
43
+ # This block is executed within the scope of the experiment and so can access
44
+ # experiment methods, like `name`, `context`, and `signature`.
45
45
  config.tracking_behavior = lambda do |event, args|
46
46
  # An example of using a generic logger to track events:
47
47
  config.logger.info "Gitlab::Experiment[#{name}] #{event}: #{args.merge(signature: signature)}"
@@ -58,8 +58,11 @@ Gitlab::Experiment.configure do |config|
58
58
  # Called at the end of every experiment run, with the result.
59
59
  #
60
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.
61
+ # or push the experiment into the client or publish results elsewhere like
62
+ # into redis.
63
+ #
64
+ # This block is executed within the scope of the experiment and so can access
65
+ # experiment methods, like `name`, `context`, and `signature`.
63
66
  config.publishing_behavior = lambda do |result|
64
67
  # Track the event using our own configured tracking logic.
65
68
  track(:assignment)
@@ -80,6 +83,9 @@ Gitlab::Experiment.configure do |config|
80
83
  # Given a specific context hash map, we need to generate a consistent hash
81
84
  # key. The logic in here will be used for generating cache keys, and may also
82
85
  # be used when determining which variant may be presented.
86
+ #
87
+ # This block is executed within the scope of the experiment and so can access
88
+ # experiment methods, like `name`, `context`, and `signature`.
83
89
  config.context_hash_strategy = lambda do |context|
84
90
  values = context.values.map { |v| (v.respond_to?(:to_global_id) ? v.to_global_id : v).to_s }
85
91
  Digest::MD5.hexdigest((context.keys + values).join('|'))
@@ -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,53 +19,85 @@ 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)
62
+ @excluded = []
63
+ @context = Context.new(self, context)
37
64
 
38
- context(context)
39
-
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
51
76
  end
52
77
 
53
78
  def variant(value = nil)
54
- return @variant_name = value unless value.nil?
79
+ @variant_name = value unless value.blank?
80
+ @variant_name ||= :control if excluded?
55
81
 
56
- result = instance_exec(@variant_name, &Configuration.variant_resolver)
57
- result.respond_to?(:name) ? result : Variant.new(name: result.to_s)
82
+ resolved = cache { resolve_variant_name }
83
+ Variant.new(name: (resolved.presence || :control).to_s)
58
84
  end
59
85
 
60
- def run(variant_name = nil)
61
- @variant_name = variant_name unless variant_name.nil?
86
+ def exclude(&block)
87
+ @excluded << block
88
+ end
62
89
 
63
- @result ||= super(cache { variant.name })
90
+ def run(variant_name = nil)
91
+ @result ||= begin
92
+ variant_name = variant(variant_name).name
93
+ run_callbacks(variant_assigned? ? :unsegmented_run : :segmented_run) do
94
+ if respond_to?((behavior_name = "#{variant_name}_behavior"))
95
+ behaviors[variant_name] ||= -> { send(behavior_name) } # rubocop:disable GitlabSecurity/PublicSend
96
+ end
97
+
98
+ super(@variant_name = variant_name)
99
+ end
100
+ end
64
101
  end
65
102
 
66
103
  def publish(result)
@@ -68,6 +105,8 @@ module Gitlab
68
105
  end
69
106
 
70
107
  def track(action, **event_args)
108
+ return if excluded?
109
+
71
110
  instance_exec(action, event_args, &Configuration.tracking_behavior)
72
111
  end
73
112
 
@@ -76,23 +115,49 @@ module Gitlab
76
115
  end
77
116
 
78
117
  def variant_names
79
- @variant_names ||= behaviors.keys.tap { |keys| keys.delete('control') }.map(&:to_sym)
118
+ @variant_names ||= behaviors.keys.map(&:to_sym) - [:control]
119
+ end
120
+
121
+ def signature
122
+ { variant: variant.name, experiment: name }.merge(context.signature)
80
123
  end
81
124
 
82
125
  def enabled?
83
126
  true
84
127
  end
85
128
 
86
- def identifying_key
87
- :user_id
129
+ def excluded?
130
+ @excluded.any? { |exclude| exclude.call(self) }
131
+ end
132
+
133
+ def variant_assigned?
134
+ !@variant_name.nil?
135
+ end
136
+
137
+ def id
138
+ "#{name}:#{key_for(context.value)}"
139
+ end
140
+ alias_method :session_id, :id
141
+
142
+ def flipper_id
143
+ "Experiment;#{id}"
88
144
  end
89
145
 
90
- def cache_key_for(key, migration: false)
91
- "#{name}:#{key}"
146
+ def key_for(hash)
147
+ instance_exec(hash, &Configuration.context_hash_strategy)
92
148
  end
93
149
 
94
150
  protected
95
151
 
152
+ def resolve_variant_name
153
+ return :unresolved if @resolving
154
+
155
+ @resolving = true
156
+ result = instance_exec(@variant_name, &Configuration.variant_resolver)
157
+ @resolving = false
158
+ result
159
+ end
160
+
96
161
  def generate_result(variant_name)
97
162
  observation = Scientist::Observation.new(variant_name, self, &behaviors[variant_name])
98
163
  Scientist::Result.new(self, [observation], observation)
@@ -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}:#{context.signature[:key]}",
18
+ context.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,14 +13,20 @@ 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
17
 
18
- # Cache layer. Expected to respond to fetch, like Rails.cache.
18
+ # The base class that should be instantiated for basic experiments.
19
+ @base_class = 'Gitlab::Experiment'
20
+
21
+ # The caching layer is expected to respond to fetch, like Rails.cache.
19
22
  @cache = nil
20
23
 
24
+ # The domain to use on cookies.
25
+ @cookie_domain = :all
26
+
21
27
  # Logic this project uses to resolve a variant for a given experiment.
22
28
  @variant_resolver = lambda do |requested_variant|
23
- requested_variant || 'control'
29
+ requested_variant || :control
24
30
  end
25
31
 
26
32
  # Tracking behavior can be implemented to link an event to an experiment.
@@ -28,21 +34,29 @@ module Gitlab
28
34
  Configuration.logger.info "Gitlab::Experiment[#{name}] #{event}: #{args.merge(signature: signature)}"
29
35
  end
30
36
 
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.
37
+ # Called at the end of every experiment run, with the result.
33
38
  @publishing_behavior = lambda do |_result|
34
39
  track(:assignment)
35
40
  end
36
41
 
37
42
  # 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('|'))
43
+ @context_hash_strategy = lambda do |hash_map|
44
+ values = hash_map.values.map { |v| (v.respond_to?(:to_global_id) ? v.to_global_id : v).to_s }
45
+ Digest::MD5.hexdigest(([name] + hash_map.keys + values).join('|'))
41
46
  end
42
47
 
43
48
  class << self
44
- attr_accessor :name_prefix, :logger, :cache
45
- attr_accessor :variant_resolver, :tracking_behavior, :publishing_behavior, :context_hash_strategy
49
+ attr_accessor(
50
+ :name_prefix,
51
+ :logger,
52
+ :base_class,
53
+ :cache,
54
+ :cookie_domain,
55
+ :variant_resolver,
56
+ :tracking_behavior,
57
+ :publishing_behavior,
58
+ :context_hash_strategy
59
+ )
46
60
  end
47
61
  end
48
62
  end
@@ -3,96 +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 if cookie.blank?
69
- return hash.merge(key => cookie) if hash[key].blank?
70
-
71
- @migrations_with << { key => cookie }
72
- jar.delete(cookie_name, domain: :all)
73
-
74
- hash
75
- end
76
-
77
- def generate_cookie(jar, hash, key, cookie = SecureRandom.uuid)
78
- return hash unless hash.key?(key)
79
-
80
- jar.permanent.signed[cookie_name] = {
81
- value: cookie, secure: true, domain: :all, httponly: true
82
- }
83
-
84
- hash.merge(key => cookie)
57
+ @migrations[merge ? :merged : :unmerged] << value
85
58
  end
86
59
 
87
60
  def migration_keys
88
- return nil if @migrations_from.empty? && @migrations_with.empty?
89
-
90
- @migrations_from.map { |m| key_for(m) } +
91
- @migrations_with.map { |m| key_for(@value.merge(m)) }
92
- end
61
+ return nil if @migrations[:unmerged].empty? && @migrations[:merged].empty?
93
62
 
94
- def key_for(context)
95
- 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)) }
96
65
  end
97
66
  end
98
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.4'
5
+ VERSION = '0.4.2'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,15 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gitlab-experiment
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.4
4
+ version: 0.4.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - GitLab
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-10-06 00:00:00.000000000 Z
11
+ date: 2020-11-19 00:00:00.000000000 Z
12
12
  dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activesupport
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '3.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '3.0'
13
27
  - !ruby/object:Gem::Dependency
14
28
  name: scientist
15
29
  requirement: !ruby/object:Gem::Requirement
@@ -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
@@ -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