gitlab-experiment 1.5.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: 453f39a65f699c77881655a8dbfd40d15133baa47e063b76278e2953c4b1de7c
4
- data.tar.gz: 3cd7e33850a1d0725bd7f4c82bbcd9d8769c01c1e6663226dcc07acdf68d24ec
3
+ metadata.gz: 0e0e710a80403ae390bb66fd552656996ca271891cbc4b2cd758f064538442e5
4
+ data.tar.gz: 564ea70f574ebabef344f4f428cfad52a6213a35522834956db8eb8073a498e9
5
5
  SHA512:
6
- metadata.gz: 9ba7cc88703891e0ff10a885b55e0f59911459ffa6de2a7102dececf803cf27ea0d0e62912409b543a537c6df1266e403c18d4aa96d6767cfa1f7ae64d638dd3
7
- data.tar.gz: 5f292e0a952811292354e1d9e84705b88eb225b79819bdb00b3fcf55f7a06754ff87a8502ecd8b5016278629a2c6d83609566dad373bdb2f11d44eca4ed7cdd3
6
+ metadata.gz: b76011392395d1d0b0e1d7beeec6dc085ea6d7444c231b9dbbdac7f909248cce4ca16f551bb11215b2611ad504a63a593669b31e544d69d1658da0e0ee6004eb
7
+ data.tar.gz: e9c96959bc9c30b2684bb4eb13665032753f97463f11192b66f2c5592bbe66c29a49663d1fa4a11b2dc64493d10b462dc24b6a6b2380899467df68cdac26ccae
@@ -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)
@@ -141,7 +141,7 @@ module Gitlab
141
141
  # access experiment methods, like `name`, `context`, and `signature`.
142
142
  @tracking_behavior = lambda do |event, args|
143
143
  # An example of using a generic logger to track events:
144
- Configuration.logger.info("#{self.class.name}[#{name}] #{event}: #{args.merge(signature: signature)}")
144
+ Configuration.logger.info("#{self.class.name}[#{name}] #{event}: #{args.merge(signature:)}")
145
145
 
146
146
  # Using something like snowplow to track events (in gitlab):
147
147
  #
@@ -173,7 +173,7 @@ module Gitlab
173
173
  # level1 initiated by file_name.rb:2
174
174
  # level2 initiated by file_name.rb:3
175
175
  @nested_behavior = lambda do |nested_experiment|
176
- raise NestingError.new(experiment: self, nested_experiment: nested_experiment)
176
+ raise NestingError.new(experiment: self, nested_experiment:)
177
177
  end
178
178
 
179
179
  # Called at the end of every experiment run, with the result.
@@ -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)
@@ -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.5.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)
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.5.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-06-09 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
  - - ">="