gitlab-experiment 0.6.5 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|
@@ -6,226 +6,293 @@ module Gitlab
|
|
|
6
6
|
autoload :Trackable, 'gitlab/experiment/test_behaviors/trackable.rb'
|
|
7
7
|
end
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
WrappedExperiment = Struct.new(:klass, :experiment_name, :variant_name, :expectation_chain, :blocks)
|
|
10
|
+
|
|
11
|
+
module RSpecMocks
|
|
12
|
+
@__gitlab_experiment_receivers = {}
|
|
13
|
+
|
|
14
|
+
def self.track_gitlab_experiment_receiver(method, receiver)
|
|
15
|
+
# Leverage the `>=` method on Gitlab::Experiment to determine if the receiver is an experiment, not the other
|
|
16
|
+
# way round -- `receiver.<=` could be mocked and we want to be extra careful.
|
|
17
|
+
(@__gitlab_experiment_receivers[method.to_s] ||= []) << receiver if Gitlab::Experiment >= receiver
|
|
18
|
+
rescue StandardError # again, let's just be extra careful
|
|
19
|
+
false
|
|
12
20
|
end
|
|
13
21
|
|
|
14
|
-
def
|
|
15
|
-
|
|
16
|
-
|
|
22
|
+
def self.bind_gitlab_experiment_receiver(method)
|
|
23
|
+
method.unbind.bind(@__gitlab_experiment_receivers[method.to_s].pop)
|
|
24
|
+
end
|
|
17
25
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
26
|
+
module MethodDouble
|
|
27
|
+
def proxy_method_invoked(receiver, *args, &block)
|
|
28
|
+
RSpecMocks.track_gitlab_experiment_receiver(original_method, receiver)
|
|
29
|
+
super
|
|
22
30
|
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
23
33
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
34
|
+
module RSpecHelpers
|
|
35
|
+
def stub_experiments(experiments)
|
|
36
|
+
experiments.each do |experiment|
|
|
37
|
+
wrapped_experiment(experiment, remock: true) do |instance, wrapped|
|
|
38
|
+
# Stub internal methods that will make it behave as we've instructed.
|
|
39
|
+
allow(instance).to receive(:enabled?) { wrapped.variant_name != false }
|
|
40
|
+
if instance.respond_to?(:experiment_group?, true)
|
|
41
|
+
allow(instance).to receive(:experiment_group?) { !(wrapped.variant_name == false) }
|
|
42
|
+
end
|
|
28
43
|
|
|
29
|
-
|
|
44
|
+
# Stub the variant resolution logic to handle true/false, and named variants.
|
|
45
|
+
allow(instance).to receive(:resolve_variant_name).and_wrap_original { |method|
|
|
46
|
+
# Call the original method if we specified simply `true`.
|
|
47
|
+
wrapped.variant_name == true ? method.call : wrapped.variant_name
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
end
|
|
30
51
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
allow_or_expect_klass.to receiver.and_wrap_original do |method, *original_args, &original_block|
|
|
34
|
-
method.call(*original_args).tap do |e|
|
|
35
|
-
# Stub internal methods before calling the original_block
|
|
36
|
-
allow(e).to receive(:enabled?).and_return(true)
|
|
52
|
+
wrapped_experiments
|
|
53
|
+
end
|
|
37
54
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
else
|
|
41
|
-
allow(e).to receive(:resolve_variant_name).and_return(variant_name.to_s)
|
|
42
|
-
end
|
|
55
|
+
def wrapped_experiment(experiment, remock: false, &block)
|
|
56
|
+
klass, experiment_name, variant_name = *extract_experiment_details(experiment)
|
|
43
57
|
|
|
44
|
-
|
|
45
|
-
|
|
58
|
+
wrapped_experiment = wrapped_experiments[experiment_name] =
|
|
59
|
+
(!remock && wrapped_experiments[experiment_name]) ||
|
|
60
|
+
WrappedExperiment.new(klass, experiment_name, variant_name, wrapped_experiment_chain_for(klass), [])
|
|
46
61
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
end
|
|
62
|
+
wrapped_experiment.blocks << block if block
|
|
63
|
+
wrapped_experiment
|
|
50
64
|
end
|
|
51
65
|
|
|
52
66
|
private
|
|
53
67
|
|
|
54
|
-
def
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
68
|
+
def wrapped_experiments
|
|
69
|
+
@__wrapped_experiments ||= defined?(HashWithIndifferentAccess) ? HashWithIndifferentAccess.new : {}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def wrapped_experiment_chain_for(klass)
|
|
73
|
+
@__wrapped_experiment_chains ||= {}
|
|
74
|
+
@__wrapped_experiment_chains[klass.name || klass.object_id] ||= begin
|
|
75
|
+
allow(klass).to receive(:new).and_wrap_original do |method, *args, &original_block|
|
|
76
|
+
RSpecMocks.bind_gitlab_experiment_receiver(method).call(*args).tap do |instance|
|
|
77
|
+
wrapped = @__wrapped_experiments[instance.instance_variable_get(:@_name)]
|
|
78
|
+
wrapped&.blocks&.each { |b| b.call(instance, wrapped) }
|
|
79
|
+
|
|
80
|
+
original_block&.call(instance)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
58
83
|
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def extract_experiment_details(experiment)
|
|
87
|
+
experiment_name = nil
|
|
88
|
+
variant_name = nil
|
|
59
89
|
|
|
90
|
+
experiment_name = experiment if experiment.is_a?(Symbol)
|
|
60
91
|
experiment_name, variant_name = *experiment if experiment.is_a?(Array)
|
|
61
92
|
|
|
62
93
|
base_klass = Configuration.base_class.constantize
|
|
63
|
-
variant_name = experiment.
|
|
94
|
+
variant_name = experiment.assigned.name if experiment.is_a?(base_klass)
|
|
64
95
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
elsif experiment.instance_of?(Class) # Class level stubbing, eg. "MyExperiment"
|
|
68
|
-
klass = experiment
|
|
69
|
-
else
|
|
70
|
-
experiment_name ||= experiment.instance_variable_get(:@name)
|
|
71
|
-
klass = base_klass.constantize(experiment_name)
|
|
72
|
-
end
|
|
96
|
+
resolved_klass = experiment_klass(experiment) { base_klass.constantize(experiment_name) }
|
|
97
|
+
experiment_name ||= experiment.instance_variable_get(:@_name)
|
|
73
98
|
|
|
74
|
-
|
|
75
|
-
|
|
99
|
+
[resolved_klass, experiment_name.to_s, variant_name]
|
|
100
|
+
end
|
|
76
101
|
|
|
77
|
-
|
|
78
|
-
|
|
102
|
+
def experiment_klass(experiment, &block)
|
|
103
|
+
if experiment.class.name.nil? # anonymous class instance
|
|
104
|
+
experiment.class
|
|
105
|
+
elsif experiment.instance_of?(Class) # class level stubbing, eg. "MyExperiment"
|
|
106
|
+
experiment
|
|
107
|
+
elsif block
|
|
108
|
+
yield
|
|
79
109
|
end
|
|
80
|
-
|
|
81
|
-
[klass, experiment_name, variant_name]
|
|
82
110
|
end
|
|
83
111
|
end
|
|
84
112
|
|
|
85
113
|
module RSpecMatchers
|
|
86
114
|
extend RSpec::Matchers::DSL
|
|
87
115
|
|
|
88
|
-
def require_experiment(experiment,
|
|
116
|
+
def require_experiment(experiment, matcher, instances_only: true)
|
|
89
117
|
klass = experiment.instance_of?(Class) ? experiment : experiment.class
|
|
90
|
-
unless klass <= Gitlab::Experiment
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
118
|
+
raise ArgumentError, "the #{matcher} matcher is limited to experiments" unless klass <= Gitlab::Experiment
|
|
119
|
+
|
|
120
|
+
if instances_only && experiment == klass
|
|
121
|
+
raise ArgumentError, "the #{matcher} matcher is limited to experiment instances"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
experiment
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
matcher :register_behavior do |behavior_name|
|
|
128
|
+
match do |experiment|
|
|
129
|
+
@experiment = require_experiment(experiment, 'register_behavior')
|
|
130
|
+
|
|
131
|
+
block = @experiment.behaviors[behavior_name.to_s]
|
|
132
|
+
@return_expected = false unless block
|
|
133
|
+
|
|
134
|
+
if @return_expected
|
|
135
|
+
@actual_return = block.call
|
|
136
|
+
@expected_return == @actual_return
|
|
137
|
+
else
|
|
138
|
+
block
|
|
139
|
+
end
|
|
95
140
|
end
|
|
96
141
|
|
|
97
|
-
|
|
98
|
-
|
|
142
|
+
chain :with do |expected|
|
|
143
|
+
@return_expected = true
|
|
144
|
+
@expected_return = expected
|
|
99
145
|
end
|
|
100
146
|
|
|
101
|
-
|
|
147
|
+
failure_message do
|
|
148
|
+
add_details("expected the #{behavior_name} behavior to be registered")
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
failure_message_when_negated do
|
|
152
|
+
add_details("expected the #{behavior_name} behavior not to be registered")
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def add_details(base)
|
|
156
|
+
details = []
|
|
157
|
+
|
|
158
|
+
if @return_expected
|
|
159
|
+
base = "#{base} with a return value"
|
|
160
|
+
details << " expected return: #{@expected_return.inspect}\n" \
|
|
161
|
+
" actual return: #{@actual_return.inspect}"
|
|
162
|
+
else
|
|
163
|
+
details << " behaviors: #{@experiment.behaviors.keys.inspect}"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
details.unshift(base).join("\n")
|
|
167
|
+
end
|
|
102
168
|
end
|
|
103
169
|
|
|
104
170
|
matcher :exclude do |context|
|
|
105
|
-
ivar = :'@excluded'
|
|
106
|
-
|
|
107
171
|
match do |experiment|
|
|
108
|
-
require_experiment(experiment, 'exclude')
|
|
109
|
-
experiment.context(context)
|
|
172
|
+
@experiment = require_experiment(experiment, 'exclude')
|
|
173
|
+
@experiment.context(context)
|
|
174
|
+
@experiment.instance_variable_set(:@_excluded, nil)
|
|
110
175
|
|
|
111
|
-
experiment.
|
|
112
|
-
!experiment.run_callbacks(:exclusion_check) { :not_excluded }
|
|
176
|
+
!@experiment.run_callbacks(:exclusion_check) { :not_excluded }
|
|
113
177
|
end
|
|
114
178
|
|
|
115
179
|
failure_message do
|
|
116
|
-
|
|
180
|
+
"expected #{context} to be excluded"
|
|
117
181
|
end
|
|
118
182
|
|
|
119
183
|
failure_message_when_negated do
|
|
120
|
-
|
|
184
|
+
"expected #{context} not to be excluded"
|
|
121
185
|
end
|
|
122
186
|
end
|
|
123
187
|
|
|
124
188
|
matcher :segment do |context|
|
|
125
|
-
ivar = :'@variant_name'
|
|
126
|
-
|
|
127
189
|
match do |experiment|
|
|
128
|
-
require_experiment(experiment, 'segment')
|
|
129
|
-
experiment.context(context)
|
|
190
|
+
@experiment = require_experiment(experiment, 'segment')
|
|
191
|
+
@experiment.context(context)
|
|
192
|
+
@experiment.instance_variable_set(:@_assigned_variant_name, nil)
|
|
193
|
+
@experiment.run_callbacks(:segmentation)
|
|
130
194
|
|
|
131
|
-
experiment.
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
@actual = experiment.instance_variable_get(ivar)
|
|
135
|
-
@expected ? @actual.to_s == @expected.to_s : @actual.present?
|
|
195
|
+
@actual_variant = @experiment.instance_variable_get(:@_assigned_variant_name)
|
|
196
|
+
@expected_variant ? @actual_variant.to_s == @expected_variant.to_s : @actual_variant.present?
|
|
136
197
|
end
|
|
137
198
|
|
|
138
199
|
chain :into do |expected|
|
|
139
200
|
raise ArgumentError, 'variant name must be provided' if expected.blank?
|
|
140
201
|
|
|
141
|
-
@
|
|
202
|
+
@expected_variant = expected.to_s
|
|
142
203
|
end
|
|
143
204
|
|
|
144
205
|
failure_message do
|
|
145
|
-
|
|
206
|
+
add_details("expected #{context} to be segmented")
|
|
146
207
|
end
|
|
147
208
|
|
|
148
209
|
failure_message_when_negated do
|
|
149
|
-
|
|
210
|
+
add_details("expected #{context} not to be segmented")
|
|
150
211
|
end
|
|
151
212
|
|
|
152
|
-
def
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
213
|
+
def add_details(base)
|
|
214
|
+
details = []
|
|
215
|
+
|
|
216
|
+
if @expected_variant
|
|
217
|
+
base = "#{base} into variant"
|
|
218
|
+
details << " expected variant: #{@expected_variant.inspect}\n" \
|
|
219
|
+
" actual variant: #{@actual_variant.inspect}"
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
details.unshift(base).join("\n")
|
|
157
223
|
end
|
|
158
224
|
end
|
|
159
225
|
|
|
160
226
|
matcher :track do |event, *event_args|
|
|
161
227
|
match do |experiment|
|
|
162
|
-
|
|
228
|
+
@experiment = require_experiment(experiment, 'track', instances_only: false)
|
|
229
|
+
|
|
230
|
+
set_expectations(event, *event_args, negated: false)
|
|
163
231
|
end
|
|
164
232
|
|
|
165
233
|
match_when_negated do |experiment|
|
|
166
|
-
|
|
234
|
+
@experiment = require_experiment(experiment, 'track', instances_only: false)
|
|
235
|
+
|
|
236
|
+
set_expectations(event, *event_args, negated: true)
|
|
167
237
|
end
|
|
168
238
|
|
|
169
|
-
chain
|
|
239
|
+
chain(:for) do |expected|
|
|
170
240
|
raise ArgumentError, 'variant name must be provided' if expected.blank?
|
|
171
241
|
|
|
172
|
-
@expected_variant =
|
|
242
|
+
@expected_variant = expected.to_s
|
|
173
243
|
end
|
|
174
244
|
|
|
175
|
-
chain(:with_context)
|
|
245
|
+
chain(:with_context) do |expected|
|
|
246
|
+
raise ArgumentError, 'context name must be provided' if expected.nil?
|
|
176
247
|
|
|
177
|
-
|
|
248
|
+
@expected_context = expected
|
|
249
|
+
end
|
|
178
250
|
|
|
179
|
-
|
|
180
|
-
klass = experiment.instance_of?(Class) ? experiment : experiment.class
|
|
181
|
-
unless klass <= Gitlab::Experiment
|
|
182
|
-
raise(
|
|
183
|
-
ArgumentError,
|
|
184
|
-
"track matcher is limited to experiment instances and classes"
|
|
185
|
-
)
|
|
186
|
-
end
|
|
251
|
+
chain(:on_next_instance) { @on_next_instance = true }
|
|
187
252
|
|
|
253
|
+
def set_expectations(event, *event_args, negated:)
|
|
254
|
+
failure_message = failure_message_with_details(event, negated: negated)
|
|
188
255
|
expectations = proc do |e|
|
|
189
|
-
@experiment = e
|
|
190
256
|
allow(e).to receive(:track).and_call_original
|
|
191
257
|
|
|
192
258
|
if negated
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
if @expected_variant
|
|
196
|
-
expect(@experiment.variant.name).to eq(@expected_variant), failure_message(:variant, event)
|
|
259
|
+
if @expected_variant || @expected_context
|
|
260
|
+
raise ArgumentError, 'cannot specify `for` or `with_context` when negating on tracking calls'
|
|
197
261
|
end
|
|
198
262
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
expect(e).to receive(:track).with(*[event, *event_args]).and_call_original
|
|
263
|
+
expect(e).not_to receive(:track).with(*[event, *event_args]), failure_message
|
|
264
|
+
else
|
|
265
|
+
expect(e.assigned.name).to(eq(@expected_variant), failure_message) if @expected_variant
|
|
266
|
+
expect(e.context.value).to(include(@expected_context), failure_message) if @expected_context
|
|
267
|
+
expect(e).to receive(:track).with(*[event, *event_args]).and_call_original, failure_message
|
|
204
268
|
end
|
|
205
269
|
end
|
|
206
270
|
|
|
207
|
-
if experiment.instance_of?(Class)
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
expectations.call(experiment)
|
|
211
|
-
end
|
|
271
|
+
return wrapped_experiment(@experiment, &expectations) if @on_next_instance || @experiment.instance_of?(Class)
|
|
272
|
+
|
|
273
|
+
expectations.call(@experiment)
|
|
212
274
|
end
|
|
213
275
|
|
|
214
|
-
def
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
expected context: #{@expected_context}
|
|
226
|
-
actual context: #{@experiment.context.value}
|
|
227
|
-
MESSAGE
|
|
276
|
+
def failure_message_with_details(event, negated: false)
|
|
277
|
+
add_details("expected #{@experiment.inspect} #{negated ? 'not to' : 'to'} have tracked #{event.inspect}")
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def add_details(base)
|
|
281
|
+
details = []
|
|
282
|
+
|
|
283
|
+
if @expected_variant
|
|
284
|
+
base = "#{base} for variant"
|
|
285
|
+
details << " expected variant: #{@expected_variant.inspect}\n" \
|
|
286
|
+
" actual variant: #{@experiment.assigned.name.inspect})"
|
|
228
287
|
end
|
|
288
|
+
|
|
289
|
+
if @expected_context
|
|
290
|
+
base = "#{base} with context"
|
|
291
|
+
details << " expected context: #{@expected_context.inspect}\n" \
|
|
292
|
+
" actual context: #{@experiment.context.value.inspect})"
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
details.unshift(base).join("\n")
|
|
229
296
|
end
|
|
230
297
|
end
|
|
231
298
|
end
|
|
@@ -248,4 +315,18 @@ RSpec.configure do |config|
|
|
|
248
315
|
config.define_derived_metadata(file_path: Regexp.new('/spec/experiments/')) do |metadata|
|
|
249
316
|
metadata[:type] = :experiment
|
|
250
317
|
end
|
|
318
|
+
|
|
319
|
+
# We need to monkeypatch rspec-mocks because there's an issue around stubbing class methods that impacts us here.
|
|
320
|
+
#
|
|
321
|
+
# You can find out what the outcome is of the issues I've opened on rspec-mocks, and maybe some day this won't be
|
|
322
|
+
# needed.
|
|
323
|
+
#
|
|
324
|
+
# https://github.com/rspec/rspec-mocks/issues/1452
|
|
325
|
+
# https://github.com/rspec/rspec-mocks/issues/1451 (closed)
|
|
326
|
+
#
|
|
327
|
+
# The other way I've considered patching this is inside gitlab-experiment itself, by adding an Anonymous class and
|
|
328
|
+
# instantiating that instead of the configured base_class, and then it's less common but still possible to run into
|
|
329
|
+
# the issue.
|
|
330
|
+
require 'rspec/mocks/method_double'
|
|
331
|
+
RSpec::Mocks::MethodDouble.prepend(Gitlab::Experiment::RSpecMocks::MethodDouble)
|
|
251
332
|
end
|