gitlab-experiment 0.4.6 → 0.4.11

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: 51c0f257bd0cea5fa7b970e764ac5cce9f24c5a844673f05579d1c902a46fc39
4
- data.tar.gz: ddd93dc46cdde532f20edd5209651d511105157ee9dd13d58c5a60cb229cd3ca
3
+ metadata.gz: 207b046a8c0b1c98ad958442716be6a8232c431bfcc702a42817d8eb1aa3f465
4
+ data.tar.gz: c654346312abfd1684af031fe1784cb1db61ac583c0c52c3ef50b08d0337182b
5
5
  SHA512:
6
- metadata.gz: 243606b6b55ebe069296c564ad801a6b25075a85d2b2ca38eb47e836b75b655fa9472fbcf0a0cb01c83093b97d529f8b2aec04389d79119064b5021c975b0042
7
- data.tar.gz: 1900d8b44458d2cf1e2086cc48d5e3ccc19160926c1dc9b827b7a93d042edf7354e1eeb35cae12e0c33f133aef39f605dbd5446edf987c1d99a38264d3b97d72
6
+ metadata.gz: 3f3b9d02b8a268e99bb9cda60e7c1a76d55962f9d04754209558deebd58624c5407bf883a6405c3d248b13f3a246a96a9d4a370bfca052e512f125a57aa89e8f
7
+ data.tar.gz: b43611c028598b362972079b6b785dd62e7c4b35dffa2abc6bd9822a6a2b488385101a5aea975b0d6c429638a8e8661ff6e8aca9e0a0e184c9510a6e5c591d48
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
 
@@ -101,11 +101,11 @@ Here are some examples of what you can introduce once you have a custom experime
101
101
  ```ruby
102
102
  class NotificationToggleExperiment < ApplicationExperiment
103
103
  # Exclude any users that aren't me.
104
- exclude :all_but_me
104
+ exclude :users_named_richard
105
105
 
106
- # Segment any account less than 2 weeks old into the candidate, without
106
+ # Segment any account older than 2 weeks into the candidate, without
107
107
  # asking the variant resolver to decide which variant to provide.
108
- segment :account_age, variant: :candidate
108
+ segment :old_account?, variant: :candidate
109
109
 
110
110
  # Define the default control behavior, which can be overridden at
111
111
  # experiment time.
@@ -121,12 +121,12 @@ class NotificationToggleExperiment < ApplicationExperiment
121
121
 
122
122
  private
123
123
 
124
- def all_but_me
125
- context.actor&.username == 'jejacks0n'
124
+ def users_named_richard
125
+ context.actor.first_name == 'Richard'
126
126
  end
127
127
 
128
- def account_age
129
- context.actor && context.actor.created_at < 2.weeks.ago
128
+ def old_account?
129
+ context.actor.created_at < 2.weeks.ago
130
130
  end
131
131
  end
132
132
 
@@ -217,39 +217,54 @@ This library comes with the capability to segment contexts into a specific varia
217
217
  Segmentation can be achieved by using a custom experiment class and specifying the segmentation rules at a class level.
218
218
 
219
219
  ```ruby
220
- class NotificationToggleExperiment < ApplicationExperiment
221
- segment(variant: :variant_one) { context.actor.username == 'jejacks0n' }
222
- 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
223
229
  end
224
230
  ```
225
231
 
226
- 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".
227
233
 
228
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.
229
235
 
230
- 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.
231
237
 
232
238
  ### Exclusion rules
233
239
 
234
- 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.
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.
235
241
 
236
242
  ```ruby
237
- class NotificationToggleExperiment < ApplicationExperiment
238
- exclude { context.actor.username != 'jejacks0n' }
239
- exclude { context.actor.created_at < 2.weeks.ago }
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
240
251
  end
241
252
  ```
242
253
 
243
- The previous examples will force all users with a username not matching `'jejacks0n'` and all newish users into the control. No events would be tracked for those.
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.
244
255
 
245
- You may need to check exclusion in custom tracking logic, by checking `excluded?` or wrapping your code in a callback block:
256
+ You may need to check exclusion in custom tracking logic by calling `should_track?`:
246
257
 
247
258
  ```ruby
248
- run_callbacks(:exclusion_check) do
259
+ def expensive_tracking_logic
260
+ return unless should_track?
261
+
249
262
  track(:my_event, value: expensive_method_call)
250
263
  end
251
264
  ```
252
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.
267
+
253
268
  ### Return value
254
269
 
255
270
  By default the return value is a `Gitlab::Experiment` instance. In simple cases you may want only the results of the experiment though. You can call `run` within the block to get the return value of the assigned variant.
@@ -297,7 +312,7 @@ In providing the context migration data, we can resolve an experience and its ev
297
312
 
298
313
  ```ruby
299
314
  # Migrate just the `:version` portion of the previous context, `{ actor: project, version: 1 }`:
300
- experiment(:my_experiment, actor: project, version: 2, migrated_with: { version: 1 })
315
+ experiment(:example, actor: project, version: 2, migrated_with: { version: 1 })
301
316
  ```
302
317
 
303
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.
@@ -306,7 +321,7 @@ If you wanted to introduce a `version` to your context, provide the full previou
306
321
 
307
322
  ```ruby
308
323
  # Migrate the full context from `{ actor: project }` to `{ actor: project, version: 1 }`:
309
- experiment(:my_experiment, actor: project, version: 1, migrated_from: { actor: project })
324
+ experiment(:example, actor: project, version: 1, migrated_from: { actor: project })
310
325
  ```
311
326
 
312
327
  This can impact an experience if you:
@@ -327,23 +342,23 @@ To read and write cookies, we provide the `request` from within the controller a
327
342
  You'll need to provide the `request` as an option to the experiment if it's outside of the controller and views.
328
343
 
329
344
  ```ruby
330
- experiment(:my_experiment, actor: user, request: request)
345
+ experiment(:example, actor: user, request: request)
331
346
  ```
332
347
 
333
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.
334
349
 
335
350
  ```ruby
336
351
  # actor is not present, so no cookie is set
337
- experiment(:my_experiment, project: project)
352
+ experiment(:example, project: project)
338
353
 
339
354
  # actor is present and is nil, so the cookie is set and used
340
- experiment(:my_experiment, actor: nil, project: project)
355
+ experiment(:example, actor: nil, project: project)
341
356
 
342
357
  # actor is present and set to a value, so no cookie is set
343
- experiment(:my_experiment, actor: user, project: project)
358
+ experiment(:example, actor: user, project: project)
344
359
  ```
345
360
 
346
- 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`.
347
362
 
348
363
  ## Configuration
349
364
 
@@ -401,6 +416,99 @@ Gitlab::Experiment.configure do |config|
401
416
  end
402
417
  ```
403
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
+
404
512
  ## Tracking, anonymity and GDPR
405
513
 
406
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
@@ -66,6 +67,10 @@ module Gitlab
66
67
  yield self if block_given?
67
68
  end
68
69
 
70
+ def inspect
71
+ "#<#{self.class.name || 'AnonymousClass'}:#{format('0x%016X', __id__)} @name=#{name} @context=#{context.value}>"
72
+ end
73
+
69
74
  def context(value = nil)
70
75
  return @context if value.blank?
71
76
 
@@ -74,11 +79,8 @@ module Gitlab
74
79
  end
75
80
 
76
81
  def variant(value = nil)
77
- if value.blank? && @variant_name || @resolving_variant
78
- return Variant.new(name: (@variant_name || :unresolved).to_s)
79
- end
80
-
81
82
  @variant_name = value unless value.blank?
83
+ return Variant.new(name: (@variant_name || :unresolved).to_s) if @variant_name || @resolving_variant
82
84
 
83
85
  if enabled?
84
86
  @variant_name ||= :control if excluded?
@@ -89,18 +91,16 @@ module Gitlab
89
91
  end
90
92
  end
91
93
 
92
- Variant.new(name: (@variant_name || :control).to_s)
94
+ run_callbacks(segmentation_callback_chain) do
95
+ @variant_name ||= :control
96
+ Variant.new(name: @variant_name.to_s)
97
+ end
93
98
  ensure
94
99
  @resolving_variant = false
95
100
  end
96
101
 
97
102
  def run(variant_name = nil)
98
- @result ||= begin
99
- variant_name = variant(variant_name).name
100
- run_callbacks(run_with_segmenting? ? :segmented_run : :unsegmented_run) do
101
- super(@variant_name ||= variant_name)
102
- end
103
- end
103
+ @result ||= super(variant(variant_name).name)
104
104
  end
105
105
 
106
106
  def publish(result)
@@ -148,18 +148,14 @@ module Gitlab
148
148
  "Experiment;#{id}"
149
149
  end
150
150
 
151
- def key_for(hash)
152
- instance_exec(hash, &Configuration.context_hash_strategy)
153
- end
154
-
155
- protected
156
-
157
151
  def enabled?
158
152
  true
159
153
  end
160
154
 
161
155
  def excluded?
162
- @excluded ||= !@context.trackable? || # adhere to DNT headers
156
+ return @excluded if defined?(@excluded)
157
+
158
+ @excluded = !@context.trackable? || # adhere to DNT headers
163
159
  !run_callbacks(:exclusion_check) { :not_excluded } # didn't pass exclusion check
164
160
  end
165
161
 
@@ -167,8 +163,16 @@ module Gitlab
167
163
  enabled? && !excluded?
168
164
  end
169
165
 
170
- def run_with_segmenting?
171
- !variant_assigned? && enabled? && !excluded?
166
+ def key_for(hash)
167
+ instance_exec(hash, &Configuration.context_hash_strategy)
168
+ end
169
+
170
+ protected
171
+
172
+ def segmentation_callback_chain
173
+ return :segmentation_check if !variant_assigned? && enabled? && !excluded?
174
+
175
+ :unsegmented
172
176
  end
173
177
 
174
178
  def variant_assigned?
@@ -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
@@ -7,8 +7,8 @@ module Gitlab
7
7
  include ActiveSupport::Callbacks
8
8
 
9
9
  included do
10
- define_callbacks(:unsegmented_run)
11
- define_callbacks(:segmented_run)
10
+ define_callbacks(:unsegmented)
11
+ define_callbacks(:segmentation_check)
12
12
  define_callbacks(:exclusion_check, skip_after_callbacks_if_terminated: true)
13
13
  end
14
14
 
@@ -34,7 +34,7 @@ module Gitlab
34
34
 
35
35
  raise ArgumentError, 'no filters provided' if filters.empty?
36
36
 
37
- set_callback(:segmented_run, :before, *filters, options)
37
+ set_callback(:segmentation_check, :before, *filters, options)
38
38
  end
39
39
  end
40
40
  end
@@ -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(:segmentation_check)
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.6'
5
+ VERSION = '0.4.11'
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.6
4
+ version: 0.4.11
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-21 00:00:00.000000000 Z
11
+ date: 2021-02-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -65,14 +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
75
- - lib/gitlab/experiment/rspec_matchers.rb
76
+ - lib/gitlab/experiment/rspec.rb
76
77
  - lib/gitlab/experiment/variant.rb
77
78
  - lib/gitlab/experiment/version.rb
78
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
@@ -1,91 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Gitlab
4
- class Experiment
5
- module RspecMatchers
6
- extend RSpec::Matchers::DSL
7
-
8
- matcher :exclude do |context|
9
- ivar = :'@excluded'
10
-
11
- match do |experiment|
12
- experiment.context(context)
13
-
14
- experiment.instance_variable_set(ivar, nil)
15
- !experiment.run_callbacks(:exclusion_check) { :not_excluded }
16
- end
17
-
18
- failure_message do
19
- %(expected #{context} to be excluded)
20
- end
21
-
22
- failure_message_when_negated do
23
- %(expected #{context} not to be excluded)
24
- end
25
- end
26
-
27
- matcher :segment do |context|
28
- ivar = :'@variant_name'
29
-
30
- match do |experiment|
31
- experiment.context(context)
32
-
33
- experiment.instance_variable_set(ivar, nil)
34
- experiment.run_callbacks(:segmented_run)
35
-
36
- @actual = experiment.instance_variable_get(ivar)
37
- @expected ? @actual.to_s == @expected.to_s : @actual.present?
38
- end
39
-
40
- chain :into do |expected|
41
- raise ArgumentError, 'variant name must be provided' if expected.blank?
42
-
43
- @expected = expected.to_s
44
- end
45
-
46
- failure_message do
47
- %(expected #{context} to be segmented#{message_details})
48
- end
49
-
50
- failure_message_when_negated do
51
- %(expected #{context} not to be segmented#{message_details})
52
- end
53
-
54
- def message_details
55
- message = ''
56
- message += %(\n into: #{@expected}) if @expected
57
- message += %(\n actual: #{@actual}) if @actual
58
- message
59
- end
60
- end
61
-
62
- matcher :track do |event, *event_args|
63
- match do |experiment|
64
- wrapped(experiment) { |instance| expect(instance).to receive_with(event, *event_args) }
65
- end
66
-
67
- match_when_negated do |experiment|
68
- wrapped(experiment) { |instance| expect(instance).not_to receive_with(event, *event_args) }
69
- end
70
-
71
- def wrapped(experiment, &block)
72
- allow(experiment.class).to receive(:new).and_wrap_original do |new, *new_args|
73
- new.call(*new_args).tap(&block)
74
- end
75
- end
76
-
77
- def receive_with(*args)
78
- receive(:track).with(*args) # rubocop:disable CodeReuse/ActiveRecord
79
- end
80
- end
81
- end
82
- end
83
- end
84
-
85
- RSpec.configure do |config|
86
- config.define_derived_metadata(file_path: Regexp.new('/spec/experiments/')) do |metadata|
87
- metadata[:type] = :experiment
88
- end
89
-
90
- config.include Gitlab::Experiment::RspecMatchers, :experiment
91
- end