gitlab-experiment 0.6.5 → 0.7.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 +410 -296
- data/lib/generators/gitlab/experiment/experiment_generator.rb +9 -4
- data/lib/generators/gitlab/experiment/install/templates/initializer.rb.tt +89 -45
- data/lib/generators/gitlab/experiment/templates/experiment.rb.tt +69 -3
- data/lib/gitlab/experiment/base_interface.rb +86 -24
- data/lib/gitlab/experiment/cache/redis_hash_store.rb +10 -10
- data/lib/gitlab/experiment/cache.rb +1 -3
- data/lib/gitlab/experiment/callbacks.rb +97 -6
- data/lib/gitlab/experiment/configuration.rb +196 -28
- data/lib/gitlab/experiment/context.rb +0 -2
- data/lib/gitlab/experiment/cookies.rb +0 -2
- data/lib/gitlab/experiment/engine.rb +2 -1
- data/lib/gitlab/experiment/errors.rb +21 -1
- data/lib/gitlab/experiment/nestable.rb +2 -2
- data/lib/gitlab/experiment/rollout/percent.rb +40 -17
- data/lib/gitlab/experiment/rollout/random.rb +25 -4
- data/lib/gitlab/experiment/rollout/round_robin.rb +27 -10
- data/lib/gitlab/experiment/rollout.rb +44 -8
- data/lib/gitlab/experiment/rspec.rb +208 -127
- data/lib/gitlab/experiment/version.rb +1 -1
- data/lib/gitlab/experiment.rb +112 -57
- metadata +35 -55
@@ -4,86 +4,231 @@ require 'singleton'
|
|
4
4
|
require 'logger'
|
5
5
|
require 'digest'
|
6
6
|
|
7
|
-
require 'active_support/deprecation'
|
8
|
-
|
9
7
|
module Gitlab
|
10
8
|
class Experiment
|
11
9
|
class Configuration
|
12
10
|
include Singleton
|
13
11
|
|
14
|
-
# Prefix all experiment names with a given value.
|
12
|
+
# Prefix all experiment names with a given string value.
|
13
|
+
# Use `nil` for no prefix.
|
15
14
|
@name_prefix = nil
|
16
15
|
|
17
|
-
# The logger
|
16
|
+
# The logger can be used to log various details of the experiments.
|
18
17
|
@logger = Logger.new($stdout)
|
19
18
|
|
20
19
|
# The base class that should be instantiated for basic experiments.
|
20
|
+
# It should be a string, so we can constantize it later.
|
21
21
|
@base_class = 'Gitlab::Experiment'
|
22
22
|
|
23
|
-
#
|
23
|
+
# Require experiments to be defined in a class, with variants registered.
|
24
|
+
# This will disallow any anonymous experiments that are run inline
|
25
|
+
# without previously defining a class.
|
26
|
+
@strict_registration = false
|
27
|
+
|
28
|
+
# The caching layer is expected to match the Rails.cache interface.
|
29
|
+
# If no cache is provided some rollout strategies may behave differently.
|
30
|
+
# Use `nil` for no caching.
|
24
31
|
@cache = nil
|
25
32
|
|
26
33
|
# The domain to use on cookies.
|
34
|
+
#
|
35
|
+
# When not set, it uses the current host. If you want to provide specific
|
36
|
+
# hosts, you use `:all`, or provide an array.
|
37
|
+
#
|
38
|
+
# Examples:
|
39
|
+
# nil, :all, or ['www.gitlab.com', '.gitlab.com']
|
27
40
|
@cookie_domain = :all
|
28
41
|
|
29
|
-
# The default rollout strategy
|
30
|
-
#
|
31
|
-
#
|
32
|
-
|
42
|
+
# The default rollout strategy.
|
43
|
+
#
|
44
|
+
# The recommended default rollout strategy when not using caching would
|
45
|
+
# be `Gitlab::Experiment::Rollout::Percent` as that will consistently
|
46
|
+
# assign the same variant with or without caching.
|
47
|
+
#
|
48
|
+
# Gitlab::Experiment::Rollout::Base can be inherited to implement your
|
49
|
+
# own rollout strategies.
|
50
|
+
#
|
51
|
+
# Each experiment can specify its own rollout strategy:
|
52
|
+
#
|
53
|
+
# class ExampleExperiment < ApplicationExperiment
|
54
|
+
# default_rollout :random, # :percent, :round_robin,
|
55
|
+
# include_control: true # or MyCustomRollout
|
56
|
+
# end
|
57
|
+
#
|
58
|
+
# Included rollout strategies:
|
59
|
+
# Gitlab::Experiment::Rollout::Percent, (recommended)
|
60
|
+
# Gitlab::Experiment::Rollout::RoundRobin, or
|
61
|
+
# Gitlab::Experiment::Rollout::Random
|
62
|
+
@default_rollout = Gitlab::Experiment::Rollout::Percent.new(
|
63
|
+
include_control: true # include control in possible assignments
|
64
|
+
)
|
33
65
|
|
34
66
|
# Secret seed used in generating context keys.
|
67
|
+
#
|
68
|
+
# You'll typically want to use an environment variable or secret value
|
69
|
+
# for this.
|
70
|
+
#
|
71
|
+
# Consider not using one that's shared with other systems, like Rails'
|
72
|
+
# SECRET_KEY_BASE for instance. Generate a new secret and utilize that
|
73
|
+
# instead.
|
35
74
|
@context_key_secret = nil
|
36
75
|
|
37
|
-
# Bit length used by SHA2 in generating context keys
|
76
|
+
# Bit length used by SHA2 in generating context keys.
|
77
|
+
#
|
78
|
+
# Using a higher bit length would require more computation time.
|
79
|
+
#
|
80
|
+
# Valid bit lengths:
|
81
|
+
# 256, 384, or 512
|
38
82
|
@context_key_bit_length = 256
|
39
83
|
|
40
84
|
# The default base path that the middleware (or rails engine) will be
|
41
|
-
# mounted.
|
85
|
+
# mounted. The middleware enables an instrumentation url, that's similar
|
86
|
+
# to links that can be instrumented in email campaigns.
|
87
|
+
#
|
88
|
+
# Use `nil` if you don't want to mount the middleware.
|
89
|
+
#
|
90
|
+
# Examples:
|
91
|
+
# '/-/experiment', '/redirect', nil
|
42
92
|
@mount_at = nil
|
43
93
|
|
44
|
-
#
|
45
|
-
#
|
46
|
-
|
47
|
-
|
48
|
-
#
|
94
|
+
# When using the middleware, links can be instrumented and redirected
|
95
|
+
# elsewhere. This can be exploited to make a harmful url look innocuous
|
96
|
+
# or that it's a valid url on your domain. To avoid this, you can provide
|
97
|
+
# your own logic for what urls will be considered valid and redirected
|
98
|
+
# to.
|
99
|
+
#
|
49
100
|
# Expected to return a boolean value.
|
50
|
-
@
|
101
|
+
@redirect_url_validator = lambda do |_redirect_url|
|
102
|
+
true
|
103
|
+
end
|
51
104
|
|
52
105
|
# Tracking behavior can be implemented to link an event to an experiment.
|
106
|
+
#
|
107
|
+
# This block is executed within the scope of the experiment and so can
|
108
|
+
# access experiment methods, like `name`, `context`, and `signature`.
|
53
109
|
@tracking_behavior = lambda do |event, args|
|
110
|
+
# An example of using a generic logger to track events:
|
54
111
|
Configuration.logger.info("#{self.class.name}[#{name}] #{event}: #{args.merge(signature: signature)}")
|
112
|
+
|
113
|
+
# Using something like snowplow to track events (in gitlab):
|
114
|
+
#
|
115
|
+
# Gitlab::Tracking.event(name, event, **args.merge(
|
116
|
+
# context: (args[:context] || []) << SnowplowTracker::SelfDescribingJson.new(
|
117
|
+
# 'iglu:com.gitlab/gitlab_experiment/jsonschema/0-2-0', signature
|
118
|
+
# )
|
119
|
+
# ))
|
120
|
+
end
|
121
|
+
|
122
|
+
# Logic designed to respond when a given experiment is nested within
|
123
|
+
# another experiment. This can be useful to identify overlaps and when a
|
124
|
+
# code path leads to an experiment being nested within another.
|
125
|
+
#
|
126
|
+
# Reporting complexity can arise when one experiment changes rollout, and
|
127
|
+
# a downstream experiment is impacted by that.
|
128
|
+
#
|
129
|
+
# The base_class or a custom experiment can provide a `nest_experiment`
|
130
|
+
# method that implements its own logic that may allow certain experiments
|
131
|
+
# to be nested within it.
|
132
|
+
#
|
133
|
+
# This block is executed within the scope of the experiment and so can
|
134
|
+
# access experiment methods, like `name`, `context`, and `signature`.
|
135
|
+
#
|
136
|
+
# The default exception will include the where the experiment calls were
|
137
|
+
# initiated on, so for instance:
|
138
|
+
#
|
139
|
+
# Gitlab::Experiment::NestingError: unable to nest level2 within level1:
|
140
|
+
# level1 initiated by file_name.rb:2
|
141
|
+
# level2 initiated by file_name.rb:3
|
142
|
+
@nested_behavior = lambda do |nested_experiment|
|
143
|
+
raise NestingError.new(experiment: self, nested_experiment: nested_experiment)
|
55
144
|
end
|
56
145
|
|
57
146
|
# Called at the end of every experiment run, with the result.
|
147
|
+
#
|
148
|
+
# You may want to track that you've assigned a variant to a given
|
149
|
+
# context, or push the experiment into the client or publish results
|
150
|
+
# elsewhere like into redis.
|
151
|
+
#
|
152
|
+
# This block is executed within the scope of the experiment and so can
|
153
|
+
# access experiment methods, like `name`, `context`, and `signature`.
|
58
154
|
@publishing_behavior = lambda do |_result|
|
155
|
+
# Track the event using our own configured tracking logic.
|
59
156
|
track(:assignment)
|
157
|
+
|
158
|
+
# Log using our logging system, so the result (which can be large) can
|
159
|
+
# be reviewed later if we want to.
|
160
|
+
#
|
161
|
+
# Lograge::Event.log(experiment: name, result: result, signature: signature)
|
162
|
+
|
163
|
+
# Experiments that have been run during the request lifecycle can be
|
164
|
+
# pushed to the client layer by injecting the published experiments
|
165
|
+
# into javascript in a layout or view using something like:
|
166
|
+
#
|
167
|
+
# = javascript_tag(nonce: content_security_policy_nonce) do
|
168
|
+
# window.experiments = #{raw Gitlab::Experiment.published_experiments.to_json};
|
60
169
|
end
|
61
170
|
|
62
171
|
class << self
|
63
|
-
#
|
172
|
+
# @deprecated
|
64
173
|
def context_hash_strategy=(block)
|
65
|
-
|
66
|
-
|
174
|
+
deprecated(
|
175
|
+
:context_hash_strategy,
|
176
|
+
'instead use `context_key_secret` and `context_key_bit_length`',
|
177
|
+
version: '0.7.0'
|
178
|
+
)
|
179
|
+
|
67
180
|
@__context_hash_strategy = block
|
68
181
|
end
|
69
182
|
|
70
|
-
#
|
183
|
+
# @deprecated
|
71
184
|
def variant_resolver
|
72
|
-
|
73
|
-
|
74
|
-
|
185
|
+
deprecated(
|
186
|
+
:variant_resolver,
|
187
|
+
'instead use `inclusion_resolver` with a block that returns a boolean',
|
188
|
+
version: '0.6.5'
|
189
|
+
)
|
190
|
+
|
191
|
+
@__inclusion_resolver
|
75
192
|
end
|
76
193
|
|
194
|
+
# @deprecated
|
77
195
|
def variant_resolver=(block)
|
78
|
-
|
79
|
-
|
80
|
-
|
196
|
+
deprecated(
|
197
|
+
:variant_resolver,
|
198
|
+
'instead use `inclusion_resolver` with a block that returns a boolean',
|
199
|
+
version: '0.6.5'
|
200
|
+
)
|
201
|
+
|
202
|
+
@__inclusion_resolver = block
|
203
|
+
end
|
204
|
+
|
205
|
+
# @deprecated
|
206
|
+
def inclusion_resolver=(block)
|
207
|
+
deprecated(
|
208
|
+
:inclusion_resolver,
|
209
|
+
'instead put this logic into custom rollout strategies',
|
210
|
+
version: '0.7.0'
|
211
|
+
)
|
212
|
+
|
213
|
+
@__inclusion_resolver = block
|
214
|
+
end
|
215
|
+
|
216
|
+
# @deprecated
|
217
|
+
def inclusion_resolver
|
218
|
+
deprecated(
|
219
|
+
:inclusion_resolver,
|
220
|
+
'instead put this logic into custom rollout strategies',
|
221
|
+
version: '0.7.0'
|
222
|
+
)
|
223
|
+
|
224
|
+
@__inclusion_resolver
|
81
225
|
end
|
82
226
|
|
83
227
|
attr_accessor(
|
84
228
|
:name_prefix,
|
85
229
|
:logger,
|
86
230
|
:base_class,
|
231
|
+
:strict_registration,
|
87
232
|
:cache,
|
88
233
|
:cookie_domain,
|
89
234
|
:context_key_secret,
|
@@ -91,10 +236,33 @@ module Gitlab
|
|
91
236
|
:mount_at,
|
92
237
|
:default_rollout,
|
93
238
|
:redirect_url_validator,
|
94
|
-
:inclusion_resolver,
|
95
239
|
:tracking_behavior,
|
240
|
+
:nested_behavior,
|
96
241
|
:publishing_behavior
|
97
242
|
)
|
243
|
+
|
244
|
+
# Internal helpers warnings.
|
245
|
+
def deprecated(*args, version:, stack: 0)
|
246
|
+
deprecator = deprecator(version)
|
247
|
+
args << args.pop.to_s.gsub('{{release}}', "#{deprecator.gem_name} #{deprecator.deprecation_horizon}")
|
248
|
+
args << caller_locations(4 + stack)
|
249
|
+
|
250
|
+
if args.length == 2
|
251
|
+
deprecator.warn(*args)
|
252
|
+
else
|
253
|
+
args[0] = "`#{args[0]}`"
|
254
|
+
deprecator.deprecation_warning(*args)
|
255
|
+
end
|
256
|
+
end
|
257
|
+
|
258
|
+
private
|
259
|
+
|
260
|
+
def deprecator(version = VERSION)
|
261
|
+
version = Gem::Version.new(version).bump.to_s
|
262
|
+
|
263
|
+
@__dep_versions ||= {}
|
264
|
+
@__dep_versions[version] ||= ActiveSupport::Deprecation.new(version, 'Gitlab::Experiment')
|
265
|
+
end
|
98
266
|
end
|
99
267
|
end
|
100
268
|
end
|
@@ -6,7 +6,8 @@ module Gitlab
|
|
6
6
|
class Experiment
|
7
7
|
include ActiveModel::Model
|
8
8
|
|
9
|
-
#
|
9
|
+
# Used for generating routes. We've included the method and `ActiveModel::Model` here because these things don't
|
10
|
+
# make sense outside of Rails environments.
|
10
11
|
def self.model_name
|
11
12
|
ActiveModel::Name.new(self, Gitlab)
|
12
13
|
end
|
@@ -4,6 +4,26 @@ module Gitlab
|
|
4
4
|
class Experiment
|
5
5
|
Error = Class.new(StandardError)
|
6
6
|
InvalidRolloutRules = Class.new(Error)
|
7
|
-
|
7
|
+
UnregisteredExperiment = Class.new(Error)
|
8
|
+
ExistingBehaviorError = Class.new(Error)
|
9
|
+
BehaviorMissingError = Class.new(Error)
|
10
|
+
|
11
|
+
class NestingError < Error
|
12
|
+
def initialize(experiment:, nested_experiment:)
|
13
|
+
messages = []
|
14
|
+
experiments = [nested_experiment, experiment]
|
15
|
+
|
16
|
+
callers = caller_locations
|
17
|
+
callers.select.with_index do |caller, index|
|
18
|
+
next if caller.label != 'experiment'
|
19
|
+
|
20
|
+
messages << " #{experiments[messages.length].name} initiated by #{callers[index + 1]}"
|
21
|
+
end
|
22
|
+
|
23
|
+
messages << ["unable to nest #{nested_experiment.name} within #{experiment.name}:"]
|
24
|
+
|
25
|
+
super(messages.reverse.join("\n"))
|
26
|
+
end
|
27
|
+
end
|
8
28
|
end
|
9
29
|
end
|
@@ -9,8 +9,8 @@ module Gitlab
|
|
9
9
|
set_callback :run, :around, :manage_nested_stack
|
10
10
|
end
|
11
11
|
|
12
|
-
def nest_experiment(
|
13
|
-
|
12
|
+
def nest_experiment(nested_experiment)
|
13
|
+
instance_exec(nested_experiment, &Configuration.nested_behavior)
|
14
14
|
end
|
15
15
|
|
16
16
|
private
|
@@ -1,30 +1,39 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
|
4
|
-
|
3
|
+
# The percent rollout strategy is the most comprehensive included with Gitlab::Experiment. It allows specifying the
|
4
|
+
# percentages per variant using an array, a hash, or will default to even distribution when no rules are provided.
|
5
|
+
#
|
6
|
+
# A given experiment id (context key) will always be given the same variant assignment.
|
7
|
+
#
|
8
|
+
# Example configuration usage:
|
9
|
+
#
|
10
|
+
# config.default_rollout = Gitlab::Experiment::Rollout::Percent.new
|
11
|
+
#
|
12
|
+
# Example class usage:
|
13
|
+
#
|
14
|
+
# class PillColorExperiment < ApplicationExperiment
|
15
|
+
# control { }
|
16
|
+
# variant(:red) { }
|
17
|
+
# variant(:blue) { }
|
18
|
+
#
|
19
|
+
# # Even distribution between all behaviors.
|
20
|
+
# default_rollout :percent
|
21
|
+
#
|
22
|
+
# # With specific distribution percentages.
|
23
|
+
# default_rollout :percent, distribution: { control: 25, red: 30, blue: 45 }
|
24
|
+
# end
|
25
|
+
#
|
5
26
|
module Gitlab
|
6
27
|
class Experiment
|
7
28
|
module Rollout
|
8
29
|
class Percent < Base
|
9
|
-
|
10
|
-
crc = normalized_id
|
11
|
-
total = 0
|
12
|
-
|
13
|
-
case distribution_rules
|
14
|
-
# run through the rules until finding an acceptable one
|
15
|
-
when Array then variant_names[distribution_rules.find_index { |percent| crc % 100 <= total += percent }]
|
16
|
-
# run through the variant names until finding an acceptable one
|
17
|
-
when Hash then distribution_rules.find { |_, percent| crc % 100 <= total += percent }.first
|
18
|
-
# when there are no rules, assume even distribution
|
19
|
-
else variant_names[crc % variant_names.length]
|
20
|
-
end
|
21
|
-
end
|
30
|
+
protected
|
22
31
|
|
23
32
|
def validate!
|
24
33
|
case distribution_rules
|
25
34
|
when nil then nil
|
26
35
|
when Array, Hash
|
27
|
-
if distribution_rules.length !=
|
36
|
+
if distribution_rules.length != behavior_names.length
|
28
37
|
raise InvalidRolloutRules, "the distribution rules don't match the number of variants defined"
|
29
38
|
end
|
30
39
|
else
|
@@ -32,6 +41,20 @@ module Gitlab
|
|
32
41
|
end
|
33
42
|
end
|
34
43
|
|
44
|
+
def execute_assigment
|
45
|
+
crc = normalized_id
|
46
|
+
total = 0
|
47
|
+
|
48
|
+
case distribution_rules
|
49
|
+
when Array # run through the rules until finding an acceptable one
|
50
|
+
behavior_names[distribution_rules.find_index { |percent| crc % 100 <= total += percent }]
|
51
|
+
when Hash # run through the variant names until finding an acceptable one
|
52
|
+
distribution_rules.find { |_, percent| crc % 100 <= total += percent }.first
|
53
|
+
else # assume even distribution on no rules
|
54
|
+
behavior_names.empty? ? nil : behavior_names[crc % behavior_names.length]
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
35
58
|
private
|
36
59
|
|
37
60
|
def normalized_id
|
@@ -39,7 +62,7 @@ module Gitlab
|
|
39
62
|
end
|
40
63
|
|
41
64
|
def distribution_rules
|
42
|
-
|
65
|
+
options[:distribution]
|
43
66
|
end
|
44
67
|
end
|
45
68
|
end
|
@@ -1,13 +1,34 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
# The random rollout strategy will randomly assign a variant when the context is determined to be within the experiment
|
4
|
+
# group.
|
5
|
+
#
|
6
|
+
# If caching is enabled this is a predicable and consistent assignment that will eventually assign a variant (since
|
7
|
+
# control isn't cached) but if caching isn't enabled, assignment will be random each time.
|
8
|
+
#
|
9
|
+
# Example configuration usage:
|
10
|
+
#
|
11
|
+
# config.default_rollout = Gitlab::Experiment::Rollout::Random.new
|
12
|
+
#
|
13
|
+
# Example class usage:
|
14
|
+
#
|
15
|
+
# class PillColorExperiment < ApplicationExperiment
|
16
|
+
# control { }
|
17
|
+
# variant(:red) { }
|
18
|
+
# variant(:blue) { }
|
19
|
+
#
|
20
|
+
# # Randomize between all behaviors, with a mostly even distribution).
|
21
|
+
# default_rollout :random
|
22
|
+
# end
|
23
|
+
#
|
3
24
|
module Gitlab
|
4
25
|
class Experiment
|
5
26
|
module Rollout
|
6
27
|
class Random < Base
|
7
|
-
|
8
|
-
|
9
|
-
def
|
10
|
-
|
28
|
+
protected
|
29
|
+
|
30
|
+
def execute_assigment
|
31
|
+
behavior_names.sample # pick a random variant
|
11
32
|
end
|
12
33
|
end
|
13
34
|
end
|
@@ -1,21 +1,38 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
# The round robin strategy will assign the next variant in the list, looping back to the first variant after all
|
4
|
+
# variants have been assigned. This is useful for very small sample sizes where very even distribution can be required.
|
5
|
+
#
|
6
|
+
# Requires a cache to be configured.
|
7
|
+
#
|
8
|
+
# Keeps track of the number of assignments into the experiment group, and uses this to rotate "round robin" style
|
9
|
+
# through the variants that are defined.
|
10
|
+
#
|
11
|
+
# Example configuration usage:
|
12
|
+
#
|
13
|
+
# config.default_rollout = Gitlab::Experiment::Rollout::RoundRobin.new
|
14
|
+
#
|
15
|
+
# Example class usage:
|
16
|
+
#
|
17
|
+
# class PillColorExperiment < ApplicationExperiment
|
18
|
+
# control { }
|
19
|
+
# variant(:red) { }
|
20
|
+
# variant(:blue) { }
|
21
|
+
#
|
22
|
+
# # Rotate evenly between all behaviors.
|
23
|
+
# default_rollout :round_robin
|
24
|
+
# end
|
25
|
+
#
|
3
26
|
module Gitlab
|
4
27
|
class Experiment
|
5
28
|
module Rollout
|
6
29
|
class RoundRobin < Base
|
7
30
|
KEY_NAME = :last_round_robin_variant
|
8
31
|
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
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]
|
32
|
+
protected
|
33
|
+
|
34
|
+
def execute_assigment
|
35
|
+
behavior_names[(cache.attr_inc(KEY_NAME) - 1) % behavior_names.size]
|
19
36
|
end
|
20
37
|
end
|
21
38
|
end
|
@@ -14,27 +14,63 @@ module Gitlab
|
|
14
14
|
end
|
15
15
|
|
16
16
|
class Base
|
17
|
-
|
17
|
+
DEFAULT_OPTIONS = {
|
18
|
+
include_control: false
|
19
|
+
}.freeze
|
20
|
+
|
21
|
+
attr_reader :experiment, :options
|
18
22
|
|
19
23
|
delegate :variant_names, :cache, :id, to: :experiment
|
20
24
|
|
21
25
|
def initialize(options = {})
|
22
|
-
@options = options
|
23
|
-
# validate! # we want to validate here, but we can't yet
|
26
|
+
@options = DEFAULT_OPTIONS.merge(options)
|
24
27
|
end
|
25
28
|
|
26
|
-
def
|
29
|
+
def for(experiment)
|
30
|
+
raise ArgumentError, 'you must provide an experiment instance' unless experiment.class <= Gitlab::Experiment
|
31
|
+
|
27
32
|
@experiment = experiment
|
28
|
-
|
29
|
-
|
33
|
+
|
34
|
+
self
|
35
|
+
end
|
36
|
+
|
37
|
+
def enabled?
|
38
|
+
require_experiment(__method__)
|
39
|
+
|
40
|
+
true
|
41
|
+
end
|
42
|
+
|
43
|
+
def resolve
|
44
|
+
require_experiment(__method__)
|
45
|
+
|
46
|
+
return nil if @experiment.respond_to?(:experiment_group?) && !@experiment.experiment_group?
|
47
|
+
|
48
|
+
validate! # allow the rollout strategy to validate itself
|
49
|
+
|
50
|
+
assignment = execute_assigment
|
51
|
+
assignment == :control ? nil : assignment # avoid caching control
|
30
52
|
end
|
31
53
|
|
54
|
+
protected
|
55
|
+
|
32
56
|
def validate!
|
33
57
|
# base is always valid
|
34
58
|
end
|
35
59
|
|
36
|
-
def
|
37
|
-
|
60
|
+
def execute_assigment
|
61
|
+
behavior_names.first
|
62
|
+
end
|
63
|
+
|
64
|
+
private
|
65
|
+
|
66
|
+
def require_experiment(method_name)
|
67
|
+
return if @experiment.present?
|
68
|
+
|
69
|
+
raise ArgumentError, "you need to call `for` with an experiment instance before chaining `#{method_name}`"
|
70
|
+
end
|
71
|
+
|
72
|
+
def behavior_names
|
73
|
+
options[:include_control] ? [:control] + variant_names : variant_names
|
38
74
|
end
|
39
75
|
end
|
40
76
|
end
|