gitlab-experiment 0.4.5 → 0.4.10
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 +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
|