gitlab-experiment 0.3.0 → 0.4.3

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6134f703a49eb7411ff59e1fa7778421890498a537419cd765a99c5e07ff1523
4
- data.tar.gz: ea3736188b46c9527818109dc87839c33937e0c9086bae4464992046f425874a
3
+ metadata.gz: 65d5d515cf32a09ec81868e3688576ee7314ae1580a9dde67c5c733174b12604
4
+ data.tar.gz: 2b8008b7c47812b492355df4715d5aa53195a852e46a1dd1a5e1b427a2bb9560
5
5
  SHA512:
6
- metadata.gz: ec048634699257e018d7e67f290dfa2dd5e2712d58b1ef25eeb1c9353c5a23ad2f7a8ec0484493e6bccaabc57801aa22ceb8632a5a8c355652ae4a5541a43286
7
- data.tar.gz: 51f63fb0256c49f393887451723483233927a0a983c51ca80f98494634e156dab28d0f87f21319a4df750392bb8329923e94ab23091929b3b26904edb25ce5b3
6
+ metadata.gz: 3ecd4bad77738fbf1c624970c9206c21f72b1c6e4efe59ffba6145959d4e3e1221d0cda709c0d086a849af4063074bbd9e0fa04ddc623589cd623c4dabd50705
7
+ data.tar.gz: 774e0605346863a8f05a28cedf0bb9884412e0ff1b90fcb359cf02be469e46638dbdeb611e0d8298b6d2e6c21b46f4b772f12e9e092b253e68266fa66ce7ed8e
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
@@ -79,72 +82,84 @@ To this end, we track events that are important by calling the same experiment e
79
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, actor: user) do |e|
89
- # Context may be passed in the block, but must be finalized before calling
90
- # run or track.
91
- e.context(project: project) # add the project to the context
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, 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
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
112
+
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
109
120
 
110
- # Define the control and candidate variant.
111
- exp.use { render_toggle } # control
112
- exp.try { render_button } # candidate
121
+ def account_age
122
+ context.actor && context.actor.created_at < 2.weeks.ago
123
+ end
124
+ end
113
125
 
114
- # Run the experiment, returning the result.
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
129
+
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(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
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,11 +167,11 @@ 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
177
  experiment(:notification_toggle, :no_interface, actor: user) do |e|
@@ -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.
@@ -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 rake` 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,43 +5,43 @@ 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. Blank or nil values will be defaulted to the control.
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
- requested_variant || 'control'
34
+ requested_variant
24
35
 
25
36
  # Run the candidate, unless a variant was requested, with a fallback:
26
37
  #
27
- # requested_variant || variant_names.first || 'control'
28
-
29
- # Using Unleash to determine the variant:
30
- #
31
- # fallback = Unleash::Variant.new(name: requested_variant || 'control', enabled: true)
32
- # Unleash.get_variant(name, context.value, fallback)
33
-
34
- # Using Flipper to determine the variant:
35
- #
36
- # TODO: provide example.
37
- # Variant.new(name: requested_variant || 'control')
38
+ # requested_variant || variant_names.first || nil
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,8 +1,12 @@
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'
7
11
  require 'gitlab/experiment/cookies'
8
12
  require 'gitlab/experiment/context'
@@ -15,22 +19,45 @@ module Gitlab
15
19
  class Experiment
16
20
  include Scientist::Experiment
17
21
  include Caching
22
+ include Callbacks
18
23
 
19
24
  class << self
20
25
  def configure
21
26
  yield Configuration
22
27
  end
23
28
 
24
- def run(name, variant_name = nil, **context, &block)
25
- 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)
26
33
  return instance unless block_given?
27
34
 
28
35
  instance.context.frozen? ? instance.run : instance.tap(&:run)
29
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
30
55
  end
31
56
 
32
- def initialize(name, variant_name = nil, **context)
33
- @name = name
57
+ def initialize(name = nil, variant_name = nil, **context)
58
+ raise ArgumentError, 'name is required' if name.blank? && self.class.base?
59
+
60
+ @name = self.class.experiment_name(name, suffix: false)
34
61
  @variant_name = variant_name
35
62
  @excluded = []
36
63
  @context = Context.new(self, context)
@@ -42,17 +69,29 @@ module Gitlab
42
69
  end
43
70
 
44
71
  def context(value = nil)
45
- return @context if value.nil?
72
+ return @context if value.blank?
46
73
 
47
74
  @context.value(value)
48
75
  @context
49
76
  end
50
77
 
51
78
  def variant(value = nil)
52
- return @variant_name = value unless value.nil?
79
+ if value.blank? && @variant_name || @resolving_variant
80
+ return Variant.new(name: (@variant_name || :unresolved).to_s)
81
+ end
82
+
83
+ @variant_name = value unless value.blank?
84
+ @variant_name ||= :control if excluded?
53
85
 
54
- result = instance_exec(@variant_name, &Configuration.variant_resolver)
55
- result.respond_to?(:name) ? result : Variant.new(name: result.to_s)
86
+ @resolving_variant = true
87
+ resolved = :control
88
+ if (result = cache_variant(@variant_name) { resolve_variant_name }).present?
89
+ @variant_name = resolved = result.to_sym
90
+ end
91
+
92
+ Variant.new(name: resolved.to_s)
93
+ ensure
94
+ @resolving_variant = false
56
95
  end
57
96
 
58
97
  def exclude(&block)
@@ -61,10 +100,14 @@ module Gitlab
61
100
 
62
101
  def run(variant_name = nil)
63
102
  @result ||= begin
64
- @variant_name = variant_name unless variant_name.nil?
65
- @variant_name ||= :control if excluded?
66
-
67
- super(cache { variant.name })
103
+ variant_name = variant(variant_name).name
104
+ run_callbacks(variant_assigned? ? :unsegmented_run : :segmented_run) do
105
+ if respond_to?((behavior_name = "#{variant_name}_behavior"))
106
+ behaviors[variant_name] ||= -> { send(behavior_name) } # rubocop:disable GitlabSecurity/PublicSend
107
+ end
108
+
109
+ super(@variant_name = variant_name)
110
+ end
68
111
  end
69
112
  end
70
113
 
@@ -98,8 +141,29 @@ module Gitlab
98
141
  @excluded.any? { |exclude| exclude.call(self) }
99
142
  end
100
143
 
144
+ def variant_assigned?
145
+ !@variant_name.nil?
146
+ end
147
+
148
+ def id
149
+ "#{name}:#{key_for(context.value)}"
150
+ end
151
+ alias_method :session_id, :id
152
+
153
+ def flipper_id
154
+ "Experiment;#{id}"
155
+ end
156
+
157
+ def key_for(hash)
158
+ instance_exec(hash, &Configuration.context_hash_strategy)
159
+ end
160
+
101
161
  protected
102
162
 
163
+ def resolve_variant_name
164
+ instance_exec(@variant_name, &Configuration.variant_resolver)
165
+ end
166
+
103
167
  def generate_result(variant_name)
104
168
  observation = Scientist::Observation.new(variant_name, self, &behaviors[variant_name])
105
169
  Scientist::Result.new(self, [observation], observation)
@@ -3,20 +3,26 @@
3
3
  module Gitlab
4
4
  class Experiment
5
5
  module Caching
6
- def cache(&block)
7
- return yield unless (cache = Configuration.cache)
6
+ def cache_variant(specified = nil, &block)
7
+ cache = Configuration.cache
8
+ return (specified.presence || yield) unless cache
8
9
 
9
10
  key, migrations = cache_strategy
10
- migrated_cache(cache, migrations || [], key) or cache.fetch(key, &block)
11
+ result = migrated_cache(cache, migrations || [], key) || cache.fetch(key, &block)
12
+ return result unless specified.present?
13
+
14
+ cache.write(cache_key, specified) if result != specified
15
+ specified
16
+ end
17
+
18
+ def cache_key(key = nil)
19
+ "#{name}:#{key || context.signature[:key]}"
11
20
  end
12
21
 
13
22
  private
14
23
 
15
24
  def cache_strategy
16
- [
17
- "#{name}:#{signature[:key]}",
18
- signature[:migration_keys]&.map { |key| "#{name}:#{key}" }
19
- ]
25
+ [cache_key, context.signature[:migration_keys]&.map { |key| cache_key(key) }]
20
26
  end
21
27
 
22
28
  def migrated_cache(cache, migrations, new_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,21 @@ 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.
28
+ # If no variant is determined, the control will be used.
22
29
  @variant_resolver = lambda do |requested_variant|
23
- requested_variant || 'control'
30
+ requested_variant
24
31
  end
25
32
 
26
33
  # Tracking behavior can be implemented to link an event to an experiment.
@@ -28,21 +35,29 @@ module Gitlab
28
35
  Configuration.logger.info "Gitlab::Experiment[#{name}] #{event}: #{args.merge(signature: signature)}"
29
36
  end
30
37
 
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.
38
+ # Called at the end of every experiment run, with the result.
33
39
  @publishing_behavior = lambda do |_result|
34
40
  track(:assignment)
35
41
  end
36
42
 
37
43
  # 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('|'))
44
+ @context_hash_strategy = lambda do |hash_map|
45
+ values = hash_map.values.map { |v| (v.respond_to?(:to_global_id) ? v.to_global_id : v).to_s }
46
+ Digest::MD5.hexdigest(([name] + hash_map.keys + values).join('|'))
41
47
  end
42
48
 
43
49
  class << self
44
- attr_accessor :name_prefix, :logger, :cache
45
- attr_accessor :variant_resolver, :tracking_behavior, :publishing_behavior, :context_hash_strategy
50
+ attr_accessor(
51
+ :name_prefix,
52
+ :logger,
53
+ :base_class,
54
+ :cache,
55
+ :cookie_domain,
56
+ :variant_resolver,
57
+ :tracking_behavior,
58
+ :publishing_behavior,
59
+ :context_hash_strategy
60
+ )
46
61
  end
47
62
  end
48
63
  end
@@ -39,7 +39,7 @@ module Gitlab
39
39
  end
40
40
 
41
41
  def signature
42
- @signature ||= { key: key_for(@value), migration_keys: migration_keys }.compact
42
+ @signature ||= { key: @experiment.key_for(@value), migration_keys: migration_keys }.compact
43
43
  end
44
44
 
45
45
  private
@@ -60,12 +60,8 @@ module Gitlab
60
60
  def migration_keys
61
61
  return nil if @migrations[:unmerged].empty? && @migrations[:merged].empty?
62
62
 
63
- @migrations[:unmerged].map { |m| key_for(m) } +
64
- @migrations[:merged].map { |m| key_for(@value.merge(m)) }
65
- end
66
-
67
- def key_for(context)
68
- 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)) }
69
65
  end
70
66
  end
71
67
  end
@@ -11,7 +11,7 @@ module Gitlab
11
11
  return hash if cookie_jar.nil?
12
12
 
13
13
  resolver = [hash, :actor, cookie_name, cookie_jar.signed[cookie_name]]
14
- resolve_cookie(*resolver) or generate_cookie(*resolver)
14
+ resolve_cookie(*resolver) || generate_cookie(*resolver)
15
15
  end
16
16
 
17
17
  def cookie_jar
@@ -24,7 +24,7 @@ module Gitlab
24
24
  return hash.merge(key => cookie) if hash[key].nil?
25
25
 
26
26
  add_migration(key => cookie)
27
- cookie_jar.delete(cookie_name, domain: :all)
27
+ cookie_jar.delete(cookie_name, domain: domain)
28
28
 
29
29
  hash
30
30
  end
@@ -34,11 +34,15 @@ module Gitlab
34
34
 
35
35
  cookie ||= SecureRandom.uuid
36
36
  cookie_jar.permanent.signed[cookie_name] = {
37
- value: cookie, secure: true, domain: :all, httponly: true
37
+ value: cookie, secure: true, domain: domain, httponly: true
38
38
  }
39
39
 
40
40
  hash.merge(key => cookie)
41
41
  end
42
+
43
+ def domain
44
+ Configuration.cookie_domain
45
+ end
42
46
  end
43
47
  end
44
48
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Gitlab
4
4
  class Experiment
5
- VERSION = '0.3.0'
5
+ VERSION = '0.4.3'
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.3.0
4
+ version: 0.4.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - GitLab
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-10-21 00:00:00.000000000 Z
11
+ date: 2020-12-11 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,11 +53,20 @@ 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
49
72
  - lib/gitlab/experiment/cookies.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