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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 81476752521c6ead83308324fdcf360db7b8182bab340c9ffe7ad4eec21b998e
4
- data.tar.gz: 006d94ff92104bef820be7d6c7c9638b20a6e0a4a533439fca056b4cba31a0bc
3
+ metadata.gz: a1cbae46377202f37922a880d0c1cde571de29e96dd14fbe879e05718ba6d8ea
4
+ data.tar.gz: 48258191a1de1e9ca5de40428d481466c3a68272422783610dbfbd112a834ea7
5
5
  SHA512:
6
- metadata.gz: a5644f3cf6798ddc27034b8857cf03bb77904b3b5c8a80b84c398134ad143b919e16ddc23746a58e63b71d0464c92ec5cce682ddb2039e471b55cfeb42e3cd4a
7
- data.tar.gz: 2dd2adf62427f96a9745d9681ee87b5621acf43fc4dae33ffefdadfad5af6ec3a0ad1e79e601a4d6260e61fa64faf635af9ae753e3fb474e033af7d2d7d55381
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
- # Segment any account less than 2 weeks old into the candidate, without
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 :account_age, variant: :candidate
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 account_age
122
- context.actor && context.actor.created_at < 2.weeks.ago
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 NotificationToggleExperiment < ApplicationExperiment
214
- segment(variant: :variant_one) { context.actor.username == 'jejacks0n' }
215
- segment(variant: :variant_two) { context.actor.created_at < 2.weeks.ago }
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 with the username `'jejacks0n'` would always receive the experience defined in "variant_one". As well, any account less than 2 weeks old would get the alternate experience defined in "variant_two".
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 with the name `'jejacks0n'`, regardless of account age, will always be provided the experience as defined in "variant_one".
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(:my_experiment, actor: project, version: 2, migrated_with: { version: 1 })
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(:my_experiment, actor: project, version: 1, migrated_from: { actor: project })
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(:my_experiment, actor: user, request: request)
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(:my_experiment, project: project)
352
+ experiment(:example, project: project)
310
353
 
311
354
  # actor is present and is nil, so the cookie is set and used
312
- experiment(:my_experiment, actor: nil, project: project)
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(:my_experiment, actor: user, project: project)
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['my_experiment_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`.
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).
@@ -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/caching'
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 Caching
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
- @resolving_variant = true
87
- resolved = :control
88
- if (result = cache_variant(@variant_name) { resolve_variant_name }).present?
89
- @variant_name = resolved = result.to_sym
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: resolved.to_s)
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(variant_assigned? ? :unsegmented_run : :segmented_run) do
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 if excluded?
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 enabled?
147
- true
147
+ def id
148
+ "#{name}:#{key_for(context.value)}"
148
149
  end
150
+ alias_method :session_id, :id
149
151
 
150
- def excluded?
151
- @excluded.any? { |exclude| exclude.call(self) }
152
+ def flipper_id
153
+ "Experiment;#{id}"
152
154
  end
153
155
 
154
- def variant_assigned?
155
- !@variant_name.nil?
156
+ def enabled?
157
+ true
156
158
  end
157
159
 
158
- def id
159
- "#{name}:#{key_for(context.value)}"
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 flipper_id
164
- "Experiment;#{id}"
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::Base.include(Dsl)
8
- ActionController::Base.helper_method(:experiment)
9
- end
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
- config.after_initialize do
12
- include_dsl if defined?(ActionController)
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Gitlab
4
4
  class Experiment
5
- VERSION = '0.4.5'
5
+ VERSION = '0.4.10'
6
6
  end
7
7
  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.5
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-01-12 00:00:00.000000000 Z
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/caching.rb
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