gitlab-experiment 0.4.10 → 0.5.2
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 +60 -100
- data/lib/gitlab/experiment/base_interface.rb +81 -0
- data/lib/gitlab/experiment/cache.rb +2 -0
- data/lib/gitlab/experiment/callbacks.rb +9 -18
- 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 +90 -44
- 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: d4b1d80362166d1e86dd434fffe2a8e31676440f48b578e4bfcca4624fc819a8
|
4
|
+
data.tar.gz: 4535076f1c77f3faefc92f59be23689a5412f462bda088b67a4fbbdb58c74254
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: d9d3445f7ee60be9d445760f103162861b1983c35ed6a9347e8703bc44c4bef2d7b154a62b2b05aa02c84471b8cfa95f91aa5521ee5743b9afa8ea5b4a4088eb
|
7
|
+
data.tar.gz: cb49261fe14971ff76799bac943e5826b04cad84982b8d061cbd7b2843a0817854772f2b3e8e55600023aff9b5266c0ba562df50c47bb90703cb686c4f973ebd
|
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,33 +69,36 @@ module Gitlab
|
|
79
69
|
end
|
80
70
|
|
81
71
|
def variant(value = nil)
|
82
|
-
if value.
|
83
|
-
|
84
|
-
end
|
85
|
-
|
86
|
-
@variant_name = value unless value.blank?
|
72
|
+
@variant_name = cache_variant(value) if value.present?
|
73
|
+
return Variant.new(name: (@variant_name || :unresolved).to_s) if @variant_name || @resolving_variant
|
87
74
|
|
88
75
|
if enabled?
|
89
|
-
@variant_name ||= :control if excluded?
|
90
|
-
|
91
76
|
@resolving_variant = true
|
92
|
-
|
93
|
-
|
94
|
-
|
77
|
+
@variant_name ||= :control if excluded?
|
78
|
+
result = cache_variant(@variant_name) { resolve_variant_name }
|
79
|
+
@variant_name = result.to_sym if result.present?
|
95
80
|
end
|
96
81
|
|
97
|
-
|
82
|
+
run_callbacks(segmentation_callback_chain) do
|
83
|
+
@variant_name ||= :control
|
84
|
+
Variant.new(name: @variant_name.to_s)
|
85
|
+
end
|
98
86
|
ensure
|
99
87
|
@resolving_variant = false
|
100
88
|
end
|
101
89
|
|
90
|
+
def rollout(rollout = nil)
|
91
|
+
return @rollout ||= self.class.default_rollout if rollout.blank?
|
92
|
+
|
93
|
+
@rollout = Rollout.resolve(rollout)
|
94
|
+
end
|
95
|
+
|
96
|
+
def exclude!
|
97
|
+
@excluded = true
|
98
|
+
end
|
99
|
+
|
102
100
|
def run(variant_name = nil)
|
103
|
-
@result ||=
|
104
|
-
variant_name = variant(variant_name).name
|
105
|
-
run_callbacks(run_with_segmenting? ? :segmented_run : :unsegmented_run) do
|
106
|
-
super(@variant_name ||= variant_name)
|
107
|
-
end
|
108
|
-
end
|
101
|
+
@result ||= super(variant(variant_name).name)
|
109
102
|
end
|
110
103
|
|
111
104
|
def publish(result)
|
@@ -118,75 +111,42 @@ module Gitlab
|
|
118
111
|
instance_exec(action, event_args, &Configuration.tracking_behavior)
|
119
112
|
end
|
120
113
|
|
121
|
-
def
|
122
|
-
|
114
|
+
def enabled?
|
115
|
+
true
|
123
116
|
end
|
124
117
|
|
125
|
-
def
|
126
|
-
@
|
127
|
-
end
|
118
|
+
def excluded?
|
119
|
+
return @excluded if defined?(@excluded)
|
128
120
|
|
129
|
-
|
130
|
-
|
131
|
-
next unless name.end_with?('_behavior')
|
121
|
+
@excluded = !run_callbacks(:exclusion_check) { :not_excluded }
|
122
|
+
end
|
132
123
|
|
133
|
-
|
134
|
-
|
135
|
-
end
|
124
|
+
def experiment_group?
|
125
|
+
instance_exec(@variant_name, &Configuration.inclusion_resolver)
|
136
126
|
end
|
137
127
|
|
138
|
-
def
|
139
|
-
|
140
|
-
behaviors[name] = block
|
128
|
+
def should_track?
|
129
|
+
enabled? && @context.trackable? && !excluded?
|
141
130
|
end
|
142
131
|
|
143
132
|
def signature
|
144
133
|
{ variant: variant.name, experiment: name }.merge(context.signature)
|
145
134
|
end
|
146
135
|
|
147
|
-
def id
|
148
|
-
"#{name}:#{key_for(context.value)}"
|
149
|
-
end
|
150
|
-
alias_method :session_id, :id
|
151
|
-
|
152
|
-
def flipper_id
|
153
|
-
"Experiment;#{id}"
|
154
|
-
end
|
155
|
-
|
156
|
-
def enabled?
|
157
|
-
true
|
158
|
-
end
|
159
|
-
|
160
|
-
def excluded?
|
161
|
-
@excluded ||= !@context.trackable? || # adhere to DNT headers
|
162
|
-
!run_callbacks(:exclusion_check) { :not_excluded } # didn't pass exclusion check
|
163
|
-
end
|
164
|
-
|
165
|
-
def should_track?
|
166
|
-
enabled? && !excluded?
|
167
|
-
end
|
168
|
-
|
169
136
|
def key_for(hash)
|
170
137
|
instance_exec(hash, &Configuration.context_hash_strategy)
|
171
138
|
end
|
172
139
|
|
173
140
|
protected
|
174
141
|
|
175
|
-
def
|
176
|
-
|
177
|
-
end
|
142
|
+
def segmentation_callback_chain
|
143
|
+
return :segmentation_check if @variant_name.nil? && enabled? && !excluded?
|
178
144
|
|
179
|
-
|
180
|
-
!@variant_name.nil?
|
145
|
+
:unsegmented
|
181
146
|
end
|
182
147
|
|
183
148
|
def resolve_variant_name
|
184
|
-
|
185
|
-
end
|
186
|
-
|
187
|
-
def generate_result(variant_name)
|
188
|
-
observation = Scientist::Observation.new(variant_name, self, &behaviors[variant_name])
|
189
|
-
Scientist::Result.new(self, [observation], observation)
|
149
|
+
rollout.new(self).execute if experiment_group?
|
190
150
|
end
|
191
151
|
end
|
192
152
|
end
|
@@ -0,0 +1,81 @@
|
|
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 generate_result(variant_name)
|
76
|
+
observation = Scientist::Observation.new(variant_name, self, &behaviors[variant_name])
|
77
|
+
Scientist::Result.new(self, [observation], observation)
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
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
|
@@ -7,34 +9,23 @@ module Gitlab
|
|
7
9
|
include ActiveSupport::Callbacks
|
8
10
|
|
9
11
|
included do
|
10
|
-
define_callbacks(:
|
11
|
-
define_callbacks(:
|
12
|
+
define_callbacks(:unsegmented)
|
13
|
+
define_callbacks(:segmentation_check)
|
12
14
|
define_callbacks(:exclusion_check, skip_after_callbacks_if_terminated: true)
|
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
|
|
@@ -86,7 +125,7 @@ module Gitlab
|
|
86
125
|
experiment.context(context)
|
87
126
|
|
88
127
|
experiment.instance_variable_set(ivar, nil)
|
89
|
-
experiment.run_callbacks(:
|
128
|
+
experiment.run_callbacks(:segmentation_check)
|
90
129
|
|
91
130
|
@actual = experiment.instance_variable_get(ivar)
|
92
131
|
@expected ? @actual.to_s == @expected.to_s : @actual.present?
|
@@ -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.2
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- GitLab
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2021-02
|
11
|
+
date: 2021-04-02 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
|