gitlab-experiment 0.3.1 → 0.4.4
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +113 -51
- data/lib/generators/gitlab/experiment/USAGE +17 -0
- data/lib/generators/gitlab/experiment/experiment_generator.rb +33 -0
- data/lib/generators/gitlab/experiment/install/install_generator.rb +41 -0
- data/lib/generators/gitlab/experiment/install/templates/POST_INSTALL +2 -0
- data/lib/generators/gitlab/experiment/install/templates/application_experiment.rb.tt +4 -0
- data/lib/generators/{gitlab_experiment/install/templates/initializer.rb → gitlab/experiment/install/templates/initializer.rb.tt} +29 -23
- data/lib/generators/gitlab/experiment/templates/experiment.rb.tt +15 -0
- data/lib/generators/rspec/experiment/experiment_generator.rb +15 -0
- data/lib/generators/rspec/experiment/templates/experiment_spec.rb.tt +9 -0
- data/lib/generators/test_unit/experiment/experiment_generator.rb +17 -0
- data/lib/generators/test_unit/experiment/templates/experiment_test.rb.tt +11 -0
- data/lib/gitlab/experiment.rb +66 -17
- data/lib/gitlab/experiment/caching.rb +13 -7
- data/lib/gitlab/experiment/callbacks.rb +39 -0
- data/lib/gitlab/experiment/configuration.rb +15 -7
- data/lib/gitlab/experiment/context.rb +3 -7
- data/lib/gitlab/experiment/cookies.rb +7 -3
- data/lib/gitlab/experiment/version.rb +1 -1
- metadata +28 -5
- data/lib/generators/gitlab_experiment/install/POST_INSTALL +0 -0
- data/lib/generators/gitlab_experiment/install/install_generator.rb +0 -21
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 5b1a2f81e8680cffda06d116655200c055dbb74a42a52a7fa8adcb84c720fb01
|
4
|
+
data.tar.gz: 715ea6dd8494457c169ef3147ca2edf2fc6e4c63571801e1f5b22814182ebb56
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 7eec246157542ff0897f9af9f3135272d4d7fea31049443519fb550d5ca9cb3ad2d073ae8594104256d353b8042132f7ae5cba399bb44c38d7d5332154d5b849
|
7
|
+
data.tar.gz: ff4145fbe06fad2de4ef6295f4a0715c71c9ef4165308452d9dacd0c02a0df94d2152d138a6945c583c68d6c5966e1bb1e200fd79a7d9668382ee7c1d68861ea
|
data/README.md
CHANGED
@@ -1,10 +1,11 @@
|
|
1
|
-
|
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
|
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
|
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
|
-
|
83
|
-
<summary>You can also use the more low level class or instance interfaces...</summary>
|
85
|
+
### Custom experiments
|
84
86
|
|
85
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
99
|
-
|
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
|
-
|
99
|
+
Here are some examples of what you can introduce once you have a custom experiment defined.
|
103
100
|
|
104
101
|
```ruby
|
105
|
-
|
106
|
-
#
|
107
|
-
#
|
108
|
-
|
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
|
-
|
111
|
-
|
112
|
-
|
121
|
+
def account_age
|
122
|
+
context.actor && context.actor.created_at < 2.weeks.ago
|
123
|
+
end
|
124
|
+
end
|
113
125
|
|
114
|
-
#
|
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
|
-
|
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
|
146
|
+
<summary>You can also use the lower level class interface...</summary>
|
125
147
|
|
126
|
-
###
|
148
|
+
### Using the `.run` approach
|
127
149
|
|
128
|
-
|
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
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
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
|
-
|
140
|
-
|
141
|
-
|
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
|
-
|
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/
|
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
|
-
|
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
|
@@ -7,41 +7,41 @@ Gitlab::Experiment.configure do |config|
|
|
7
7
|
# The logger is used to log various details of the experiments.
|
8
8
|
config.logger = Logger.new($stdout)
|
9
9
|
|
10
|
-
# The
|
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
|
-
#
|
16
|
-
#
|
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
|
20
|
-
#
|
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
|
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 ||
|
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
|
-
#
|
43
|
-
# 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
|
62
|
-
# into redis.
|
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,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
|
data/lib/gitlab/experiment.rb
CHANGED
@@ -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,31 +19,48 @@ 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)
|
29
|
+
def run(name = nil, variant_name = nil, **context, &block)
|
30
|
+
raise ArgumentError, 'name is required' if name.nil? && base?
|
31
|
+
|
25
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
|
30
37
|
|
31
|
-
def
|
32
|
-
name =
|
33
|
-
|
34
|
-
|
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
|
35
54
|
end
|
36
55
|
end
|
37
56
|
|
38
|
-
def initialize(name, variant_name = nil, **context)
|
39
|
-
|
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)
|
40
61
|
@variant_name = variant_name
|
41
62
|
@excluded = []
|
42
|
-
@context = Context.new(self, context)
|
63
|
+
@context = Context.new(self, **context)
|
43
64
|
|
44
65
|
exclude { !@context.trackable? }
|
45
66
|
compare { false }
|
@@ -48,17 +69,29 @@ module Gitlab
|
|
48
69
|
end
|
49
70
|
|
50
71
|
def context(value = nil)
|
51
|
-
return @context if value.
|
72
|
+
return @context if value.blank?
|
52
73
|
|
53
74
|
@context.value(value)
|
54
75
|
@context
|
55
76
|
end
|
56
77
|
|
57
78
|
def variant(value = nil)
|
58
|
-
|
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?
|
59
85
|
|
60
|
-
|
61
|
-
|
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
|
62
95
|
end
|
63
96
|
|
64
97
|
def exclude(&block)
|
@@ -67,10 +100,14 @@ module Gitlab
|
|
67
100
|
|
68
101
|
def run(variant_name = nil)
|
69
102
|
@result ||= begin
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
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
|
74
111
|
end
|
75
112
|
end
|
76
113
|
|
@@ -104,8 +141,12 @@ module Gitlab
|
|
104
141
|
@excluded.any? { |exclude| exclude.call(self) }
|
105
142
|
end
|
106
143
|
|
144
|
+
def variant_assigned?
|
145
|
+
!@variant_name.nil?
|
146
|
+
end
|
147
|
+
|
107
148
|
def id
|
108
|
-
"#{name}:#{
|
149
|
+
"#{name}:#{key_for(context.value)}"
|
109
150
|
end
|
110
151
|
alias_method :session_id, :id
|
111
152
|
|
@@ -113,8 +154,16 @@ module Gitlab
|
|
113
154
|
"Experiment;#{id}"
|
114
155
|
end
|
115
156
|
|
157
|
+
def key_for(hash)
|
158
|
+
instance_exec(hash, &Configuration.context_hash_strategy)
|
159
|
+
end
|
160
|
+
|
116
161
|
protected
|
117
162
|
|
163
|
+
def resolve_variant_name
|
164
|
+
instance_exec(@variant_name, &Configuration.variant_resolver)
|
165
|
+
end
|
166
|
+
|
118
167
|
def generate_result(variant_name)
|
119
168
|
observation = Scientist::Observation.new(variant_name, self, &behaviors[variant_name])
|
120
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
|
7
|
-
|
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)
|
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
|
@@ -15,12 +15,19 @@ module Gitlab
|
|
15
15
|
# The logger is used to log various details of the experiments.
|
16
16
|
@logger = Logger.new($stdout)
|
17
17
|
|
18
|
-
#
|
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
|
30
|
+
requested_variant
|
24
31
|
end
|
25
32
|
|
26
33
|
# Tracking behavior can be implemented to link an event to an experiment.
|
@@ -28,23 +35,24 @@ 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
|
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 |
|
39
|
-
values =
|
40
|
-
Digest::MD5.hexdigest((
|
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
50
|
attr_accessor(
|
45
51
|
:name_prefix,
|
46
52
|
:logger,
|
53
|
+
:base_class,
|
47
54
|
:cache,
|
55
|
+
:cookie_domain,
|
48
56
|
:variant_resolver,
|
49
57
|
:tracking_behavior,
|
50
58
|
:publishing_behavior,
|
@@ -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)
|
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:
|
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:
|
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
|
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.
|
4
|
+
version: 0.4.4
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- GitLab
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2020-
|
11
|
+
date: 2020-12-15 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/
|
43
|
-
- lib/generators/
|
44
|
-
- lib/generators/
|
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
|
File without changes
|
@@ -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
|