gitlab-experiment 0.4.5 → 0.4.10
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +156 -20
- data/lib/gitlab/experiment.rb +37 -27
- data/lib/gitlab/experiment/cache.rb +76 -0
- data/lib/gitlab/experiment/cache/redis_hash_store.rb +68 -0
- data/lib/gitlab/experiment/callbacks.rb +14 -0
- data/lib/gitlab/experiment/context.rb +8 -0
- data/lib/gitlab/experiment/engine.rb +10 -5
- data/lib/gitlab/experiment/rspec.rb +193 -0
- data/lib/gitlab/experiment/version.rb +1 -1
- metadata +5 -3
- data/lib/gitlab/experiment/caching.rb +0 -39
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: a1cbae46377202f37922a880d0c1cde571de29e96dd14fbe879e05718ba6d8ea
|
4
|
+
data.tar.gz: 48258191a1de1e9ca5de40428d481466c3a68272422783610dbfbd112a834ea7
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 8b7d5b2e1725e988bc55653baa1ece89c2d4b31f9cafc7e4d563bc6c093071abdf8fea89abf758658740f34168eb185a01beada94d331e85bf6c9b5cad78a345
|
7
|
+
data.tar.gz: cb295e0547616c5c7b47bcec319e27693e7017bbf14a0026b02afb6324715922a01620485db0878cf806f3ac89306f22e812cd44b812da871e3dc77157aa2386
|
data/README.md
CHANGED
@@ -15,7 +15,7 @@ When we discuss the behavior of this gem, we'll use terms like experiment, conte
|
|
15
15
|
- `candidate` defines that there's one experimental code path.
|
16
16
|
- `variant(s)` is used when more than one experimental code path exists.
|
17
17
|
|
18
|
-
Candidate and variant are the same concept, but simplify how we speak about experimental paths.<br clear="all">
|
18
|
+
Candidate and variant are the same concept, but simplify how we speak about experimental paths -- this concept is also widely referred to as the "experiment group".<br clear="all">
|
19
19
|
|
20
20
|
[[_TOC_]]
|
21
21
|
|
@@ -100,26 +100,33 @@ Here are some examples of what you can introduce once you have a custom experime
|
|
100
100
|
|
101
101
|
```ruby
|
102
102
|
class NotificationToggleExperiment < ApplicationExperiment
|
103
|
-
#
|
103
|
+
# Exclude any users that aren't me.
|
104
|
+
exclude :users_named_richard
|
105
|
+
|
106
|
+
# Segment any account older than 2 weeks into the candidate, without
|
104
107
|
# asking the variant resolver to decide which variant to provide.
|
105
|
-
segment :
|
108
|
+
segment :old_account?, variant: :candidate
|
106
109
|
|
107
110
|
# Define the default control behavior, which can be overridden at
|
108
111
|
# experiment time.
|
109
112
|
def control_behavior
|
110
|
-
render_toggle
|
113
|
+
# render_toggle
|
111
114
|
end
|
112
115
|
|
113
116
|
# Define the default candidate behavior, which can be overridden
|
114
117
|
# at experiment time.
|
115
118
|
def candidate_behavior
|
116
|
-
render_button
|
119
|
+
# render_button
|
117
120
|
end
|
118
121
|
|
119
122
|
private
|
120
123
|
|
121
|
-
def
|
122
|
-
context.actor
|
124
|
+
def users_named_richard
|
125
|
+
context.actor.first_name == 'Richard'
|
126
|
+
end
|
127
|
+
|
128
|
+
def old_account?
|
129
|
+
context.actor.created_at < 2.weeks.ago
|
123
130
|
end
|
124
131
|
end
|
125
132
|
|
@@ -177,7 +184,7 @@ Generally, defining segmentation rules is a better way to approach routing into
|
|
177
184
|
experiment(:notification_toggle, :no_interface, actor: user) do |e|
|
178
185
|
e.use { render_toggle } # control
|
179
186
|
e.try { render_button } # candidate
|
180
|
-
e.try(:no_interface) { no_interface! } # variant
|
187
|
+
e.try(:no_interface) { no_interface! } # no_interface variant
|
181
188
|
end
|
182
189
|
```
|
183
190
|
|
@@ -210,17 +217,53 @@ This library comes with the capability to segment contexts into a specific varia
|
|
210
217
|
Segmentation can be achieved by using a custom experiment class and specifying the segmentation rules at a class level.
|
211
218
|
|
212
219
|
```ruby
|
213
|
-
class
|
214
|
-
segment(variant: :variant_one) { context.actor.
|
215
|
-
segment
|
220
|
+
class ExampleExperiment < ApplicationExperiment
|
221
|
+
segment(variant: :variant_one) { context.actor.first_name == 'Richard' }
|
222
|
+
segment :old_account?, variant: :variant_two
|
223
|
+
|
224
|
+
private
|
225
|
+
|
226
|
+
def old_account?
|
227
|
+
context.actor.created_at < 2.weeks.ago
|
228
|
+
end
|
216
229
|
end
|
217
230
|
```
|
218
231
|
|
219
|
-
In the previous examples, any user
|
232
|
+
In the previous examples, any user named `'Richard'` would always receive the experience defined in "variant_one". As well, any account older than 2 weeks old would get the alternate experience defined in "variant_two".
|
220
233
|
|
221
234
|
When an experiment is run, the segmentation rules are executed in the order they're defined. The first segmentation rule to produce a truthy result is the one which gets used to assign the variant. The remaining segmentation rules are skipped.
|
222
235
|
|
223
|
-
This means that any user
|
236
|
+
This means that any user named `'Richard'`, regardless of account age, will always be provided the experience as defined in "variant_one". If you wanted the opposite logic, you can flip the order.
|
237
|
+
|
238
|
+
### Exclusion rules
|
239
|
+
|
240
|
+
Exclusion rules are similar to segmentation rules, but are intended to determine if a context should even be considered as something we should track events towards. Exclusion means we don't care about the events in relation to the given context.
|
241
|
+
|
242
|
+
```ruby
|
243
|
+
class ExampleExperiment < ApplicationExperiment
|
244
|
+
exclude :old_account?, ->{ context.actor.first_name == 'Richard' }
|
245
|
+
|
246
|
+
private
|
247
|
+
|
248
|
+
def old_account?
|
249
|
+
context.actor.created_at < 2.weeks.ago
|
250
|
+
end
|
251
|
+
end
|
252
|
+
```
|
253
|
+
|
254
|
+
The previous examples will exclude all users named `'Richard'` as well as any account older than 2 weeks old. Not only will they be given the control behavior, but no events will be tracked in these cases as well.
|
255
|
+
|
256
|
+
You may need to check exclusion in custom tracking logic by calling `should_track?`:
|
257
|
+
|
258
|
+
```ruby
|
259
|
+
def expensive_tracking_logic
|
260
|
+
return unless should_track?
|
261
|
+
|
262
|
+
track(:my_event, value: expensive_method_call)
|
263
|
+
end
|
264
|
+
```
|
265
|
+
|
266
|
+
Note: Exclusion rules aren't the best way to determine if an experiment is enabled. There's an `enabled?` method that can be overridden to have a high-level way of determining if an experiment should be running and tracking at all. This `enabled?` check should be as efficient as possible because it's the first early opt out path an experiment can implement.
|
224
267
|
|
225
268
|
### Return value
|
226
269
|
|
@@ -269,7 +312,7 @@ In providing the context migration data, we can resolve an experience and its ev
|
|
269
312
|
|
270
313
|
```ruby
|
271
314
|
# Migrate just the `:version` portion of the previous context, `{ actor: project, version: 1 }`:
|
272
|
-
experiment(:
|
315
|
+
experiment(:example, actor: project, version: 2, migrated_with: { version: 1 })
|
273
316
|
```
|
274
317
|
|
275
318
|
You can add or remove context by providing a `migrated_from` option. This approach expects a full context replacement -- i.e. what it was before you added or removed the new context key.
|
@@ -278,7 +321,7 @@ If you wanted to introduce a `version` to your context, provide the full previou
|
|
278
321
|
|
279
322
|
```ruby
|
280
323
|
# Migrate the full context from `{ actor: project }` to `{ actor: project, version: 1 }`:
|
281
|
-
experiment(:
|
324
|
+
experiment(:example, actor: project, version: 1, migrated_from: { actor: project })
|
282
325
|
```
|
283
326
|
|
284
327
|
This can impact an experience if you:
|
@@ -299,23 +342,23 @@ To read and write cookies, we provide the `request` from within the controller a
|
|
299
342
|
You'll need to provide the `request` as an option to the experiment if it's outside of the controller and views.
|
300
343
|
|
301
344
|
```ruby
|
302
|
-
experiment(:
|
345
|
+
experiment(:example, actor: user, request: request)
|
303
346
|
```
|
304
347
|
|
305
348
|
The cookie isn't set if the `actor` key isn't present at all in the context. Meaning that when no `actor` key is provided, the cookie will not be set.
|
306
349
|
|
307
350
|
```ruby
|
308
351
|
# actor is not present, so no cookie is set
|
309
|
-
experiment(:
|
352
|
+
experiment(:example, project: project)
|
310
353
|
|
311
354
|
# actor is present and is nil, so the cookie is set and used
|
312
|
-
experiment(:
|
355
|
+
experiment(:example, actor: nil, project: project)
|
313
356
|
|
314
357
|
# actor is present and set to a value, so no cookie is set
|
315
|
-
experiment(:
|
358
|
+
experiment(:example, actor: user, project: project)
|
316
359
|
```
|
317
360
|
|
318
|
-
For edge cases, you can pass the cookie through by assigning it yourself -- e.g. `actor: request.cookie_jar.signed['
|
361
|
+
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`.
|
319
362
|
|
320
363
|
## Configuration
|
321
364
|
|
@@ -373,6 +416,99 @@ Gitlab::Experiment.configure do |config|
|
|
373
416
|
end
|
374
417
|
```
|
375
418
|
|
419
|
+
## Testing (rspec support)
|
420
|
+
|
421
|
+
This gem comes with some rspec helpers and custom matchers. These are in flux at the time of writing.
|
422
|
+
|
423
|
+
First, require the rspec support file:
|
424
|
+
|
425
|
+
```ruby
|
426
|
+
require 'gitlab/experiment/rspec'
|
427
|
+
```
|
428
|
+
|
429
|
+
This mixes in some of the basics, but the matchers and other aspects need to be included. This happens automatically for files in `spec/experiments`, but for other files and specs you want to include it in, you can specify the `:experiment` type:
|
430
|
+
|
431
|
+
```ruby
|
432
|
+
it "tests", :experiment do
|
433
|
+
end
|
434
|
+
```
|
435
|
+
|
436
|
+
### Stub helpers
|
437
|
+
|
438
|
+
You can stub experiments using `stub_experiments`. Pass it a hash using experiment names as the keys and the variants you want each to resolve to as the values:
|
439
|
+
|
440
|
+
```ruby
|
441
|
+
# Ensures the experiments named `:example` & `:example2` are both
|
442
|
+
# "enabled" and that each will resolve to the given variant
|
443
|
+
# (`:my_variant` & `:control` respectively).
|
444
|
+
stub_experiments(example: :my_variant, example2: :control)
|
445
|
+
|
446
|
+
experiment(:example) do |e|
|
447
|
+
e.enabled? # => true
|
448
|
+
e.variant.name # => 'my_variant'
|
449
|
+
end
|
450
|
+
|
451
|
+
experiment(:example2) do |e|
|
452
|
+
e.enabled? # => true
|
453
|
+
e.variant.name # => 'control'
|
454
|
+
end
|
455
|
+
```
|
456
|
+
|
457
|
+
### Exclusion and segmentation matchers
|
458
|
+
|
459
|
+
You can also easily test the exclusion and segmentation matchers.
|
460
|
+
|
461
|
+
```ruby
|
462
|
+
class ExampleExperiment < ApplicationExperiment
|
463
|
+
exclude { context.actor.first_name == 'Richard' }
|
464
|
+
segment(variant: :candidate) { context.actor.username == 'jejacks0n' }
|
465
|
+
end
|
466
|
+
|
467
|
+
excluded = double(username: 'rdiggitty', first_name: 'Richard')
|
468
|
+
segmented = double(username: 'jejacks0n', first_name: 'Jeremy')
|
469
|
+
|
470
|
+
# exclude matcher
|
471
|
+
expect(experiment(:example)).to exclude(actor: excluded)
|
472
|
+
expect(experiment(:example)).not_to exclude(actor: segmented)
|
473
|
+
|
474
|
+
# segment matcher
|
475
|
+
expect(experiment(:example)).to segment(actor: segmented).into(:candidate)
|
476
|
+
expect(experiment(:example)).not_to segment(actor: excluded)
|
477
|
+
```
|
478
|
+
|
479
|
+
### Tracking matcher
|
480
|
+
|
481
|
+
Tracking events is a major aspect of experimentation, and because of this we try to provide a flexible way to ensure your tracking calls are covered.
|
482
|
+
|
483
|
+
You can do this on the instance level or at an "any instance" level. At an instance level this is pretty straight forward:
|
484
|
+
|
485
|
+
```ruby
|
486
|
+
subject = experiment(:example)
|
487
|
+
|
488
|
+
expect(subject).to track(:my_event)
|
489
|
+
|
490
|
+
subject.track(:my_event)
|
491
|
+
```
|
492
|
+
|
493
|
+
You can use the `on_any_instance` chain method to specify that it could happen on any instance of the experiment. This can be useful if you're calling `experiment(:example).track` downstream:
|
494
|
+
|
495
|
+
```ruby
|
496
|
+
expect(experiment(:example)).to track(:my_event).on_any_instance
|
497
|
+
|
498
|
+
experiment(:example).track(:my_event)
|
499
|
+
```
|
500
|
+
|
501
|
+
And here's a full example of the methods that can be chained onto the `track` matcher:
|
502
|
+
|
503
|
+
```ruby
|
504
|
+
expect(experiment(:example)).to track(:my_event, value: 1, property: '_property_')
|
505
|
+
.on_any_instance
|
506
|
+
.with_context(foo: :bar)
|
507
|
+
.for(:variant_name)
|
508
|
+
|
509
|
+
experiment(:example, :variant_name, foo: :bar).track(:my_event, value: 1, property: '_property_')
|
510
|
+
```
|
511
|
+
|
376
512
|
## Tracking, anonymity and GDPR
|
377
513
|
|
378
514
|
We generally try not to track things like user identifying values in our experimentation. What we can and do track is the "experiment experience" (a.k.a. the context key).
|
data/lib/gitlab/experiment.rb
CHANGED
@@ -2,10 +2,11 @@
|
|
2
2
|
|
3
3
|
require 'scientist'
|
4
4
|
require 'active_support/callbacks'
|
5
|
+
require 'active_support/cache'
|
5
6
|
require 'active_support/core_ext/object/blank'
|
6
7
|
require 'active_support/core_ext/string/inflections'
|
7
8
|
|
8
|
-
require 'gitlab/experiment/
|
9
|
+
require 'gitlab/experiment/cache'
|
9
10
|
require 'gitlab/experiment/callbacks'
|
10
11
|
require 'gitlab/experiment/configuration'
|
11
12
|
require 'gitlab/experiment/cookies'
|
@@ -18,7 +19,7 @@ require 'gitlab/experiment/engine' if defined?(Rails::Engine)
|
|
18
19
|
module Gitlab
|
19
20
|
class Experiment
|
20
21
|
include Scientist::Experiment
|
21
|
-
include
|
22
|
+
include Cache
|
22
23
|
include Callbacks
|
23
24
|
|
24
25
|
class << self
|
@@ -58,16 +59,18 @@ module Gitlab
|
|
58
59
|
raise ArgumentError, 'name is required' if name.blank? && self.class.base?
|
59
60
|
|
60
61
|
@name = self.class.experiment_name(name, suffix: false)
|
61
|
-
@excluded = []
|
62
62
|
@context = Context.new(self, **context)
|
63
63
|
@variant_name = cache_variant(variant_name) { nil } if variant_name.present?
|
64
64
|
|
65
|
-
exclude { !@context.trackable? }
|
66
65
|
compare { false }
|
67
66
|
|
68
67
|
yield self if block_given?
|
69
68
|
end
|
70
69
|
|
70
|
+
def inspect
|
71
|
+
"#<#{self.class.name || 'AnonymousClass'}:#{format('0x%016X', __id__)} @name=#{name} @context=#{context.value}>"
|
72
|
+
end
|
73
|
+
|
71
74
|
def context(value = nil)
|
72
75
|
return @context if value.blank?
|
73
76
|
|
@@ -81,27 +84,25 @@ module Gitlab
|
|
81
84
|
end
|
82
85
|
|
83
86
|
@variant_name = value unless value.blank?
|
84
|
-
@variant_name ||= :control if excluded?
|
85
87
|
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
@
|
88
|
+
if enabled?
|
89
|
+
@variant_name ||= :control if excluded?
|
90
|
+
|
91
|
+
@resolving_variant = true
|
92
|
+
if (result = cache_variant(@variant_name) { resolve_variant_name }).present?
|
93
|
+
@variant_name = result.to_sym
|
94
|
+
end
|
90
95
|
end
|
91
96
|
|
92
|
-
Variant.new(name:
|
97
|
+
Variant.new(name: (@variant_name || :control).to_s)
|
93
98
|
ensure
|
94
99
|
@resolving_variant = false
|
95
100
|
end
|
96
101
|
|
97
|
-
def exclude(&block)
|
98
|
-
@excluded << block
|
99
|
-
end
|
100
|
-
|
101
102
|
def run(variant_name = nil)
|
102
103
|
@result ||= begin
|
103
104
|
variant_name = variant(variant_name).name
|
104
|
-
run_callbacks(
|
105
|
+
run_callbacks(run_with_segmenting? ? :segmented_run : :unsegmented_run) do
|
105
106
|
super(@variant_name ||= variant_name)
|
106
107
|
end
|
107
108
|
end
|
@@ -112,7 +113,7 @@ module Gitlab
|
|
112
113
|
end
|
113
114
|
|
114
115
|
def track(action, **event_args)
|
115
|
-
return
|
116
|
+
return unless should_track?
|
116
117
|
|
117
118
|
instance_exec(action, event_args, &Configuration.tracking_behavior)
|
118
119
|
end
|
@@ -143,25 +144,26 @@ module Gitlab
|
|
143
144
|
{ variant: variant.name, experiment: name }.merge(context.signature)
|
144
145
|
end
|
145
146
|
|
146
|
-
def
|
147
|
-
|
147
|
+
def id
|
148
|
+
"#{name}:#{key_for(context.value)}"
|
148
149
|
end
|
150
|
+
alias_method :session_id, :id
|
149
151
|
|
150
|
-
def
|
151
|
-
|
152
|
+
def flipper_id
|
153
|
+
"Experiment;#{id}"
|
152
154
|
end
|
153
155
|
|
154
|
-
def
|
155
|
-
|
156
|
+
def enabled?
|
157
|
+
true
|
156
158
|
end
|
157
159
|
|
158
|
-
def
|
159
|
-
|
160
|
+
def excluded?
|
161
|
+
@excluded ||= !@context.trackable? || # adhere to DNT headers
|
162
|
+
!run_callbacks(:exclusion_check) { :not_excluded } # didn't pass exclusion check
|
160
163
|
end
|
161
|
-
alias_method :session_id, :id
|
162
164
|
|
163
|
-
def
|
164
|
-
|
165
|
+
def should_track?
|
166
|
+
enabled? && !excluded?
|
165
167
|
end
|
166
168
|
|
167
169
|
def key_for(hash)
|
@@ -170,6 +172,14 @@ module Gitlab
|
|
170
172
|
|
171
173
|
protected
|
172
174
|
|
175
|
+
def run_with_segmenting?
|
176
|
+
!variant_assigned? && enabled? && !excluded?
|
177
|
+
end
|
178
|
+
|
179
|
+
def variant_assigned?
|
180
|
+
!@variant_name.nil?
|
181
|
+
end
|
182
|
+
|
173
183
|
def resolve_variant_name
|
174
184
|
instance_exec(@variant_name, &Configuration.variant_resolver)
|
175
185
|
end
|
@@ -0,0 +1,76 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Gitlab
|
4
|
+
class Experiment
|
5
|
+
module Cache
|
6
|
+
autoload :RedisHashStore, 'gitlab/experiment/cache/redis_hash_store.rb'
|
7
|
+
|
8
|
+
class Interface
|
9
|
+
attr_reader :store, :key
|
10
|
+
|
11
|
+
def initialize(experiment, store)
|
12
|
+
@experiment = experiment
|
13
|
+
@store = store
|
14
|
+
@key = experiment.cache_key
|
15
|
+
end
|
16
|
+
|
17
|
+
def read
|
18
|
+
store.read(key)
|
19
|
+
end
|
20
|
+
|
21
|
+
def write(value = nil)
|
22
|
+
store.write(key, value || @experiment.variant.name)
|
23
|
+
end
|
24
|
+
|
25
|
+
def delete
|
26
|
+
store.delete(key)
|
27
|
+
end
|
28
|
+
|
29
|
+
def attr_get(name)
|
30
|
+
store.read(@experiment.cache_key(name, suffix: :attrs))
|
31
|
+
end
|
32
|
+
|
33
|
+
def attr_set(name, value)
|
34
|
+
store.write(@experiment.cache_key(name, suffix: :attrs), value)
|
35
|
+
end
|
36
|
+
|
37
|
+
def attr_inc(name, amount = 1)
|
38
|
+
store.increment(@experiment.cache_key(name, suffix: :attrs), amount)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def cache
|
43
|
+
@cache ||= Interface.new(self, Configuration.cache)
|
44
|
+
end
|
45
|
+
|
46
|
+
def cache_variant(specified = nil, &block)
|
47
|
+
return (specified.presence || yield) unless cache.store
|
48
|
+
|
49
|
+
result = migrated_cache_fetch(cache.store, &block)
|
50
|
+
return result unless specified.present?
|
51
|
+
|
52
|
+
cache.write(specified) if result != specified
|
53
|
+
specified
|
54
|
+
end
|
55
|
+
|
56
|
+
def cache_key(key = nil, suffix: nil)
|
57
|
+
"#{[name, suffix].compact.join('_')}:#{key || context.signature[:key]}"
|
58
|
+
end
|
59
|
+
|
60
|
+
private
|
61
|
+
|
62
|
+
def migrated_cache_fetch(store, &block)
|
63
|
+
migrations = context.signature[:migration_keys]&.map { |key| cache_key(key) } || []
|
64
|
+
migrations.find do |old_key|
|
65
|
+
next unless (value = store.read(old_key))
|
66
|
+
|
67
|
+
store.write(cache_key, value)
|
68
|
+
store.delete(old_key)
|
69
|
+
return value
|
70
|
+
end
|
71
|
+
|
72
|
+
store.fetch(cache_key, &block)
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
@@ -0,0 +1,68 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'active_support/notifications'
|
4
|
+
|
5
|
+
# This cache strategy is an implementation on top of the redis hash data type,
|
6
|
+
# that also adheres to the ActiveSupport::Cache::Store interface. It's a good
|
7
|
+
# example of how to build a custom caching strategy for Gitlab::Experiment, and
|
8
|
+
# is intended to be a long lived cache -- until the experiment is cleaned up.
|
9
|
+
#
|
10
|
+
# The data structure:
|
11
|
+
# key: experiment.name
|
12
|
+
# fields: context key => variant name
|
13
|
+
#
|
14
|
+
# Gitlab::Experiment::Configuration.cache = Gitlab::Experiment::Cache::RedisHashStore.new(
|
15
|
+
# pool: -> { Gitlab::Redis::SharedState.with { |redis| yield redis } }
|
16
|
+
# )
|
17
|
+
module Gitlab
|
18
|
+
class Experiment
|
19
|
+
module Cache
|
20
|
+
class RedisHashStore < ActiveSupport::Cache::Store
|
21
|
+
# Clears the entire cache for a given experiment. Be careful with this
|
22
|
+
# since it would reset all resolved variants for the entire experiment.
|
23
|
+
def clear(key:)
|
24
|
+
key = hkey(key)[0] # extract only the first part of the key
|
25
|
+
pool do |redis|
|
26
|
+
case redis.type(key)
|
27
|
+
when 'hash', 'none'
|
28
|
+
redis.del(key) # delete the primary experiment key
|
29
|
+
redis.del("#{key}_attrs") # delete the experiment attributes key
|
30
|
+
else raise ArgumentError, 'invalid call to clear a non-hash cache key'
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def increment(key, amount = 1)
|
36
|
+
pool { |redis| redis.hincrby(*hkey(key), amount) }
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
|
41
|
+
def pool(&block)
|
42
|
+
raise ArgumentError, 'missing block' unless block.present?
|
43
|
+
|
44
|
+
@options[:pool].call(&block)
|
45
|
+
end
|
46
|
+
|
47
|
+
def hkey(key)
|
48
|
+
key.to_s.split(':') # this assumes the default strategy in gitlab-experiment
|
49
|
+
end
|
50
|
+
|
51
|
+
def read_entry(key, **options)
|
52
|
+
value = pool { |redis| redis.hget(*hkey(key)) }
|
53
|
+
value.nil? ? nil : ActiveSupport::Cache::Entry.new(value)
|
54
|
+
end
|
55
|
+
|
56
|
+
def write_entry(key, entry, **options)
|
57
|
+
return false if entry.value.blank? # don't cache any empty values
|
58
|
+
|
59
|
+
pool { |redis| redis.hset(*hkey(key), entry.value) }
|
60
|
+
end
|
61
|
+
|
62
|
+
def delete_entry(key, **options)
|
63
|
+
pool { |redis| redis.hdel(*hkey(key)) }
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
@@ -9,9 +9,23 @@ module Gitlab
|
|
9
9
|
included do
|
10
10
|
define_callbacks(:unsegmented_run)
|
11
11
|
define_callbacks(:segmented_run)
|
12
|
+
define_callbacks(:exclusion_check, skip_after_callbacks_if_terminated: true)
|
12
13
|
end
|
13
14
|
|
14
15
|
class_methods do
|
16
|
+
def exclude(*filter_list, **options, &block)
|
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
|
28
|
+
|
15
29
|
def segment(*filter_list, variant:, **options, &block)
|
16
30
|
filters = filter_list.unshift(block).compact.map do |filter|
|
17
31
|
result_lambda = ActiveSupport::Callbacks::CallTemplate.build(filter, self).make_lambda
|
@@ -42,6 +42,14 @@ module Gitlab
|
|
42
42
|
@signature ||= { key: @experiment.key_for(@value), migration_keys: migration_keys }.compact
|
43
43
|
end
|
44
44
|
|
45
|
+
def method_missing(method_name, *)
|
46
|
+
@value.include?(method_name.to_sym) ? @value[method_name.to_sym] : super
|
47
|
+
end
|
48
|
+
|
49
|
+
def respond_to_missing?(method_name, *)
|
50
|
+
@value.include?(method_name.to_sym) ? true : super
|
51
|
+
end
|
52
|
+
|
45
53
|
private
|
46
54
|
|
47
55
|
def process_migrations(value)
|
@@ -4,13 +4,18 @@ module Gitlab
|
|
4
4
|
class Experiment
|
5
5
|
class Engine < ::Rails::Engine
|
6
6
|
def self.include_dsl
|
7
|
-
ActionController
|
8
|
-
|
9
|
-
|
7
|
+
if defined?(ActionController)
|
8
|
+
ActionController::Base.include(Dsl)
|
9
|
+
ActionController::Base.helper_method(:experiment)
|
10
|
+
end
|
11
|
+
|
12
|
+
return unless defined?(ActionMailer)
|
10
13
|
|
11
|
-
|
12
|
-
|
14
|
+
ActionMailer::Base.include(Dsl)
|
15
|
+
ActionMailer::Base.helper_method(:experiment)
|
13
16
|
end
|
17
|
+
|
18
|
+
config.after_initialize { include_dsl }
|
14
19
|
end
|
15
20
|
end
|
16
21
|
end
|
@@ -0,0 +1,193 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Gitlab
|
4
|
+
class Experiment
|
5
|
+
module RSpecHelpers
|
6
|
+
def stub_experiments(experiments)
|
7
|
+
experiments.each do |name, variant|
|
8
|
+
variant = :control if variant == false
|
9
|
+
raise ArgumentError, 'variant must be a symbol or false' unless variant.is_a?(Symbol)
|
10
|
+
|
11
|
+
klass = Gitlab::Experiment.send(:constantize, name) # rubocop:disable GitlabSecurity/PublicSend
|
12
|
+
|
13
|
+
# We have to use this high level any_instance behavior as there's
|
14
|
+
# not an alternative that allows multiple wrappings of `new`.
|
15
|
+
allow_any_instance_of(klass).to receive(:enabled?).and_return(true)
|
16
|
+
allow_any_instance_of(klass).to receive(:resolve_variant_name).and_return(variant.to_s)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def wrapped_experiment(experiment, shallow: false, failure: nil, &block)
|
21
|
+
if shallow
|
22
|
+
yield experiment if block.present?
|
23
|
+
return experiment
|
24
|
+
end
|
25
|
+
|
26
|
+
receive_wrapped_new = receive(:new).and_wrap_original do |new, *new_args, &new_block|
|
27
|
+
instance = new.call(*new_args)
|
28
|
+
instance.tap(&block) if block.present?
|
29
|
+
instance.tap(&new_block) if new_block.present?
|
30
|
+
instance
|
31
|
+
end
|
32
|
+
|
33
|
+
klass = experiment.class == Class ? experiment : experiment.class
|
34
|
+
if failure
|
35
|
+
expect(klass).to receive_wrapped_new, failure
|
36
|
+
else
|
37
|
+
allow(klass).to receive_wrapped_new
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
module RSpecMatchers
|
43
|
+
extend RSpec::Matchers::DSL
|
44
|
+
|
45
|
+
def require_experiment(experiment, matcher_name, classes: false)
|
46
|
+
klass = experiment.class == Class ? experiment : experiment.class
|
47
|
+
unless klass <= Gitlab::Experiment
|
48
|
+
raise(
|
49
|
+
ArgumentError,
|
50
|
+
"#{matcher_name} matcher is limited to experiment instances#{classes ? ' and classes' : ''}"
|
51
|
+
)
|
52
|
+
end
|
53
|
+
|
54
|
+
if experiment == klass && !classes
|
55
|
+
raise ArgumentError, "#{matcher_name} matcher requires an instance of an experiment"
|
56
|
+
end
|
57
|
+
|
58
|
+
experiment != klass
|
59
|
+
end
|
60
|
+
|
61
|
+
matcher :exclude do |context|
|
62
|
+
ivar = :'@excluded'
|
63
|
+
|
64
|
+
match do |experiment|
|
65
|
+
require_experiment(experiment, 'exclude')
|
66
|
+
experiment.context(context)
|
67
|
+
|
68
|
+
experiment.instance_variable_set(ivar, nil)
|
69
|
+
!experiment.run_callbacks(:exclusion_check) { :not_excluded }
|
70
|
+
end
|
71
|
+
|
72
|
+
failure_message do
|
73
|
+
%(expected #{context} to be excluded)
|
74
|
+
end
|
75
|
+
|
76
|
+
failure_message_when_negated do
|
77
|
+
%(expected #{context} not to be excluded)
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
matcher :segment do |context|
|
82
|
+
ivar = :'@variant_name'
|
83
|
+
|
84
|
+
match do |experiment|
|
85
|
+
require_experiment(experiment, 'segment')
|
86
|
+
experiment.context(context)
|
87
|
+
|
88
|
+
experiment.instance_variable_set(ivar, nil)
|
89
|
+
experiment.run_callbacks(:segmented_run)
|
90
|
+
|
91
|
+
@actual = experiment.instance_variable_get(ivar)
|
92
|
+
@expected ? @actual.to_s == @expected.to_s : @actual.present?
|
93
|
+
end
|
94
|
+
|
95
|
+
chain :into do |expected|
|
96
|
+
raise ArgumentError, 'variant name must be provided' if expected.blank?
|
97
|
+
|
98
|
+
@expected = expected.to_s
|
99
|
+
end
|
100
|
+
|
101
|
+
failure_message do
|
102
|
+
%(expected #{context} to be segmented#{message_details})
|
103
|
+
end
|
104
|
+
|
105
|
+
failure_message_when_negated do
|
106
|
+
%(expected #{context} not to be segmented#{message_details})
|
107
|
+
end
|
108
|
+
|
109
|
+
def message_details
|
110
|
+
message = ''
|
111
|
+
message += %( into variant\n expected variant: #{@expected}) if @expected
|
112
|
+
message += %(\n actual variant: #{@actual}) if @actual
|
113
|
+
message
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
matcher :track do |event, *event_args|
|
118
|
+
match do |experiment|
|
119
|
+
expect_tracking_on(experiment, false, event, *event_args)
|
120
|
+
end
|
121
|
+
|
122
|
+
match_when_negated do |experiment|
|
123
|
+
expect_tracking_on(experiment, true, event, *event_args)
|
124
|
+
end
|
125
|
+
|
126
|
+
chain :for do |expected_variant|
|
127
|
+
raise ArgumentError, 'variant name must be provided' if expected.blank?
|
128
|
+
|
129
|
+
@expected_variant = expected_variant.to_s
|
130
|
+
end
|
131
|
+
|
132
|
+
chain(:with_context) { |expected_context| @expected_context = expected_context }
|
133
|
+
chain(:on_any_instance) { @on_self = false }
|
134
|
+
|
135
|
+
def expect_tracking_on(experiment, negated, event, *event_args)
|
136
|
+
@experiment = experiment
|
137
|
+
@on_self = true if require_experiment(experiment, 'track', classes: !@on_self) && @on_self.nil?
|
138
|
+
wrapped_experiment(experiment, shallow: @on_self, failure: failure_message(:no_new, event)) do |instance|
|
139
|
+
@experiment = instance
|
140
|
+
allow(@experiment).to receive(:track)
|
141
|
+
|
142
|
+
if negated
|
143
|
+
expect(@experiment).not_to receive_tracking_call_for(event, *event_args)
|
144
|
+
else
|
145
|
+
expect(@experiment).to receive_tracking_call_for(event, *event_args)
|
146
|
+
end
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
def receive_tracking_call_for(event, *event_args)
|
151
|
+
receive(:track).with(*[event, *event_args]) do # rubocop:disable CodeReuse/ActiveRecord
|
152
|
+
if @expected_variant
|
153
|
+
expect(@experiment.variant.name).to eq(@expected_variant), failure_message(:variant, event)
|
154
|
+
end
|
155
|
+
|
156
|
+
if @expected_context
|
157
|
+
expect(@experiment.context.value).to include(@expected_context), failure_message(:context, event)
|
158
|
+
end
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
def failure_message(failure_type, event)
|
163
|
+
case failure_type
|
164
|
+
when :variant
|
165
|
+
<<~MESSAGE.strip
|
166
|
+
expected #{@experiment.inspect} to have tracked #{event.inspect} for variant
|
167
|
+
expected variant: #{@expected_variant}
|
168
|
+
actual variant: #{@experiment.variant.name}
|
169
|
+
MESSAGE
|
170
|
+
when :context
|
171
|
+
<<~MESSAGE.strip
|
172
|
+
expected #{@experiment.inspect} to have tracked #{event.inspect} with context
|
173
|
+
expected context: #{@expected_context}
|
174
|
+
actual context: #{@experiment.context.value}
|
175
|
+
MESSAGE
|
176
|
+
when :no_new
|
177
|
+
%(expected #{@experiment.inspect} to have tracked #{event.inspect}, but no new instances were created)
|
178
|
+
end
|
179
|
+
end
|
180
|
+
end
|
181
|
+
end
|
182
|
+
end
|
183
|
+
end
|
184
|
+
|
185
|
+
RSpec.configure do |config|
|
186
|
+
config.include Gitlab::Experiment::RSpecHelpers
|
187
|
+
config.include Gitlab::Experiment::Dsl
|
188
|
+
|
189
|
+
config.include Gitlab::Experiment::RSpecMatchers, :experiment
|
190
|
+
config.define_derived_metadata(file_path: Regexp.new('/spec/experiments/')) do |metadata|
|
191
|
+
metadata[:type] = :experiment
|
192
|
+
end
|
193
|
+
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.
|
4
|
+
version: 0.4.10
|
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-02-16 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activesupport
|
@@ -65,13 +65,15 @@ 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/
|
68
|
+
- lib/gitlab/experiment/cache.rb
|
69
|
+
- lib/gitlab/experiment/cache/redis_hash_store.rb
|
69
70
|
- lib/gitlab/experiment/callbacks.rb
|
70
71
|
- lib/gitlab/experiment/configuration.rb
|
71
72
|
- lib/gitlab/experiment/context.rb
|
72
73
|
- lib/gitlab/experiment/cookies.rb
|
73
74
|
- lib/gitlab/experiment/dsl.rb
|
74
75
|
- lib/gitlab/experiment/engine.rb
|
76
|
+
- lib/gitlab/experiment/rspec.rb
|
75
77
|
- lib/gitlab/experiment/variant.rb
|
76
78
|
- lib/gitlab/experiment/version.rb
|
77
79
|
homepage: https://gitlab.com/gitlab-org/gitlab-experiment
|
@@ -1,39 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module Gitlab
|
4
|
-
class Experiment
|
5
|
-
module Caching
|
6
|
-
def cache_variant(specified = nil, &block)
|
7
|
-
cache = Configuration.cache
|
8
|
-
return (specified.presence || yield) unless cache
|
9
|
-
|
10
|
-
key, migrations = cache_strategy
|
11
|
-
result = migrated_cache(cache, migrations || [], key) || cache.fetch(key, &block)
|
12
|
-
return result unless specified.present?
|
13
|
-
|
14
|
-
cache.write(cache_key, specified) if result != specified
|
15
|
-
specified
|
16
|
-
end
|
17
|
-
|
18
|
-
def cache_key(key = nil)
|
19
|
-
"#{name}:#{key || context.signature[:key]}"
|
20
|
-
end
|
21
|
-
|
22
|
-
private
|
23
|
-
|
24
|
-
def cache_strategy
|
25
|
-
[cache_key, context.signature[:migration_keys]&.map { |key| cache_key(key) }]
|
26
|
-
end
|
27
|
-
|
28
|
-
def migrated_cache(cache, migrations, new_key)
|
29
|
-
migrations.find do |old_key|
|
30
|
-
next unless (value = cache.read(old_key))
|
31
|
-
|
32
|
-
cache.write(new_key, value)
|
33
|
-
cache.delete(old_key)
|
34
|
-
break value
|
35
|
-
end
|
36
|
-
end
|
37
|
-
end
|
38
|
-
end
|
39
|
-
end
|