gitlab-experiment 1.5.0 → 2.0.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 +4 -4
- data/README.md +16 -30
- data/lib/generators/gitlab/experiment/install/templates/initializer.rb.tt +0 -14
- data/lib/gitlab/experiment/base_interface.rb +1 -1
- data/lib/gitlab/experiment/cache.rb +4 -4
- data/lib/gitlab/experiment/configuration.rb +2 -31
- data/lib/gitlab/experiment/context.rb +1 -1
- data/lib/gitlab/experiment/cookies.rb +2 -2
- data/lib/gitlab/experiment/errors.rb +0 -1
- data/lib/gitlab/experiment/rollout/random.rb +6 -11
- data/lib/gitlab/experiment/rollout.rb +3 -21
- data/lib/gitlab/experiment/rspec.rb +98 -2
- data/lib/gitlab/experiment/version.rb +1 -1
- data/lib/gitlab/experiment.rb +10 -17
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b69f6d4aa4d4bbba5b9ef15f5a1d72c84726df5a6ec648cd09761cdc9c997d5b
|
|
4
|
+
data.tar.gz: ea6f508020bf5941ee7e74647c43052aedf55fa969d8c7dea9d42f86e9b03c8b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 62690c224599c2c0cbb78a42bdffee6104d5c811faa43027e92a2fd219006e4d0dda7664ee22bdfbb850ad0aa171fa23a82b944a0e667f3b680f2355119c2ae8
|
|
7
|
+
data.tar.gz: 9b7cfe7e5fcdfce2cfd2826da2974b64c5d61d4989fc87725483e45a9f2547840847ee0f7847d5ebd2e4bcfda13dfe78873404d8c6efd5b60e962b8d6413f56a
|
data/README.md
CHANGED
|
@@ -685,11 +685,10 @@ end
|
|
|
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.
|
|
689
|
-
>
|
|
690
|
-
>
|
|
691
|
-
>
|
|
692
|
-
> excluded from tracking there.
|
|
688
|
+
> **Note:** `only_assigned: true` relies on cached assignments. All rollout
|
|
689
|
+
> strategies cache control variants (see
|
|
690
|
+
> [Advanced: Caching Configuration](#advanced-caching-configuration)) so that bucketed
|
|
691
|
+
> control users are included in `only_assigned` tracking.
|
|
693
692
|
|
|
694
693
|
**Real-world use cases:**
|
|
695
694
|
- **Post-signup experiences**: Assign at signup, show features throughout the app
|
|
@@ -704,7 +703,7 @@ needs.
|
|
|
704
703
|
|
|
705
704
|
**Built-in strategies:**
|
|
706
705
|
- [`Percent`](lib/gitlab/experiment/rollout/percent.rb) - Consistent percentage-based assignment (default, recommended)
|
|
707
|
-
- [`Random`](lib/gitlab/experiment/rollout/random.rb) -
|
|
706
|
+
- [`Random`](lib/gitlab/experiment/rollout/random.rb) - Random assignment on first roll (useful for load testing)
|
|
708
707
|
- [`RoundRobin`](lib/gitlab/experiment/rollout/round_robin.rb) - Cycle through variants (requires caching)
|
|
709
708
|
- [`Base`](lib/gitlab/experiment/rollout.rb) - Useful for building custom rollout strategies
|
|
710
709
|
|
|
@@ -885,13 +884,13 @@ graph TD
|
|
|
885
884
|
Excluded? -->|No| Segmented?
|
|
886
885
|
Segmented? -->|Yes / Cached| VariantA
|
|
887
886
|
Segmented? -->|No| Rollout[Rollout Resolve]
|
|
888
|
-
Rollout
|
|
887
|
+
Rollout -->|Cached| Control
|
|
889
888
|
Rollout -->|Cached| VariantA
|
|
890
889
|
Rollout -->|Cached| VariantB
|
|
891
890
|
Rollout -->|Cached| VariantN
|
|
892
891
|
|
|
893
|
-
class ForcedVariant,VariantA,VariantB,VariantN included
|
|
894
|
-
class
|
|
892
|
+
class ForcedVariant,VariantA,VariantB,VariantN,Control included
|
|
893
|
+
class Excluded excluded
|
|
895
894
|
class Cached cached
|
|
896
895
|
```
|
|
897
896
|
|
|
@@ -901,6 +900,7 @@ class Cached cached
|
|
|
901
900
|
3. Cache provides consistency across calls
|
|
902
901
|
4. Segmentation takes priority over rollout
|
|
903
902
|
5. `only_assigned: true` exits early if no cache hit
|
|
903
|
+
6. Control is cached like any other variant, so the assignment stays stable across calls for every rollout strategy
|
|
904
904
|
|
|
905
905
|
### Experiment Context and Stickiness
|
|
906
906
|
|
|
@@ -1326,31 +1326,17 @@ documented in its implementation.
|
|
|
1326
1326
|
|
|
1327
1327
|
**Control variant caching:**
|
|
1328
1328
|
|
|
1329
|
-
|
|
1329
|
+
The gem always caches `:control` assignments alongside variant assignments. This is what
|
|
1330
1330
|
makes `only_assigned: true` lookups include bucketed control users -- without it, control
|
|
1331
1331
|
users would appear unassigned at secondary tracking sites and would be silently excluded.
|
|
1332
1332
|
|
|
1333
|
-
|
|
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.
|
|
1333
|
+
This applies to every rollout strategy.
|
|
1348
1334
|
|
|
1349
|
-
**Operational note:**
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
`
|
|
1335
|
+
**Operational note:** Because control is cached, cache hashes grow with every bucketed
|
|
1336
|
+
user, not just users assigned to a candidate variant. For long-running experiments the
|
|
1337
|
+
cache will be roughly `1 / candidate_percentage` the size it would have been before. Plan
|
|
1338
|
+
periodic cleanup using your store's deletion API -- for the `RedisHashStore`, that's
|
|
1339
|
+
`experiment.cache.store.clear(key: experiment.name)`.
|
|
1354
1340
|
|
|
1355
1341
|
### Advanced: Custom Rollout Strategies
|
|
1356
1342
|
|
|
@@ -22,20 +22,6 @@ 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
|
-
|
|
39
25
|
# The domain to use on cookies.
|
|
40
26
|
#
|
|
41
27
|
# When not set, it uses the current host. If you want to provide specific
|
|
@@ -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, &
|
|
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(&
|
|
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(&
|
|
57
|
-
cache.store.fetch(cache_key, &
|
|
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,21 +30,6 @@ 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
|
-
|
|
48
33
|
# The domain to use on cookies.
|
|
49
34
|
#
|
|
50
35
|
# When not set, it uses the current host. If you want to provide specific
|
|
@@ -141,7 +126,7 @@ module Gitlab
|
|
|
141
126
|
# access experiment methods, like `name`, `context`, and `signature`.
|
|
142
127
|
@tracking_behavior = lambda do |event, args|
|
|
143
128
|
# An example of using a generic logger to track events:
|
|
144
|
-
Configuration.logger.info("#{self.class.name}[#{name}] #{event}: #{args.merge(signature:
|
|
129
|
+
Configuration.logger.info("#{self.class.name}[#{name}] #{event}: #{args.merge(signature:)}")
|
|
145
130
|
|
|
146
131
|
# Using something like snowplow to track events (in gitlab):
|
|
147
132
|
#
|
|
@@ -173,7 +158,7 @@ module Gitlab
|
|
|
173
158
|
# level1 initiated by file_name.rb:2
|
|
174
159
|
# level2 initiated by file_name.rb:3
|
|
175
160
|
@nested_behavior = lambda do |nested_experiment|
|
|
176
|
-
raise NestingError.new(experiment: self, nested_experiment:
|
|
161
|
+
raise NestingError.new(experiment: self, nested_experiment:)
|
|
177
162
|
end
|
|
178
163
|
|
|
179
164
|
# Called at the end of every experiment run, with the result.
|
|
@@ -208,7 +193,6 @@ module Gitlab
|
|
|
208
193
|
:base_class,
|
|
209
194
|
:strict_registration,
|
|
210
195
|
:cache,
|
|
211
|
-
:cache_control,
|
|
212
196
|
:cookie_domain,
|
|
213
197
|
:cookie_name,
|
|
214
198
|
:secure_cookie,
|
|
@@ -229,19 +213,6 @@ module Gitlab
|
|
|
229
213
|
@default_rollout = Rollout.resolve(*args)
|
|
230
214
|
end
|
|
231
215
|
|
|
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
|
-
|
|
245
216
|
# Internal warning helpers.
|
|
246
217
|
|
|
247
218
|
def deprecated(*args, version:, stack: 0)
|
|
@@ -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:
|
|
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
|
|
35
|
+
value: cookie, secure: Configuration.secure_cookie, domain:, httponly: true
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
hash.merge(key => cookie)
|
|
@@ -7,7 +7,6 @@ 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)
|
|
11
10
|
|
|
12
11
|
class NestingError < Error
|
|
13
12
|
def initialize(experiment:, nested_experiment:)
|
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# The random rollout strategy
|
|
4
|
-
# group.
|
|
3
|
+
# The random rollout strategy picks a variant uniformly at random the first time a context is assigned into the
|
|
4
|
+
# experiment group.
|
|
5
5
|
#
|
|
6
|
-
# If caching is enabled
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
# assignment is cached and stable. Without caching, assignment is random on every call.
|
|
6
|
+
# If caching is enabled, that first roll is persisted and the context gets the same variant on every subsequent visit,
|
|
7
|
+
# so the assignment is stable/sticky per context -- including :control, which is cached like any other variant. Without
|
|
8
|
+
# caching, assignment is random on every call.
|
|
10
9
|
#
|
|
11
|
-
# This is the only rollout that opts out of caching control: every other rollout caches whatever variant it resolves to.
|
|
12
10
|
# Example configuration usage:
|
|
13
11
|
#
|
|
14
12
|
# config.default_rollout = Gitlab::Experiment::Rollout::Random.new
|
|
@@ -31,10 +29,7 @@ module Gitlab
|
|
|
31
29
|
protected
|
|
32
30
|
|
|
33
31
|
def execute_assignment
|
|
34
|
-
|
|
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
|
+
behavior_names.sample # pick a random variant; cached on first roll so it stays stable
|
|
38
33
|
end
|
|
39
34
|
end
|
|
40
35
|
end
|
|
@@ -31,15 +31,6 @@ 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
|
-
)
|
|
43
34
|
end
|
|
44
35
|
|
|
45
36
|
def enabled?
|
|
@@ -49,18 +40,9 @@ module Gitlab
|
|
|
49
40
|
def resolve
|
|
50
41
|
validate! # allow the rollout strategy to validate itself
|
|
51
42
|
|
|
52
|
-
|
|
53
|
-
#
|
|
54
|
-
|
|
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)
|
|
43
|
+
# Control variants are cached alongside other variants, which is required
|
|
44
|
+
# for `only_assigned: true` tracking to work for the control arm.
|
|
45
|
+
execute_assignment
|
|
64
46
|
end
|
|
65
47
|
|
|
66
48
|
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, &
|
|
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:
|
|
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
|
data/lib/gitlab/experiment.rb
CHANGED
|
@@ -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, &
|
|
37
|
-
variant(:control, *filter_list, **options, &
|
|
36
|
+
def control(*filter_list, **options, &)
|
|
37
|
+
variant(:control, *filter_list, **options, &)
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
-
def candidate(*filter_list, **options, &
|
|
41
|
-
variant(:candidate, *filter_list, **options, &
|
|
40
|
+
def candidate(*filter_list, **options, &)
|
|
41
|
+
variant(:candidate, *filter_list, **options, &)
|
|
42
42
|
end
|
|
43
43
|
|
|
44
|
-
def variant(variant, *filter_list, **options, &
|
|
45
|
-
build_behavior_callback(filter_list, variant, **options, &
|
|
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(&
|
|
90
|
-
variant(:control, &
|
|
89
|
+
def control(&)
|
|
90
|
+
variant(:control, &)
|
|
91
91
|
end
|
|
92
92
|
|
|
93
|
-
def candidate(&
|
|
94
|
-
variant(:candidate, &
|
|
93
|
+
def candidate(&)
|
|
94
|
+
variant(:candidate, &)
|
|
95
95
|
end
|
|
96
96
|
|
|
97
97
|
def variant(name, &block)
|
|
@@ -168,13 +168,6 @@ module Gitlab
|
|
|
168
168
|
def only_assigned?
|
|
169
169
|
return false unless context.only_assigned
|
|
170
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
171
|
find_variant.blank?
|
|
179
172
|
end
|
|
180
173
|
|
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:
|
|
4
|
+
version: 2.0.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-
|
|
11
|
+
date: 2026-07-22 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.
|
|
249
|
+
version: '3.2'
|
|
250
250
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
251
251
|
requirements:
|
|
252
252
|
- - ">="
|