gitlab-experiment 1.3.0 → 1.5.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: e34a0534e252b90b635ed280483d18741c2ac7034f5aadc68f14812b77baa0e7
4
- data.tar.gz: 96bb108c2888ffc97f28b59b00b3a631d1370782e9108a06e22bb8492ac79bef
3
+ metadata.gz: 453f39a65f699c77881655a8dbfd40d15133baa47e063b76278e2953c4b1de7c
4
+ data.tar.gz: 3cd7e33850a1d0725bd7f4c82bbcd9d8769c01c1e6663226dcc07acdf68d24ec
5
5
  SHA512:
6
- metadata.gz: 2481560efcb4fafd00f64cadb7fd4199607cec596e168ce4eb72334f17ad812407441cb492412680caae3c5e7ff699cb455092dd4ae750c942badd9f3b52b304
7
- data.tar.gz: 3996ae0f5626023540ae3ad4aeaae2127407626ee369ce2edfa829379c074672bfc9840ecf96baec55d800e648c1bb18ece6fb54c96d3e1e03d0cabf757f6fce
6
+ metadata.gz: 9ba7cc88703891e0ff10a885b55e0f59911459ffa6de2a7102dececf803cf27ea0d0e62912409b543a537c6df1266e403c18d4aa96d6767cfa1f7ae64d638dd3
7
+ data.tar.gz: 5f292e0a952811292354e1d9e84705b88eb225b79819bdb00b3fcf55f7a06754ff87a8502ecd8b5016278629a2c6d83609566dad373bdb2f11d44eca4ed7cdd3
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
@@ -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
@@ -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)
@@ -7,10 +7,19 @@ module Gitlab
7
7
  klass.include(self).tap { |base| base.helper_method(:experiment) if with_helper }
8
8
  end
9
9
 
10
+ # An explicit `request:` keyword argument always takes precedence; when
11
+ # present, no auto-detection runs.
12
+ #
13
+ # Otherwise, `request` is auto-detected through implicit_experiment_request in this order:
14
+ # 1. A `request` method on `self` (the controller pattern).
15
+ # 2. A `@request` instance variable on `self` (the service pattern).
16
+ #
17
+ # Callers with `request` as a pure local variable should pass
18
+ # `request: request` explicitly.
10
19
  def experiment(name, variant_name = nil, **context, &block)
11
20
  raise ArgumentError, 'name is required' if name.nil?
12
21
 
13
- context[:request] ||= request if respond_to?(:request)
22
+ context[:request] ||= implicit_experiment_request
14
23
 
15
24
  base = Configuration.base_class.constantize
16
25
  klass = base.constantize(name) || base
@@ -20,6 +29,16 @@ module Gitlab
20
29
 
21
30
  instance.context.frozen? ? instance.run : instance.tap(&:run)
22
31
  end
32
+
33
+ private
34
+
35
+ def implicit_experiment_request
36
+ if respond_to?(:request)
37
+ request
38
+ elsif instance_variable_defined?(:@request)
39
+ instance_variable_get(:@request)
40
+ end
41
+ end
23
42
  end
24
43
  end
25
44
  end
@@ -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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Gitlab
4
4
  class Experiment
5
- VERSION = '1.3.0'
5
+ VERSION = '1.5.0'
6
6
  end
7
7
  end
@@ -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.3.0
4
+ version: 1.5.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-02-25 00:00:00.000000000 Z
11
+ date: 2026-06-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport