gitlab-experiment 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c1bda42d269c22b469ab0a942c38e01b3d718db2c4e7c4ffa61a41e0da53e74a
4
- data.tar.gz: 020d283e9875365e5db338e1dfdfe57e3cf53e10a46d6f2d069603931f94bf50
3
+ metadata.gz: 0e0e710a80403ae390bb66fd552656996ca271891cbc4b2cd758f064538442e5
4
+ data.tar.gz: 564ea70f574ebabef344f4f428cfad52a6213a35522834956db8eb8073a498e9
5
5
  SHA512:
6
- metadata.gz: 558f12b6f92de538d024757f271194675d0ed863e50325debdcf16ddb2e20873df92e008dc20818962a5bbe536aba97c923345119c4a55f2855fc428e0cd43fa
7
- data.tar.gz: dcebe030c7d8b232c5b4c261c894f9db21b48744c80b807642d07a772b9cc0e26ee94264e1a3aa2680326f94f7ae06f860349d0521449d3f2145974c6b3a84d9
6
+ metadata.gz: b76011392395d1d0b0e1d7beeec6dc085ea6d7444c231b9dbbdac7f909248cce4ca16f551bb11215b2611ad504a63a593669b31e544d69d1658da0e0ee6004eb
7
+ data.tar.gz: e9c96959bc9c30b2684bb4eb13665032753f97463f11192b66f2c5592bbe66c29a49663d1fa4a11b2dc64493d10b462dc24b6a6b2380899467df68cdac26ccae
data/README.md CHANGED
@@ -680,11 +680,17 @@ end
680
680
  ```
681
681
 
682
682
  **Behavior with `only_assigned: true`:**
683
- - ✅ If user already assigned → returns their cached variant
684
- - ✅ If user not assigned → returns control, no tracking
683
+ - ✅ If user has a cached assignment (control or candidate) → returns that variant and tracks
684
+ - ✅ If user has no cached assignment → returns control, no tracking
685
685
  - ✅ Experiment reach stays controlled
686
686
  - ✅ Perfect for multi-page experimental experiences
687
687
 
688
+ > **Note:** `only_assigned: true` relies on cached assignments. Control variants are cached
689
+ > by default (see [Advanced: Caching Configuration](#advanced-caching-configuration)) so that
690
+ > bucketed control users are included in `only_assigned` tracking. If you set
691
+ > `cache_control: false`, control users will appear unassigned at secondary sites and be
692
+ > excluded from tracking there.
693
+
688
694
  **Real-world use cases:**
689
695
  - **Post-signup experiences**: Assign at signup, show features throughout the app
690
696
  - **Gradual feature expansion**: Roll out to 5%, then add more touchpoints without expanding population
@@ -1318,6 +1324,34 @@ documented in its implementation.
1318
1324
 
1319
1325
  **Important:** Caching changes how rollout strategies behave. Once cached, subsequent calls return the cached value regardless of rollout strategy changes.
1320
1326
 
1327
+ **Control variant caching:**
1328
+
1329
+ By default, the gem caches `:control` assignments alongside variant assignments. This is what
1330
+ makes `only_assigned: true` lookups include bucketed control users -- without it, control
1331
+ users would appear unassigned at secondary tracking sites and would be silently excluded.
1332
+
1333
+ ```ruby
1334
+ # Disable globally (don't cache :control)
1335
+ Gitlab::Experiment.configure do |config|
1336
+ config.cache_control = false
1337
+ end
1338
+
1339
+ # Or override per-rollout
1340
+ class MyExperiment < ApplicationExperiment
1341
+ default_rollout :percent, cache_control: false
1342
+ end
1343
+ ```
1344
+
1345
+ The `Random` rollout strategy intentionally opts out of caching `:control` regardless of this
1346
+ setting, because its convergence model depends on re-rolling control assignments on every
1347
+ visit until a non-control variant is picked.
1348
+
1349
+ **Operational note:** With control caching enabled, `Percent` and `RoundRobin` cache hashes
1350
+ grow with every bucketed user, not just users assigned to a candidate variant. For
1351
+ long-running experiments the cache will be roughly `1 / candidate_percentage` the size it
1352
+ would have been before. Plan periodic cleanup using your store's deletion API -- for the
1353
+ `RedisHashStore`, that's `experiment.cache.store.clear(key: experiment.name)`.
1354
+
1321
1355
  ### Advanced: Custom Rollout Strategies
1322
1356
 
1323
1357
  Build custom integrations with your existing infrastructure:
@@ -22,6 +22,20 @@ Gitlab::Experiment.configure do |config|
22
22
  # Use `nil` for no caching.
23
23
  config.cache = nil
24
24
 
25
+ # Cache `:control` assignments alongside variant assignments.
26
+ #
27
+ # When set to true (the default), bucketed control users are cached and
28
+ # therefore visible to `only_assigned: true` tracking calls. Set to false
29
+ # to skip caching control -- control users will be treated as unassigned
30
+ # at `only_assigned` sites and excluded from tracking there.
31
+ #
32
+ # Can be overridden per-rollout:
33
+ #
34
+ # class ExampleExperiment < ApplicationExperiment
35
+ # default_rollout :percent, cache_control: false
36
+ # end
37
+ config.cache_control = true
38
+
25
39
  # The domain to use on cookies.
26
40
  #
27
41
  # When not set, it uses the current host. If you want to provide specific
@@ -81,7 +81,7 @@ module Gitlab
81
81
  def process_redirect_url(url)
82
82
  return unless Configuration.redirect_url_validator&.call(url)
83
83
 
84
- track('visited', url: url)
84
+ track('visited', url:)
85
85
  url # return the url, which allows for mutation
86
86
  end
87
87
 
@@ -43,18 +43,18 @@ module Gitlab
43
43
  @cache ||= Interface.new(self, Configuration.cache)
44
44
  end
45
45
 
46
- def cache_variant(specified = nil, &block)
46
+ def cache_variant(specified = nil, &)
47
47
  return (specified.presence || yield) unless cache.store
48
48
 
49
- result = migrated_cache_fetch(cache.store) || find_variant(&block)
49
+ result = migrated_cache_fetch(cache.store) || find_variant(&)
50
50
  return result unless specified.present?
51
51
 
52
52
  cache.write(specified) if result.to_s != specified.to_s
53
53
  specified
54
54
  end
55
55
 
56
- def find_variant(&block)
57
- cache.store.fetch(cache_key, &block)
56
+ def find_variant(&)
57
+ cache.store.fetch(cache_key, &)
58
58
  end
59
59
 
60
60
  def cache_key(key = nil, suffix: nil)
@@ -30,6 +30,21 @@ module Gitlab
30
30
  # Use `nil` for no caching.
31
31
  @cache = nil
32
32
 
33
+ # Whether `:control` assignments should be written to the cache.
34
+ #
35
+ # When true (default), control variants are cached alongside other
36
+ # variants, which is required for `only_assigned: true` tracking to
37
+ # work correctly for the control arm of an experiment.
38
+ #
39
+ # When false, control variants are not cached. This preserves the
40
+ # behavior of versions prior to this option being introduced. Any
41
+ # experiment using `only_assigned: true` will silently exclude
42
+ # control users from tracking.
43
+ #
44
+ # Individual rollouts can override this via the `cache_control:`
45
+ # option, e.g. `default_rollout :percent, cache_control: false`.
46
+ @cache_control = true
47
+
33
48
  # The domain to use on cookies.
34
49
  #
35
50
  # When not set, it uses the current host. If you want to provide specific
@@ -126,7 +141,7 @@ module Gitlab
126
141
  # access experiment methods, like `name`, `context`, and `signature`.
127
142
  @tracking_behavior = lambda do |event, args|
128
143
  # An example of using a generic logger to track events:
129
- Configuration.logger.info("#{self.class.name}[#{name}] #{event}: #{args.merge(signature: signature)}")
144
+ Configuration.logger.info("#{self.class.name}[#{name}] #{event}: #{args.merge(signature:)}")
130
145
 
131
146
  # Using something like snowplow to track events (in gitlab):
132
147
  #
@@ -158,7 +173,7 @@ module Gitlab
158
173
  # level1 initiated by file_name.rb:2
159
174
  # level2 initiated by file_name.rb:3
160
175
  @nested_behavior = lambda do |nested_experiment|
161
- raise NestingError.new(experiment: self, nested_experiment: nested_experiment)
176
+ raise NestingError.new(experiment: self, nested_experiment:)
162
177
  end
163
178
 
164
179
  # Called at the end of every experiment run, with the result.
@@ -193,6 +208,7 @@ module Gitlab
193
208
  :base_class,
194
209
  :strict_registration,
195
210
  :cache,
211
+ :cache_control,
196
212
  :cookie_domain,
197
213
  :cookie_name,
198
214
  :secure_cookie,
@@ -213,6 +229,19 @@ module Gitlab
213
229
  @default_rollout = Rollout.resolve(*args)
214
230
  end
215
231
 
232
+ def cache_control=(value) # rubocop:disable Lint/DuplicateMethods
233
+ if value != true
234
+ deprecated(
235
+ :cache_control,
236
+ "setting `cache_control` to a non-true value is deprecated and will be removed in version 2.0. " \
237
+ "Control variants will always be cached.",
238
+ version: '1.4'
239
+ )
240
+ end
241
+
242
+ @cache_control = value
243
+ end
244
+
216
245
  # Internal warning helpers.
217
246
 
218
247
  def deprecated(*args, version:, stack: 0)
@@ -49,7 +49,7 @@ module Gitlab
49
49
  end
50
50
 
51
51
  def signature
52
- @signature ||= { key: key, migration_keys: migration_keys }.compact
52
+ @signature ||= { key:, migration_keys: }.compact
53
53
  end
54
54
 
55
55
  def method_missing(method_name, *)
@@ -22,7 +22,7 @@ module Gitlab
22
22
  return hash.merge(key => cookie) if hash[key].nil?
23
23
 
24
24
  add_unmerged_migration(key => cookie)
25
- cookie_jar.delete(cookie_name, domain: domain)
25
+ cookie_jar.delete(cookie_name, domain:)
26
26
 
27
27
  hash
28
28
  end
@@ -32,7 +32,7 @@ module Gitlab
32
32
 
33
33
  cookie ||= SecureRandom.uuid
34
34
  cookie_jar.permanent.signed[cookie_name] = {
35
- value: cookie, secure: Configuration.secure_cookie, domain: domain, httponly: true
35
+ value: cookie, secure: Configuration.secure_cookie, domain:, httponly: true
36
36
  }
37
37
 
38
38
  hash.merge(key => cookie)
@@ -7,6 +7,7 @@ module Gitlab
7
7
  UnregisteredExperiment = Class.new(Error)
8
8
  ExistingBehaviorError = Class.new(Error)
9
9
  BehaviorMissingError = Class.new(Error)
10
+ ControlNotCachedError = Class.new(Error)
10
11
 
11
12
  class NestingError < Error
12
13
  def initialize(experiment:, nested_experiment:)
@@ -3,9 +3,12 @@
3
3
  # The random rollout strategy will randomly assign a variant when the context is determined to be within the experiment
4
4
  # group.
5
5
  #
6
- # If caching is enabled this is a predicable and consistent assignment that will eventually assign a variant (since
7
- # control isn't cached) but if caching isn't enabled, assignment will be random each time.
6
+ # If caching is enabled this is a predictable and consistent assignment that will eventually assign a non-control
7
+ # variant. This relies on the strategy intentionally NOT caching control assignments -- so when a context is sampled
8
+ # into control, it gets re-rolled on the next visit until it lands on a non-control variant, at which point the
9
+ # assignment is cached and stable. Without caching, assignment is random on every call.
8
10
  #
11
+ # This is the only rollout that opts out of caching control: every other rollout caches whatever variant it resolves to.
9
12
  # Example configuration usage:
10
13
  #
11
14
  # config.default_rollout = Gitlab::Experiment::Rollout::Random.new
@@ -28,7 +31,10 @@ module Gitlab
28
31
  protected
29
32
 
30
33
  def execute_assignment
31
- behavior_names.sample # pick a random variant
34
+ result = behavior_names.sample # pick a random variant
35
+ # Drop :control so it's never cached, even if cache_control is true.
36
+ # The context will be re-rolled on the next visit and eventually converge on a non-control variant.
37
+ result == :control ? nil : result
32
38
  end
33
39
  end
34
40
  end
@@ -31,6 +31,15 @@ module Gitlab
31
31
 
32
32
  @experiment = experiment
33
33
  @options = options
34
+
35
+ return if !options.key?(:cache_control) || options[:cache_control] == true
36
+
37
+ Configuration.deprecated(
38
+ :cache_control,
39
+ "setting `cache_control` to a non-true value on a rollout is deprecated and " \
40
+ "will be removed in version 2.0. Control variants will always be cached.",
41
+ version: '1.4'
42
+ )
34
43
  end
35
44
 
36
45
  def enabled?
@@ -41,7 +50,17 @@ module Gitlab
41
50
  validate! # allow the rollout strategy to validate itself
42
51
 
43
52
  assignment = execute_assignment
44
- assignment == :control ? nil : assignment # avoid caching control by returning nil
53
+ # Cache control assignments unless explicitly opted out. The per-rollout `cache_control:`
54
+ # option takes precedence over the global `Configuration.cache_control` default.
55
+ return assignment if cache_control_enabled?
56
+
57
+ assignment == :control ? nil : assignment
58
+ end
59
+
60
+ # Returns true when this rollout caches `:control` assignments. Per-rollout
61
+ # `cache_control:` option takes precedence over the global default.
62
+ def cache_control_enabled?
63
+ options.fetch(:cache_control, Configuration.cache_control)
45
64
  end
46
65
 
47
66
  private
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'active_support/core_ext/enumerable'
4
+ require 'active_support/core_ext/object/blank'
5
+
3
6
  module Gitlab
4
7
  class Experiment
5
8
  module TestBehaviors
@@ -24,7 +27,7 @@ module Gitlab
24
27
  end
25
28
 
26
29
  module MethodDouble
27
- def proxy_method_invoked(receiver, *args, &block)
30
+ def proxy_method_invoked(receiver, *args, &)
28
31
  RSpecMocks.track_gitlab_experiment_receiver(original_method, receiver)
29
32
  super
30
33
  end
@@ -121,6 +124,38 @@ module Gitlab
121
124
  end
122
125
  end
123
126
 
127
+ # Records experiment tracking calls captured via Configuration.tracking_behavior, so the
128
+ # `have_tracked_experiment` matcher can assert on the ordered sequence of emitted events. Only
129
+ # GLEX-native data is captured (the action plus the experiment's signature), so it works for any
130
+ # consumer regardless of how the host application wires up tracking.
131
+ class TrackedExperiments
132
+ attr_reader :events
133
+
134
+ def initialize(max_segmentations = 1)
135
+ @events = []
136
+ @max_segmentations = max_segmentations
137
+ end
138
+
139
+ def record(action, signature)
140
+ event = { action: }.merge(signature)
141
+ verify_single_assignment(event)
142
+ @events << event
143
+ end
144
+
145
+ private
146
+
147
+ def verify_single_assignment(event)
148
+ return unless event[:action] == :assignment && event[:key].present?
149
+
150
+ keys = @events.select { |e| e[:action] == :assignment && e[:key].present? }
151
+ .pluck(:key).push(event[:key]).uniq
152
+ return unless keys.size > @max_segmentations
153
+
154
+ raise "#{event[:experiment]} was segmented #{keys.size} " \
155
+ "#{keys.size == 1 ? 'time' : 'times'} (expected #{@max_segmentations}) - #{keys.join(', ')}."
156
+ end
157
+ end
158
+
124
159
  module RSpecMatchers
125
160
  extend RSpec::Matchers::DSL
126
161
 
@@ -135,6 +170,10 @@ module Gitlab
135
170
  experiment
136
171
  end
137
172
 
173
+ def tracked_experiments
174
+ @_tracked_experiments
175
+ end
176
+
138
177
  matcher :register_behavior do |behavior_name|
139
178
  match do |experiment|
140
179
  @experiment = require_experiment(experiment, 'register_behavior')
@@ -262,7 +301,7 @@ module Gitlab
262
301
  chain(:on_next_instance) { @on_next_instance = true }
263
302
 
264
303
  def set_expectations(event, *event_args, negated:)
265
- failure_message = failure_message_with_details(event, negated: negated)
304
+ failure_message = failure_message_with_details(event, negated:)
266
305
  expectations = proc do |e|
267
306
  allow(e).to receive(:track).and_call_original
268
307
 
@@ -306,6 +345,43 @@ module Gitlab
306
345
  details.unshift(base).join("\n")
307
346
  end
308
347
  end
348
+
349
+ matcher :have_tracked_experiment do |experiment_name, expectation = []|
350
+ match do
351
+ @experiment_name = experiment_name
352
+ @expectation = expectation
353
+
354
+ expectation == formatted_events
355
+ end
356
+
357
+ failure_message do
358
+ "Expected to track ordered events:\n#{events_to_s(@expectation)}\n" \
359
+ "but tracked:\n#{events_to_s(formatted_events)}"
360
+ end
361
+
362
+ def formatted_events
363
+ unless tracked_experiments
364
+ raise 'have_tracked_experiment requires the :experiment_tracking metadata tag on the example'
365
+ end
366
+
367
+ tracked_experiments.events
368
+ .select { |event| event[:experiment] == @experiment_name.to_s }
369
+ .uniq { |event| event[:action] }
370
+ .map.with_index { |event, index| format_event(event, @expectation[index] || {}) }
371
+ end
372
+
373
+ # A bare expectation entry (eg. `:assignment`) asserts only the action; a Hash entry (eg.
374
+ # `{ action: :assignment, migration_keys: [...] }`) asserts each named signature field.
375
+ def format_event(event, expected_event)
376
+ return event[:action] unless expected_event.is_a?(Hash)
377
+
378
+ expected_event.keys.index_with { |key| event[key] }
379
+ end
380
+
381
+ def events_to_s(events)
382
+ events.map { |event| " #{event}" }.join(",\n")
383
+ end
384
+ end
309
385
  end
310
386
  end
311
387
  end
@@ -326,6 +402,26 @@ RSpec.configure do |config|
326
402
 
327
403
  config.include Gitlab::Experiment::RSpecMatchers, :experiment
328
404
  config.include Gitlab::Experiment::RSpecMatchers, type: :experiment
405
+ config.include Gitlab::Experiment::RSpecMatchers, :experiment_tracking
406
+
407
+ # Capture experiment tracking calls at the gem's own emission seam (Configuration.tracking_behavior),
408
+ # so `have_tracked_experiment` can assert on them without coupling to any host tracking implementation.
409
+ # The metadata value, when numeric, sets the maximum number of segmentations allowed (defaults to 1).
410
+ config.before(:each, :experiment_tracking) do |example|
411
+ max_segmentations = example.metadata[:experiment_tracking]
412
+ max_segmentations = 1 unless max_segmentations.is_a?(Numeric)
413
+ recorder = @_tracked_experiments = Gitlab::Experiment::TrackedExperiments.new(max_segmentations)
414
+
415
+ @_original_tracking_behavior = Gitlab::Experiment::Configuration.tracking_behavior
416
+ Gitlab::Experiment::Configuration.tracking_behavior = lambda do |action, _args|
417
+ # `instance_exec`'d on the experiment by `Experiment#track`, so `signature` is in scope here.
418
+ recorder.record(action, signature)
419
+ end
420
+ end
421
+
422
+ config.after(:each, :experiment_tracking) do
423
+ Gitlab::Experiment::Configuration.tracking_behavior = @_original_tracking_behavior
424
+ end
329
425
 
330
426
  config.define_derived_metadata(file_path: Regexp.new('spec/experiments/')) do |metadata|
331
427
  metadata[:type] ||= :experiment
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Gitlab
4
4
  class Experiment
5
- VERSION = '1.4.0'
5
+ VERSION = '1.6.0'
6
6
  end
7
7
  end
@@ -33,16 +33,16 @@ module Gitlab
33
33
  class << self
34
34
  # Class level behavior registration methods.
35
35
 
36
- def control(*filter_list, **options, &block)
37
- variant(:control, *filter_list, **options, &block)
36
+ def control(*filter_list, **options, &)
37
+ variant(:control, *filter_list, **options, &)
38
38
  end
39
39
 
40
- def candidate(*filter_list, **options, &block)
41
- variant(:candidate, *filter_list, **options, &block)
40
+ def candidate(*filter_list, **options, &)
41
+ variant(:candidate, *filter_list, **options, &)
42
42
  end
43
43
 
44
- def variant(variant, *filter_list, **options, &block)
45
- build_behavior_callback(filter_list, variant, **options, &block)
44
+ def variant(variant, *filter_list, **options, &)
45
+ build_behavior_callback(filter_list, variant, **options, &)
46
46
  end
47
47
 
48
48
  # Class level callback registration methods.
@@ -86,12 +86,12 @@ module Gitlab
86
86
  [Configuration.name_prefix, @_name].compact.join('_')
87
87
  end
88
88
 
89
- def control(&block)
90
- variant(:control, &block)
89
+ def control(&)
90
+ variant(:control, &)
91
91
  end
92
92
 
93
- def candidate(&block)
94
- variant(:candidate, &block)
93
+ def candidate(&)
94
+ variant(:candidate, &)
95
95
  end
96
96
 
97
97
  def variant(name, &block)
@@ -166,7 +166,16 @@ module Gitlab
166
166
  end
167
167
 
168
168
  def only_assigned?
169
- !!context.only_assigned && find_variant.blank?
169
+ return false unless context.only_assigned
170
+
171
+ unless rollout.cache_control_enabled?
172
+ raise ControlNotCachedError,
173
+ "`only_assigned: true` requires `cache_control: true`, but the rollout for " \
174
+ "experiment `#{name}` has it disabled. Control users would be silently excluded " \
175
+ "from tracking. Enable cache_control or remove `only_assigned: true`."
176
+ end
177
+
178
+ find_variant.blank?
170
179
  end
171
180
 
172
181
  def should_track?
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: 1.4.0
4
+ version: 1.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - GitLab
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-22 00:00:00.000000000 Z
11
+ date: 2026-06-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -246,7 +246,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
246
246
  requirements:
247
247
  - - ">="
248
248
  - !ruby/object:Gem::Version
249
- version: '3.0'
249
+ version: '3.2'
250
250
  required_rubygems_version: !ruby/object:Gem::Requirement
251
251
  requirements:
252
252
  - - ">="