gitlab-experiment 0.4.12 → 0.5.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +37 -30
- data/lib/generators/gitlab/experiment/install/templates/initializer.rb.tt +13 -10
- data/lib/gitlab/experiment.rb +31 -85
- data/lib/gitlab/experiment/base_interface.rb +85 -0
- data/lib/gitlab/experiment/cache.rb +2 -0
- data/lib/gitlab/experiment/callbacks.rb +2 -0
- data/lib/gitlab/experiment/configuration.rb +24 -5
- data/lib/gitlab/experiment/context.rb +2 -0
- data/lib/gitlab/experiment/dsl.rb +10 -1
- data/lib/gitlab/experiment/rollout.rb +27 -0
- data/lib/gitlab/experiment/rollout/first.rb +16 -0
- data/lib/gitlab/experiment/rollout/random.rb +15 -0
- data/lib/gitlab/experiment/rollout/round_robin.rb +23 -0
- data/lib/gitlab/experiment/rspec.rb +14 -7
- data/lib/gitlab/experiment/version.rb +1 -1
- metadata +7 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 31e4979de3006211bcd1f63882215ae9213c3a22de25e7cca8546f3fce2ef649
|
4
|
+
data.tar.gz: cb865da8c87f019755194c98e073720f5f246b5a2c57e6d511c2a280d78ff996
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 7906faeb21deaa6e0be322c94c0fd45ea200b5419ac5aacd2d2656b416ac08c21654e84f0ef3d2db5bb33c819dc73020267488a6f7f1ac045b66591ec04ae334
|
7
|
+
data.tar.gz: e0f9328d5c8f1eca74681e749e01cc6ccf513d619b3c4aa522c45d08a4692d370a96d97437975285fa26dcebad0a7e0a3a8356724b980902ba1b86c8fa82f0a1
|
data/README.md
CHANGED
@@ -3,7 +3,7 @@ GitLab Experiment
|
|
3
3
|
|
4
4
|
<img alt="experiment" src="/uploads/60990b2dbf4c0406bbf8b7f998de2dea/experiment.png" align="right" width="40%">
|
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
|
+
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. You can read our [Experiment Guide](https://docs.gitlab.com/ee/development/experiment_guide/) docs if you're curious about how we use this gem internally.
|
7
7
|
|
8
8
|
This library provides a clean and elegant DSL (domain specific language) to define, run, and track your GitLab experiment.
|
9
9
|
|
@@ -64,7 +64,7 @@ end
|
|
64
64
|
|
65
65
|
You can define the experiment using simple control/candidate paths, or provide named variants.
|
66
66
|
|
67
|
-
Handling
|
67
|
+
Handling [multivariate](https://en.wikipedia.org/wiki/Multivariate_statistics) 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.
|
68
68
|
|
69
69
|
```ruby
|
70
70
|
experiment(:notification_toggle, actor: user) do |e|
|
@@ -149,37 +149,13 @@ experiment(:notification_toggle, actor: user) do |e|
|
|
149
149
|
end
|
150
150
|
```
|
151
151
|
|
152
|
-
<details>
|
153
|
-
<summary>You can also use the lower level class interface...</summary>
|
154
|
-
|
155
|
-
### Using the `.run` approach
|
156
|
-
|
157
|
-
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.
|
158
|
-
|
159
|
-
```ruby
|
160
|
-
exp = Gitlab::Experiment.run(:notification_toggle, actor: user) do |e|
|
161
|
-
# Context may be passed in the block, but must be finalized before calling
|
162
|
-
# run or track.
|
163
|
-
e.context(project: project) # add the project to the context
|
164
|
-
|
165
|
-
# Define the control and candidate variant.
|
166
|
-
e.use { render_toggle } # control
|
167
|
-
e.try { render_button } # candidate
|
168
|
-
end
|
169
|
-
|
170
|
-
# Track an event on the experiment we've defined.
|
171
|
-
exp.track(:clicked_button)
|
172
|
-
```
|
173
|
-
|
174
|
-
</details>
|
175
|
-
|
176
152
|
<details>
|
177
153
|
<summary>You can also specify the variant to use for segmentation...</summary>
|
178
154
|
|
179
|
-
### Specifying variant
|
180
|
-
|
181
155
|
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.
|
182
156
|
|
157
|
+
Any time a specific variant is provided (including `:control`) it will be cached for that context, if caching is enabled.
|
158
|
+
|
183
159
|
```ruby
|
184
160
|
experiment(:notification_toggle, :no_interface, actor: user) do |e|
|
185
161
|
e.use { render_toggle } # control
|
@@ -360,6 +336,37 @@ experiment(:example, actor: user, project: project)
|
|
360
336
|
|
361
337
|
For edge cases, you can pass the cookie through by assigning it yourself -- e.g. `actor: request.cookie_jar.signed['example_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`.
|
362
338
|
|
339
|
+
## How it works
|
340
|
+
|
341
|
+
The way the gem works is best described using the following decision tree illustration. When an experiment is run, the following logic is executed to resolve what experience should be provided, given how the experiment is defined, and the context provided.
|
342
|
+
|
343
|
+
```mermaid
|
344
|
+
graph TD
|
345
|
+
GP[General Pool/Population] --> Enabled?
|
346
|
+
Enabled? -->|Yes| Cached?[Cached? / Pre-segmented?]
|
347
|
+
Enabled? -->|No| Excluded[Control / No Tracking]
|
348
|
+
Cached? -->|No| Excluded?
|
349
|
+
Cached? -->|Yes| Cached[Cached Value]
|
350
|
+
Excluded? -->|Yes / Cached| Excluded
|
351
|
+
Excluded? -->|No| Segmented?
|
352
|
+
Segmented? -->|Yes / Cached| VariantA
|
353
|
+
Segmented? -->|No| Included?[Experiment Group?]
|
354
|
+
Included? -->|Yes| Rollout
|
355
|
+
Included? -->|No| Control
|
356
|
+
Rollout -->|Cached| VariantA
|
357
|
+
Rollout -->|Cached| VariantB
|
358
|
+
Rollout -->|Cached| VariantC
|
359
|
+
|
360
|
+
classDef included fill:#380d75,color:#ffffff,stroke:none
|
361
|
+
classDef excluded fill:#fca121,stroke:none
|
362
|
+
classDef cached fill:#2e2e2e,color:#ffffff,stroke:none
|
363
|
+
classDef default fill:#fff,stroke:#6e49cb
|
364
|
+
|
365
|
+
class VariantA,VariantB,VariantC included
|
366
|
+
class Control,Excluded excluded
|
367
|
+
class Cached cached
|
368
|
+
```
|
369
|
+
|
363
370
|
## Configuration
|
364
371
|
|
365
372
|
This gem needs to be configured before being used in a meaningful way.
|
@@ -534,7 +541,7 @@ https://gitlab.com/gitlab-org/gitlab-experiment. This project is intended to be
|
|
534
541
|
safe, welcoming space for collaboration, and contributors are expected to adhere
|
535
542
|
to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
|
536
543
|
|
537
|
-
## Release
|
544
|
+
## Release process
|
538
545
|
|
539
546
|
Please refer to the [Release Process](docs/release_process.md).
|
540
547
|
|
@@ -543,7 +550,7 @@ Please refer to the [Release Process](docs/release_process.md).
|
|
543
550
|
The gem is available as open source under the terms of the
|
544
551
|
[MIT License](http://opensource.org/licenses/MIT).
|
545
552
|
|
546
|
-
## Code of
|
553
|
+
## Code of conduct
|
547
554
|
|
548
555
|
Everyone interacting in the `Gitlab::Experiment` project’s codebases, issue trackers,
|
549
556
|
chat rooms and mailing lists is expected to follow the
|
@@ -22,20 +22,23 @@ Gitlab::Experiment.configure do |config|
|
|
22
22
|
# `['www.gitlab.com', '.gitlab.com']`.
|
23
23
|
config.cookie_domain = :all
|
24
24
|
|
25
|
-
#
|
25
|
+
# The default rollout strategy that works for single and multi-variants.
|
26
26
|
#
|
27
|
-
#
|
28
|
-
#
|
27
|
+
# You can provide your own rollout strategies and override them per
|
28
|
+
# experiment.
|
29
|
+
#
|
30
|
+
# Examples include:
|
31
|
+
# Rollout::First, Rollout::Random, Rollout::RoundRobin
|
32
|
+
config.default_rollout = Gitlab::Experiment::Rollout::First
|
33
|
+
|
34
|
+
# Logic this project uses to determine inclusion in a given experiment.
|
35
|
+
#
|
36
|
+
# Expected to return a boolean value.
|
29
37
|
#
|
30
38
|
# This block is executed within the scope of the experiment and so can access
|
31
39
|
# experiment methods, like `name`, `context`, and `signature`.
|
32
|
-
config.
|
33
|
-
|
34
|
-
requested_variant
|
35
|
-
|
36
|
-
# Run the candidate, unless a variant was requested, with a fallback:
|
37
|
-
#
|
38
|
-
# requested_variant || variant_names.first || nil
|
40
|
+
config.inclusion_resolver = lambda do |requested_variant|
|
41
|
+
false
|
39
42
|
end
|
40
43
|
|
41
44
|
# Tracking behavior can be implemented to link an event to an experiment.
|
data/lib/gitlab/experiment.rb
CHANGED
@@ -3,11 +3,15 @@
|
|
3
3
|
require 'scientist'
|
4
4
|
require 'active_support/callbacks'
|
5
5
|
require 'active_support/cache'
|
6
|
+
require 'active_support/concern'
|
6
7
|
require 'active_support/core_ext/object/blank'
|
7
8
|
require 'active_support/core_ext/string/inflections'
|
9
|
+
require 'active_support/core_ext/module/delegation'
|
8
10
|
|
11
|
+
require 'gitlab/experiment/base_interface'
|
9
12
|
require 'gitlab/experiment/cache'
|
10
13
|
require 'gitlab/experiment/callbacks'
|
14
|
+
require 'gitlab/experiment/rollout'
|
11
15
|
require 'gitlab/experiment/configuration'
|
12
16
|
require 'gitlab/experiment/cookies'
|
13
17
|
require 'gitlab/experiment/context'
|
@@ -18,57 +22,29 @@ require 'gitlab/experiment/engine' if defined?(Rails::Engine)
|
|
18
22
|
|
19
23
|
module Gitlab
|
20
24
|
class Experiment
|
21
|
-
include
|
25
|
+
include BaseInterface
|
22
26
|
include Cache
|
23
27
|
include Callbacks
|
24
28
|
|
25
29
|
class << self
|
26
|
-
def
|
27
|
-
|
28
|
-
end
|
29
|
-
|
30
|
-
def run(name = nil, variant_name = nil, **context, &block)
|
31
|
-
raise ArgumentError, 'name is required' if name.nil? && base?
|
32
|
-
|
33
|
-
instance = constantize(name).new(name, variant_name, **context, &block)
|
34
|
-
return instance unless block
|
35
|
-
|
36
|
-
instance.context.frozen? ? instance.run : instance.tap(&:run)
|
37
|
-
end
|
38
|
-
|
39
|
-
def experiment_name(name = nil, suffix: true, suffix_word: 'experiment')
|
40
|
-
name = (name.presence || self.name).to_s.underscore.sub(%r{(?<char>[_/]|)#{suffix_word}$}, '')
|
41
|
-
name = "#{name}#{Regexp.last_match(:char) || '_'}#{suffix_word}"
|
42
|
-
suffix ? name : name.sub(/_#{suffix_word}$/, '')
|
43
|
-
end
|
30
|
+
def default_rollout(rollout = nil)
|
31
|
+
return @rollout ||= Configuration.default_rollout if rollout.blank?
|
44
32
|
|
45
|
-
|
46
|
-
self == Gitlab::Experiment || name == Configuration.base_class
|
47
|
-
end
|
48
|
-
|
49
|
-
private
|
50
|
-
|
51
|
-
def constantize(name = nil)
|
52
|
-
return self if name.nil?
|
53
|
-
|
54
|
-
experiment_name(name).classify.safe_constantize || Configuration.base_class.constantize
|
33
|
+
@rollout = Rollout.resolve(rollout)
|
55
34
|
end
|
56
35
|
end
|
57
36
|
|
58
|
-
def
|
59
|
-
|
60
|
-
|
61
|
-
@name = self.class.experiment_name(name, suffix: false)
|
62
|
-
@context = Context.new(self, **context)
|
63
|
-
@variant_name = cache_variant(variant_name) { nil } if variant_name.present?
|
64
|
-
|
65
|
-
compare { false }
|
37
|
+
def name
|
38
|
+
[Configuration.name_prefix, @name].compact.join('_')
|
39
|
+
end
|
66
40
|
|
67
|
-
|
41
|
+
def use(&block)
|
42
|
+
try(:control, &block)
|
68
43
|
end
|
69
44
|
|
70
|
-
def
|
71
|
-
|
45
|
+
def try(name = nil, &block)
|
46
|
+
name = (name || :candidate).to_s
|
47
|
+
behaviors[name] = block
|
72
48
|
end
|
73
49
|
|
74
50
|
def context(value = nil)
|
@@ -99,6 +75,12 @@ module Gitlab
|
|
99
75
|
@resolving_variant = false
|
100
76
|
end
|
101
77
|
|
78
|
+
def rollout(rollout = nil)
|
79
|
+
return @rollout ||= self.class.default_rollout if rollout.blank?
|
80
|
+
|
81
|
+
@rollout = Rollout.resolve(rollout)
|
82
|
+
end
|
83
|
+
|
102
84
|
def run(variant_name = nil)
|
103
85
|
@result ||= super(variant(variant_name).name)
|
104
86
|
end
|
@@ -113,41 +95,6 @@ module Gitlab
|
|
113
95
|
instance_exec(action, event_args, &Configuration.tracking_behavior)
|
114
96
|
end
|
115
97
|
|
116
|
-
def name
|
117
|
-
[Configuration.name_prefix, @name].compact.join('_')
|
118
|
-
end
|
119
|
-
|
120
|
-
def variant_names
|
121
|
-
@variant_names ||= behaviors.keys.map(&:to_sym) - [:control]
|
122
|
-
end
|
123
|
-
|
124
|
-
def behaviors
|
125
|
-
@behaviors ||= public_methods.each_with_object(super) do |name, behaviors|
|
126
|
-
next unless name.end_with?('_behavior')
|
127
|
-
|
128
|
-
behavior_name = name.to_s.sub(/_behavior$/, '')
|
129
|
-
behaviors[behavior_name] ||= -> { send(name) } # rubocop:disable GitlabSecurity/PublicSend
|
130
|
-
end
|
131
|
-
end
|
132
|
-
|
133
|
-
def try(name = nil, &block)
|
134
|
-
name = (name || 'candidate').to_s
|
135
|
-
behaviors[name] = block
|
136
|
-
end
|
137
|
-
|
138
|
-
def signature
|
139
|
-
{ variant: variant.name, experiment: name }.merge(context.signature)
|
140
|
-
end
|
141
|
-
|
142
|
-
def id
|
143
|
-
"#{name}:#{key_for(context.value)}"
|
144
|
-
end
|
145
|
-
alias_method :session_id, :id
|
146
|
-
|
147
|
-
def flipper_id
|
148
|
-
"Experiment;#{id}"
|
149
|
-
end
|
150
|
-
|
151
98
|
def enabled?
|
152
99
|
true
|
153
100
|
end
|
@@ -159,10 +106,18 @@ module Gitlab
|
|
159
106
|
!run_callbacks(:exclusion_check) { :not_excluded } # didn't pass exclusion check
|
160
107
|
end
|
161
108
|
|
109
|
+
def experiment_group?
|
110
|
+
instance_exec(@variant_name, &Configuration.inclusion_resolver)
|
111
|
+
end
|
112
|
+
|
162
113
|
def should_track?
|
163
114
|
enabled? && !excluded?
|
164
115
|
end
|
165
116
|
|
117
|
+
def signature
|
118
|
+
{ variant: variant.name, experiment: name }.merge(context.signature)
|
119
|
+
end
|
120
|
+
|
166
121
|
def key_for(hash)
|
167
122
|
instance_exec(hash, &Configuration.context_hash_strategy)
|
168
123
|
end
|
@@ -175,17 +130,8 @@ module Gitlab
|
|
175
130
|
:unsegmented
|
176
131
|
end
|
177
132
|
|
178
|
-
def variant_assigned?
|
179
|
-
!@variant_name.nil?
|
180
|
-
end
|
181
|
-
|
182
133
|
def resolve_variant_name
|
183
|
-
|
184
|
-
end
|
185
|
-
|
186
|
-
def generate_result(variant_name)
|
187
|
-
observation = Scientist::Observation.new(variant_name, self, &behaviors[variant_name])
|
188
|
-
Scientist::Result.new(self, [observation], observation)
|
134
|
+
rollout.new(self).execute if experiment_group?
|
189
135
|
end
|
190
136
|
end
|
191
137
|
end
|
@@ -0,0 +1,85 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Gitlab
|
4
|
+
class Experiment
|
5
|
+
module BaseInterface
|
6
|
+
extend ActiveSupport::Concern
|
7
|
+
|
8
|
+
# don't `include` here so we don't override the default scientist class
|
9
|
+
Scientist::Experiment.send(:append_features, self) # rubocop:disable GitlabSecurity/PublicSend
|
10
|
+
|
11
|
+
class_methods do
|
12
|
+
include Scientist::Experiment::RaiseOnMismatch
|
13
|
+
|
14
|
+
def configure
|
15
|
+
yield Configuration
|
16
|
+
end
|
17
|
+
|
18
|
+
def experiment_name(name = nil, suffix: true, suffix_word: 'experiment')
|
19
|
+
name = (name.presence || self.name).to_s.underscore.sub(%r{(?<char>[_/]|)#{suffix_word}$}, '')
|
20
|
+
name = "#{name}#{Regexp.last_match(:char) || '_'}#{suffix_word}"
|
21
|
+
suffix ? name : name.sub(/_#{suffix_word}$/, '')
|
22
|
+
end
|
23
|
+
|
24
|
+
def base?
|
25
|
+
self == Gitlab::Experiment || name == Configuration.base_class
|
26
|
+
end
|
27
|
+
|
28
|
+
def constantize(name = nil)
|
29
|
+
return self if name.nil?
|
30
|
+
|
31
|
+
experiment_name(name).classify.safe_constantize || Configuration.base_class.constantize
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def initialize(name = nil, variant_name = nil, **context)
|
36
|
+
raise ArgumentError, 'name is required' if name.blank? && self.class.base?
|
37
|
+
|
38
|
+
@name = self.class.experiment_name(name, suffix: false)
|
39
|
+
@context = Context.new(self, **context)
|
40
|
+
@variant_name = cache_variant(variant_name) { nil } if variant_name.present?
|
41
|
+
|
42
|
+
compare { false }
|
43
|
+
|
44
|
+
yield self if block_given?
|
45
|
+
end
|
46
|
+
|
47
|
+
def inspect
|
48
|
+
"#<#{self.class.name || 'AnonymousClass'}:#{format('0x%016X', __id__)} @name=#{name} @context=#{context.value}>"
|
49
|
+
end
|
50
|
+
|
51
|
+
def id
|
52
|
+
"#{name}:#{key_for(context.value)}"
|
53
|
+
end
|
54
|
+
alias_method :session_id, :id
|
55
|
+
|
56
|
+
def flipper_id
|
57
|
+
"Experiment;#{id}"
|
58
|
+
end
|
59
|
+
|
60
|
+
def variant_names
|
61
|
+
@variant_names ||= behaviors.keys.map(&:to_sym) - [:control]
|
62
|
+
end
|
63
|
+
|
64
|
+
def behaviors
|
65
|
+
@behaviors ||= public_methods.each_with_object(super) do |name, behaviors|
|
66
|
+
next unless name.end_with?('_behavior')
|
67
|
+
|
68
|
+
behavior_name = name.to_s.sub(/_behavior$/, '')
|
69
|
+
behaviors[behavior_name] ||= -> { send(name) } # rubocop:disable GitlabSecurity/PublicSend
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
protected
|
74
|
+
|
75
|
+
def variant_assigned?
|
76
|
+
!@variant_name.nil?
|
77
|
+
end
|
78
|
+
|
79
|
+
def generate_result(variant_name)
|
80
|
+
observation = Scientist::Observation.new(variant_name, self, &behaviors[variant_name])
|
81
|
+
Scientist::Result.new(self, [observation], observation)
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
@@ -4,6 +4,8 @@ require 'singleton'
|
|
4
4
|
require 'logger'
|
5
5
|
require 'digest'
|
6
6
|
|
7
|
+
require 'active_support/deprecation'
|
8
|
+
|
7
9
|
module Gitlab
|
8
10
|
class Experiment
|
9
11
|
class Configuration
|
@@ -24,10 +26,13 @@ module Gitlab
|
|
24
26
|
# The domain to use on cookies.
|
25
27
|
@cookie_domain = :all
|
26
28
|
|
27
|
-
#
|
28
|
-
|
29
|
-
|
30
|
-
|
29
|
+
# The default rollout strategy that works for single and multi-variants.
|
30
|
+
@default_rollout = Rollout::First
|
31
|
+
|
32
|
+
# Logic this project uses to determine inclusion in a given experiment.
|
33
|
+
# Expected to return a boolean value.
|
34
|
+
@inclusion_resolver = lambda do |requested_variant|
|
35
|
+
false
|
31
36
|
end
|
32
37
|
|
33
38
|
# Tracking behavior can be implemented to link an event to an experiment.
|
@@ -47,13 +52,27 @@ module Gitlab
|
|
47
52
|
end
|
48
53
|
|
49
54
|
class << self
|
55
|
+
# TODO: Added deprecation in release 0.5.0
|
56
|
+
def variant_resolver
|
57
|
+
ActiveSupport::Deprecation.warn('variant_resolver is deprecated, instead use `inclusion_resolver` with a' \
|
58
|
+
'block that returns a boolean.')
|
59
|
+
@inclusion_resolver
|
60
|
+
end
|
61
|
+
|
62
|
+
def variant_resolver=(block)
|
63
|
+
ActiveSupport::Deprecation.warn('variant_resolver is deprecated, instead use `inclusion_resolver` with a' \
|
64
|
+
'block that returns a boolean.')
|
65
|
+
@inclusion_resolver = block
|
66
|
+
end
|
67
|
+
|
50
68
|
attr_accessor(
|
51
69
|
:name_prefix,
|
52
70
|
:logger,
|
53
71
|
:base_class,
|
54
72
|
:cache,
|
55
73
|
:cookie_domain,
|
56
|
-
:
|
74
|
+
:default_rollout,
|
75
|
+
:inclusion_resolver,
|
57
76
|
:tracking_behavior,
|
58
77
|
:publishing_behavior,
|
59
78
|
:context_hash_strategy
|
@@ -4,8 +4,17 @@ module Gitlab
|
|
4
4
|
class Experiment
|
5
5
|
module Dsl
|
6
6
|
def experiment(name, variant_name = nil, **context, &block)
|
7
|
+
raise ArgumentError, 'name is required' if name.nil?
|
8
|
+
|
7
9
|
context[:request] ||= request if respond_to?(:request)
|
8
|
-
|
10
|
+
|
11
|
+
base = Configuration.base_class.constantize
|
12
|
+
klass = base.constantize(name) || base
|
13
|
+
|
14
|
+
instance = klass.new(name, variant_name, **context, &block)
|
15
|
+
return instance unless block
|
16
|
+
|
17
|
+
instance.context.frozen? ? instance.run : instance.tap(&:run)
|
9
18
|
end
|
10
19
|
end
|
11
20
|
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Gitlab
|
4
|
+
class Experiment
|
5
|
+
module Rollout
|
6
|
+
autoload :First, 'gitlab/experiment/rollout/first.rb' # default strategy
|
7
|
+
autoload :Random, 'gitlab/experiment/rollout/random.rb'
|
8
|
+
autoload :RoundRobin, 'gitlab/experiment/rollout/round_robin.rb'
|
9
|
+
|
10
|
+
def self.resolve(klass)
|
11
|
+
return "#{name}::#{klass.to_s.classify}".constantize if klass.is_a?(Symbol) || klass.is_a?(String)
|
12
|
+
|
13
|
+
klass
|
14
|
+
end
|
15
|
+
|
16
|
+
class Base
|
17
|
+
attr_reader :experiment
|
18
|
+
|
19
|
+
delegate :variant_names, :cache, to: :experiment
|
20
|
+
|
21
|
+
def initialize(experiment)
|
22
|
+
@experiment = experiment
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Gitlab
|
4
|
+
class Experiment
|
5
|
+
module Rollout
|
6
|
+
class First < Base
|
7
|
+
# This rollout strategy just picks the first variant name. It's the
|
8
|
+
# default resolver as it assumes a single variant. You should consider
|
9
|
+
# using a more advanced rollout if you have multiple variants.
|
10
|
+
def execute
|
11
|
+
variant_names.first
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Gitlab
|
4
|
+
class Experiment
|
5
|
+
module Rollout
|
6
|
+
class Random < Base
|
7
|
+
# Pick a random variant if we're in the experiment group. It doesn't
|
8
|
+
# take into account small sample sizes but is useful and performant.
|
9
|
+
def execute
|
10
|
+
variant_names.sample
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Gitlab
|
4
|
+
class Experiment
|
5
|
+
module Rollout
|
6
|
+
class RoundRobin < Base
|
7
|
+
KEY_NAME = :last_round_robin_variant
|
8
|
+
|
9
|
+
# Requires a cache to be configured.
|
10
|
+
#
|
11
|
+
# Keeps track of the number of assignments into the experiment group,
|
12
|
+
# and uses this to rotate "round robin" style through the variants
|
13
|
+
# that are defined.
|
14
|
+
#
|
15
|
+
# Relatively performant, but requires a cache, and is dependent on the
|
16
|
+
# performance of that cache store.
|
17
|
+
def execute
|
18
|
+
variant_names[(cache.attr_inc(KEY_NAME) - 1) % variant_names.size]
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -6,14 +6,19 @@ module Gitlab
|
|
6
6
|
def stub_experiments(experiments)
|
7
7
|
experiments.each do |name, variant|
|
8
8
|
variant = :control if variant == false
|
9
|
-
raise ArgumentError, 'variant must be a symbol or false' unless variant.is_a?(Symbol)
|
10
9
|
|
11
|
-
|
10
|
+
base = Configuration.base_class.constantize
|
11
|
+
klass = base.constantize(name) || base
|
12
12
|
|
13
13
|
# We have to use this high level any_instance behavior as there's
|
14
14
|
# not an alternative that allows multiple wrappings of `new`.
|
15
15
|
allow_any_instance_of(klass).to receive(:enabled?).and_return(true)
|
16
|
-
|
16
|
+
|
17
|
+
if variant == true # passing true allows the rollout to do its job
|
18
|
+
allow_any_instance_of(klass).to receive(:experiment_group?).and_return(true)
|
19
|
+
else
|
20
|
+
allow_any_instance_of(klass).to receive(:resolve_variant_name).and_return(variant.to_s)
|
21
|
+
end
|
17
22
|
end
|
18
23
|
end
|
19
24
|
|
@@ -147,14 +152,16 @@ module Gitlab
|
|
147
152
|
end
|
148
153
|
end
|
149
154
|
|
150
|
-
def receive_tracking_call_for(
|
151
|
-
receive(:track).with(*[
|
155
|
+
def receive_tracking_call_for(expected_event, *expected_event_args)
|
156
|
+
receive(:track).with(*[expected_event, *expected_event_args]).and_wrap_original do |track, event, *event_args|
|
157
|
+
track.call(event, *event_args) # call the original
|
158
|
+
|
152
159
|
if @expected_variant
|
153
|
-
expect(@experiment.variant.name).to eq(@expected_variant), failure_message(:variant,
|
160
|
+
expect(@experiment.variant.name).to eq(@expected_variant), failure_message(:variant, expected_event)
|
154
161
|
end
|
155
162
|
|
156
163
|
if @expected_context
|
157
|
-
expect(@experiment.context.value).to include(@expected_context), failure_message(:context,
|
164
|
+
expect(@experiment.context.value).to include(@expected_context), failure_message(:context, expected_event)
|
158
165
|
end
|
159
166
|
end
|
160
167
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: gitlab-experiment
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.5.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- GitLab
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2021-
|
11
|
+
date: 2021-03-11 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activesupport
|
@@ -65,6 +65,7 @@ files:
|
|
65
65
|
- lib/generators/test_unit/experiment/experiment_generator.rb
|
66
66
|
- lib/generators/test_unit/experiment/templates/experiment_test.rb.tt
|
67
67
|
- lib/gitlab/experiment.rb
|
68
|
+
- lib/gitlab/experiment/base_interface.rb
|
68
69
|
- lib/gitlab/experiment/cache.rb
|
69
70
|
- lib/gitlab/experiment/cache/redis_hash_store.rb
|
70
71
|
- lib/gitlab/experiment/callbacks.rb
|
@@ -73,6 +74,10 @@ files:
|
|
73
74
|
- lib/gitlab/experiment/cookies.rb
|
74
75
|
- lib/gitlab/experiment/dsl.rb
|
75
76
|
- lib/gitlab/experiment/engine.rb
|
77
|
+
- lib/gitlab/experiment/rollout.rb
|
78
|
+
- lib/gitlab/experiment/rollout/first.rb
|
79
|
+
- lib/gitlab/experiment/rollout/random.rb
|
80
|
+
- lib/gitlab/experiment/rollout/round_robin.rb
|
76
81
|
- lib/gitlab/experiment/rspec.rb
|
77
82
|
- lib/gitlab/experiment/variant.rb
|
78
83
|
- lib/gitlab/experiment/version.rb
|