gitlab-experiment 0.4.11 → 0.5.3
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 +50 -91
- data/lib/gitlab/experiment/base_interface.rb +88 -0
- data/lib/gitlab/experiment/cache.rb +2 -0
- data/lib/gitlab/experiment/callbacks.rb +7 -16
- 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 +89 -43
- data/lib/gitlab/experiment/version.rb +1 -1
- metadata +11 -6
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 1c4e4a4b19b67700aab0629cd615617c4452d8b4b145ef8ff85d12443994362b
|
4
|
+
data.tar.gz: 802c31143890f2431a80ed3dcb247cbf46247ce276053e8e27a4d190822650df
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 86f86cd9dd01006be9668a2ceb4d60351e943487576d236e12b40eb9405e4255fdbbffb0c5811b17a4f1c8d2784b011eb20bc783e622f4f4f05fc6fb166f0df3
|
7
|
+
data.tar.gz: bca224adeaade21b59077effd1faa0d6ca95607fe02f6c7735b8c9b79b7d5a08f76675549ddbc874ce63adbe39895d7b3f6f1fb71e908ae22a76c817a1d0f88d
|
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,58 +22,44 @@ 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?
|
30
|
+
def default_rollout(rollout = nil)
|
31
|
+
return @rollout ||= Configuration.default_rollout if rollout.blank?
|
32
32
|
|
33
|
-
|
34
|
-
return instance unless block
|
35
|
-
|
36
|
-
instance.context.frozen? ? instance.run : instance.tap(&:run)
|
33
|
+
@rollout = Rollout.resolve(rollout)
|
37
34
|
end
|
38
35
|
|
39
|
-
def
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
end
|
44
|
-
|
45
|
-
def base?
|
46
|
-
self == Gitlab::Experiment || name == Configuration.base_class
|
36
|
+
def exclude(*filter_list, **options, &block)
|
37
|
+
build_callback(:exclusion_check, filter_list.unshift(block), **options) do |target, callback|
|
38
|
+
throw(:abort) if target.instance_variable_get(:@excluded) || callback.call(target, nil) == true
|
39
|
+
end
|
47
40
|
end
|
48
41
|
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
experiment_name(name).classify.safe_constantize || Configuration.base_class.constantize
|
42
|
+
def segment(*filter_list, variant:, **options, &block)
|
43
|
+
build_callback(:segmentation_check, filter_list.unshift(block), **options) do |target, callback|
|
44
|
+
target.variant(variant) if target.instance_variable_get(:@variant_name).nil? && callback.call(target, nil)
|
45
|
+
end
|
55
46
|
end
|
56
47
|
end
|
57
48
|
|
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 }
|
49
|
+
def name
|
50
|
+
[Configuration.name_prefix, @name].compact.join('_')
|
51
|
+
end
|
66
52
|
|
67
|
-
|
53
|
+
def control(&block)
|
54
|
+
candidate(:control, &block)
|
68
55
|
end
|
56
|
+
alias_method :use, :control
|
69
57
|
|
70
|
-
def
|
71
|
-
|
58
|
+
def candidate(name = nil, &block)
|
59
|
+
name = (name || :candidate).to_s
|
60
|
+
behaviors[name] = block
|
72
61
|
end
|
62
|
+
alias_method :try, :candidate
|
73
63
|
|
74
64
|
def context(value = nil)
|
75
65
|
return @context if value.blank?
|
@@ -79,16 +69,12 @@ module Gitlab
|
|
79
69
|
end
|
80
70
|
|
81
71
|
def variant(value = nil)
|
82
|
-
@variant_name = value
|
72
|
+
@variant_name = cache_variant(value) if value.present?
|
83
73
|
return Variant.new(name: (@variant_name || :unresolved).to_s) if @variant_name || @resolving_variant
|
84
74
|
|
85
75
|
if enabled?
|
86
|
-
@variant_name ||= :control if excluded?
|
87
|
-
|
88
76
|
@resolving_variant = true
|
89
|
-
|
90
|
-
@variant_name = result.to_sym
|
91
|
-
end
|
77
|
+
@variant_name = cached_variant_resolver(@variant_name)
|
92
78
|
end
|
93
79
|
|
94
80
|
run_callbacks(segmentation_callback_chain) do
|
@@ -99,6 +85,16 @@ module Gitlab
|
|
99
85
|
@resolving_variant = false
|
100
86
|
end
|
101
87
|
|
88
|
+
def rollout(rollout = nil)
|
89
|
+
return @rollout ||= self.class.default_rollout if rollout.blank?
|
90
|
+
|
91
|
+
@rollout = Rollout.resolve(rollout)
|
92
|
+
end
|
93
|
+
|
94
|
+
def exclude!
|
95
|
+
@excluded = true
|
96
|
+
end
|
97
|
+
|
102
98
|
def run(variant_name = nil)
|
103
99
|
@result ||= super(variant(variant_name).name)
|
104
100
|
end
|
@@ -113,41 +109,6 @@ module Gitlab
|
|
113
109
|
instance_exec(action, event_args, &Configuration.tracking_behavior)
|
114
110
|
end
|
115
111
|
|
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
112
|
def enabled?
|
152
113
|
true
|
153
114
|
end
|
@@ -155,12 +116,19 @@ module Gitlab
|
|
155
116
|
def excluded?
|
156
117
|
return @excluded if defined?(@excluded)
|
157
118
|
|
158
|
-
@excluded =
|
159
|
-
|
119
|
+
@excluded = !run_callbacks(:exclusion_check) { :not_excluded }
|
120
|
+
end
|
121
|
+
|
122
|
+
def experiment_group?
|
123
|
+
instance_exec(@variant_name, &Configuration.inclusion_resolver)
|
160
124
|
end
|
161
125
|
|
162
126
|
def should_track?
|
163
|
-
enabled? && !excluded?
|
127
|
+
enabled? && @context.trackable? && !excluded?
|
128
|
+
end
|
129
|
+
|
130
|
+
def signature
|
131
|
+
{ variant: variant.name, experiment: name }.merge(context.signature)
|
164
132
|
end
|
165
133
|
|
166
134
|
def key_for(hash)
|
@@ -170,22 +138,13 @@ module Gitlab
|
|
170
138
|
protected
|
171
139
|
|
172
140
|
def segmentation_callback_chain
|
173
|
-
return :segmentation_check if
|
141
|
+
return :segmentation_check if @variant_name.nil? && enabled? && !excluded?
|
174
142
|
|
175
143
|
:unsegmented
|
176
144
|
end
|
177
145
|
|
178
|
-
def variant_assigned?
|
179
|
-
!@variant_name.nil?
|
180
|
-
end
|
181
|
-
|
182
146
|
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)
|
147
|
+
rollout.new(self).execute if experiment_group?
|
189
148
|
end
|
190
149
|
end
|
191
150
|
end
|
@@ -0,0 +1,88 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Gitlab
|
4
|
+
class Experiment
|
5
|
+
module BaseInterface
|
6
|
+
extend ActiveSupport::Concern
|
7
|
+
include Scientist::Experiment
|
8
|
+
|
9
|
+
class_methods do
|
10
|
+
def configure
|
11
|
+
yield Configuration
|
12
|
+
end
|
13
|
+
|
14
|
+
def experiment_name(name = nil, suffix: true, suffix_word: 'experiment')
|
15
|
+
name = (name.presence || self.name).to_s.underscore.sub(%r{(?<char>[_/]|)#{suffix_word}$}, '')
|
16
|
+
name = "#{name}#{Regexp.last_match(:char) || '_'}#{suffix_word}"
|
17
|
+
suffix ? name : name.sub(/_#{suffix_word}$/, '')
|
18
|
+
end
|
19
|
+
|
20
|
+
def base?
|
21
|
+
self == Gitlab::Experiment || name == Configuration.base_class
|
22
|
+
end
|
23
|
+
|
24
|
+
def constantize(name = nil)
|
25
|
+
return self if name.nil?
|
26
|
+
|
27
|
+
experiment_name(name).classify.safe_constantize || Configuration.base_class.constantize
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def initialize(name = nil, variant_name = nil, **context)
|
32
|
+
raise ArgumentError, 'name is required' if name.blank? && self.class.base?
|
33
|
+
|
34
|
+
@name = self.class.experiment_name(name, suffix: false)
|
35
|
+
@context = Context.new(self, **context)
|
36
|
+
@variant_name = cache_variant(variant_name) { nil } if variant_name.present?
|
37
|
+
|
38
|
+
compare { false }
|
39
|
+
|
40
|
+
yield self if block_given?
|
41
|
+
end
|
42
|
+
|
43
|
+
def inspect
|
44
|
+
"#<#{self.class.name || 'AnonymousClass'}:#{format('0x%016X', __id__)} @name=#{name} @context=#{context.value}>"
|
45
|
+
end
|
46
|
+
|
47
|
+
def id
|
48
|
+
"#{name}:#{key_for(context.value)}"
|
49
|
+
end
|
50
|
+
alias_method :session_id, :id
|
51
|
+
|
52
|
+
def flipper_id
|
53
|
+
"Experiment;#{id}"
|
54
|
+
end
|
55
|
+
|
56
|
+
def variant_names
|
57
|
+
@variant_names ||= behaviors.keys.map(&:to_sym) - [:control]
|
58
|
+
end
|
59
|
+
|
60
|
+
def behaviors
|
61
|
+
@behaviors ||= public_methods.each_with_object(super) do |name, behaviors|
|
62
|
+
next unless name.end_with?('_behavior')
|
63
|
+
|
64
|
+
behavior_name = name.to_s.sub(/_behavior$/, '')
|
65
|
+
behaviors[behavior_name] ||= -> { send(name) } # rubocop:disable GitlabSecurity/PublicSend
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
protected
|
70
|
+
|
71
|
+
def raise_on_mismatches?
|
72
|
+
false
|
73
|
+
end
|
74
|
+
|
75
|
+
def cached_variant_resolver(provided_variant)
|
76
|
+
return :control if excluded?
|
77
|
+
|
78
|
+
result = cache_variant(provided_variant) { resolve_variant_name }
|
79
|
+
result.to_sym if result.present?
|
80
|
+
end
|
81
|
+
|
82
|
+
def generate_result(variant_name)
|
83
|
+
observation = Scientist::Observation.new(variant_name, self, &behaviors[variant_name])
|
84
|
+
Scientist::Result.new(self, [observation], observation)
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
@@ -1,5 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require 'active_support/callbacks'
|
4
|
+
|
3
5
|
module Gitlab
|
4
6
|
class Experiment
|
5
7
|
module Callbacks
|
@@ -13,28 +15,17 @@ module Gitlab
|
|
13
15
|
end
|
14
16
|
|
15
17
|
class_methods do
|
16
|
-
|
17
|
-
filters = filter_list.unshift(block).compact.map do |filter|
|
18
|
-
result_lambda = ActiveSupport::Callbacks::CallTemplate.build(filter, self).make_lambda
|
19
|
-
lambda do |target|
|
20
|
-
throw(:abort) if target.instance_variable_get(:'@excluded') || result_lambda.call(target, nil) == true
|
21
|
-
end
|
22
|
-
end
|
23
|
-
|
24
|
-
raise ArgumentError, 'no filters provided' if filters.empty?
|
25
|
-
|
26
|
-
set_callback(:exclusion_check, :before, *filters, options)
|
27
|
-
end
|
18
|
+
private
|
28
19
|
|
29
|
-
def
|
30
|
-
filters =
|
20
|
+
def build_callback(chain, filters, **options)
|
21
|
+
filters = filters.compact.map do |filter|
|
31
22
|
result_lambda = ActiveSupport::Callbacks::CallTemplate.build(filter, self).make_lambda
|
32
|
-
->(target) {
|
23
|
+
->(target) { yield(target, result_lambda) }
|
33
24
|
end
|
34
25
|
|
35
26
|
raise ArgumentError, 'no filters provided' if filters.empty?
|
36
27
|
|
37
|
-
set_callback(
|
28
|
+
set_callback(chain, *filters, **options)
|
38
29
|
end
|
39
30
|
end
|
40
31
|
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
|
@@ -3,39 +3,78 @@
|
|
3
3
|
module Gitlab
|
4
4
|
class Experiment
|
5
5
|
module RSpecHelpers
|
6
|
-
def stub_experiments(experiments)
|
7
|
-
experiments.each
|
8
|
-
|
9
|
-
raise ArgumentError, 'variant must be a symbol or false' unless variant.is_a?(Symbol)
|
6
|
+
def stub_experiments(experiments, times = nil)
|
7
|
+
experiments.each { |experiment| wrapped_experiment(experiment, times) }
|
8
|
+
end
|
10
9
|
|
11
|
-
|
10
|
+
def wrapped_experiment(experiment, times = nil, expected = false, &block)
|
11
|
+
klass, experiment_name, variant_name = *experiment_details(experiment)
|
12
|
+
base_klass = Configuration.base_class.constantize
|
12
13
|
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
14
|
+
# Set expectations on experiment classes so we can and_wrap_original with more specific args
|
15
|
+
experiment_klasses = base_klass.descendants.reject { |k| k == klass }
|
16
|
+
experiment_klasses.push(base_klass).each do |k|
|
17
|
+
allow(k).to receive(:new).and_call_original
|
17
18
|
end
|
18
|
-
end
|
19
19
|
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
20
|
+
receiver = receive(:new)
|
21
|
+
|
22
|
+
# Be specific for BaseClass calls
|
23
|
+
receiver = receiver.with(experiment_name, any_args) if experiment_name && klass == base_klass
|
24
|
+
|
25
|
+
receiver.exactly(times).times if times
|
26
|
+
|
27
|
+
# Set expectations on experiment class of interest
|
28
|
+
allow_or_expect_klass = expected ? expect(klass) : allow(klass)
|
29
|
+
allow_or_expect_klass.to receiver.and_wrap_original do |method, *original_args, &original_block|
|
30
|
+
method.call(*original_args).tap do |e|
|
31
|
+
# Stub internal methods before calling the original_block
|
32
|
+
allow(e).to receive(:enabled?).and_return(true)
|
33
|
+
|
34
|
+
if variant_name == true # passing true allows the rollout to do its job
|
35
|
+
allow(e).to receive(:experiment_group?).and_return(true)
|
36
|
+
else
|
37
|
+
allow(e).to receive(:resolve_variant_name).and_return(variant_name.to_s)
|
38
|
+
end
|
39
|
+
|
40
|
+
# Stub/set expectations before calling the original_block
|
41
|
+
yield e if block
|
42
|
+
|
43
|
+
original_block.call(e) if original_block.present?
|
44
|
+
end
|
24
45
|
end
|
46
|
+
end
|
25
47
|
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
48
|
+
private
|
49
|
+
|
50
|
+
def experiment_details(experiment)
|
51
|
+
if experiment.is_a?(Symbol)
|
52
|
+
experiment_name = experiment
|
53
|
+
variant_name = nil
|
31
54
|
end
|
32
55
|
|
33
|
-
|
34
|
-
|
35
|
-
|
56
|
+
experiment_name, variant_name = *experiment if experiment.is_a?(Array)
|
57
|
+
|
58
|
+
base_klass = Configuration.base_class.constantize
|
59
|
+
variant_name = experiment.variant.name if experiment.is_a?(base_klass)
|
60
|
+
|
61
|
+
if experiment.class.name.nil? # Anonymous class instance
|
62
|
+
klass = experiment.class
|
63
|
+
elsif experiment.instance_of?(Class) # Class level stubbing, eg. "MyExperiment"
|
64
|
+
klass = experiment
|
36
65
|
else
|
37
|
-
|
66
|
+
experiment_name ||= experiment.instance_variable_get(:@name)
|
67
|
+
klass = base_klass.constantize(experiment_name)
|
68
|
+
end
|
69
|
+
|
70
|
+
if experiment_name && klass == base_klass
|
71
|
+
experiment_name = experiment_name.to_sym
|
72
|
+
|
73
|
+
# For experiment names like: "group/experiment-name"
|
74
|
+
experiment_name = experiment_name.to_s if experiment_name.inspect.include?('"')
|
38
75
|
end
|
76
|
+
|
77
|
+
[klass, experiment_name, variant_name]
|
39
78
|
end
|
40
79
|
end
|
41
80
|
|
@@ -130,33 +169,42 @@ module Gitlab
|
|
130
169
|
end
|
131
170
|
|
132
171
|
chain(:with_context) { |expected_context| @expected_context = expected_context }
|
133
|
-
|
172
|
+
|
173
|
+
chain(:on_next_instance) { @on_next_instance = true }
|
134
174
|
|
135
175
|
def expect_tracking_on(experiment, negated, event, *event_args)
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
176
|
+
klass = experiment.instance_of?(Class) ? experiment : experiment.class
|
177
|
+
unless klass <= Gitlab::Experiment
|
178
|
+
raise(
|
179
|
+
ArgumentError,
|
180
|
+
"track matcher is limited to experiment instances and classes"
|
181
|
+
)
|
182
|
+
end
|
183
|
+
|
184
|
+
expectations = proc do |e|
|
185
|
+
@experiment = e
|
186
|
+
allow(e).to receive(:track).and_call_original
|
141
187
|
|
142
188
|
if negated
|
143
|
-
expect(
|
189
|
+
expect(e).not_to receive(:track).with(*[event, *event_args])
|
144
190
|
else
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
end
|
191
|
+
if @expected_variant
|
192
|
+
expect(@experiment.variant.name).to eq(@expected_variant), failure_message(:variant, event)
|
193
|
+
end
|
149
194
|
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
expect(@experiment.variant.name).to eq(@expected_variant), failure_message(:variant, event)
|
154
|
-
end
|
195
|
+
if @expected_context
|
196
|
+
expect(@experiment.context.value).to include(@expected_context), failure_message(:context, event)
|
197
|
+
end
|
155
198
|
|
156
|
-
|
157
|
-
expect(@experiment.context.value).to include(@expected_context), failure_message(:context, event)
|
199
|
+
expect(e).to receive(:track).with(*[event, *event_args]).and_call_original
|
158
200
|
end
|
159
201
|
end
|
202
|
+
|
203
|
+
if experiment.instance_of?(Class) || @on_next_instance
|
204
|
+
wrapped_experiment(experiment, nil, true) { |e| expectations.call(e) }
|
205
|
+
else
|
206
|
+
expectations.call(experiment)
|
207
|
+
end
|
160
208
|
end
|
161
209
|
|
162
210
|
def failure_message(failure_type, event)
|
@@ -173,8 +221,6 @@ module Gitlab
|
|
173
221
|
expected context: #{@expected_context}
|
174
222
|
actual context: #{@experiment.context.value}
|
175
223
|
MESSAGE
|
176
|
-
when :no_new
|
177
|
-
%(expected #{@experiment.inspect} to have tracked #{event.inspect}, but no new instances were created)
|
178
224
|
end
|
179
225
|
end
|
180
226
|
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.3
|
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-04-06 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activesupport
|
@@ -30,20 +30,20 @@ dependencies:
|
|
30
30
|
requirements:
|
31
31
|
- - "~>"
|
32
32
|
- !ruby/object:Gem::Version
|
33
|
-
version: '1.
|
33
|
+
version: '1.6'
|
34
34
|
- - ">="
|
35
35
|
- !ruby/object:Gem::Version
|
36
|
-
version: 1.
|
36
|
+
version: 1.6.0
|
37
37
|
type: :runtime
|
38
38
|
prerelease: false
|
39
39
|
version_requirements: !ruby/object:Gem::Requirement
|
40
40
|
requirements:
|
41
41
|
- - "~>"
|
42
42
|
- !ruby/object:Gem::Version
|
43
|
-
version: '1.
|
43
|
+
version: '1.6'
|
44
44
|
- - ">="
|
45
45
|
- !ruby/object:Gem::Version
|
46
|
-
version: 1.
|
46
|
+
version: 1.6.0
|
47
47
|
description:
|
48
48
|
email:
|
49
49
|
- gitlab_rubygems@gitlab.com
|
@@ -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
|