gitlab-experiment 0.9.1 → 1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2ff3ecacc0f83a605ecaad79cef7368802307b9bc1c106eca1c74967ade3a00c
4
- data.tar.gz: a72ceadbbcd689a290bdf7f85b9dababa7f7ae252bc3886a6fcd42170e561ee7
3
+ metadata.gz: 45c95c2d6d85df98de92096ab00a7cb124ef3cf46f2dfa858465b3c43df8b22d
4
+ data.tar.gz: 17716054da0e7aff4c67ddea28c733ec275eaf95934d627bbea40856427ecb68
5
5
  SHA512:
6
- metadata.gz: 6d7f1804cf3503fd0fcf5be75ca6c4d3bfebcdccd3baf5aa3937a7c080e8336c15491a3476a9a7aef30060d0a526f4f71c6803d0a8ae8c08b0192e1401e1070c
7
- data.tar.gz: 4f739b5852733e789ab23ce0b96215d023e4483ef32ae055373bfe3083d212fb5f01aa8b45ee55bfbac0a8a734e5ec429eac175a49a0219256d6f8f62151027a
6
+ metadata.gz: a3549e9ec7f899a6994937545c1afcfaceac52efa373b3e7e594b4cd1a08e7df376d1bf79403704d681ea48a29362358ff36fd5cb0fdff6fca29c79c097c0fbb
7
+ data.tar.gz: 7da4141f53babfa226082190ef4c144900df52a8c1c35d03b1270dd4f3f8c2c1be16aad59a9d2da1e833d0a063177b4212744aad9e6a273637c265ea2148d317
data/README.md CHANGED
@@ -258,6 +258,73 @@ class PillColorExperiment < Gitlab::Experiment # OR ApplicationExperiment
258
258
  end
259
259
  ```
260
260
 
261
+ ### Checking assignment without assigning
262
+
263
+ Sometimes you may want to check if a user is already assigned to an experiment without assigning them to a variant if
264
+ they're not. This is useful when you want to show experiment-specific content only to users who are already
265
+ participating in the experiment, without expanding the experiment's reach.
266
+
267
+ You can use the `only_assigned` option to achieve this behavior:
268
+
269
+ ```haml
270
+ -# Only show experimental features to users already participating in the experiment
271
+ - experiment(:advanced_search, actor: current_user, only_assigned: true) do |e|
272
+ - e.control do
273
+ = render 'search_filters'
274
+ - e.candidate do
275
+ = render 'advanced_search_filters'
276
+ ```
277
+
278
+ When `only_assigned: true` is used:
279
+
280
+ - If the user has a cached variant assignment, the experiment runs normally and returns that variant
281
+ - If the user has no cached variant assignment, the experiment is excluded and returns the control behavior
282
+ - No new variant assignments are made
283
+ - Tracking is disabled for excluded cases
284
+ - Publishing still works to record the exclusion
285
+
286
+ This is particularly useful for:
287
+
288
+ - **Progressive rollouts**: Show experimental features only to users already in the experiment
289
+ - **Conditional UI**: Display experiment-specific UI elements only for assigned users
290
+ - **Feature gates**: Check experiment participation without expanding the participant pool
291
+ - **Cleanup phases**: Maintain experience for existing participants while preventing new assignments
292
+ - **Post-registration experiences**: When users are assigned to experiments during registration, you can later show
293
+ experimental features throughout their journey without expanding to existing users who weren't initially assigned
294
+
295
+ ```ruby
296
+ # During user registration - assign new users to experiment variants
297
+ class RegistrationsController < ApplicationController
298
+ def create
299
+ # ... user creation logic
300
+
301
+ # Assign new users to the experiment
302
+ experiment(:pill_color, actor: @user)
303
+ end
304
+ end
305
+ ```
306
+
307
+ ```haml
308
+ -# Later throughout the app - only show experimental features to assigned users
309
+ - experiment(:pill_color, actor: current_user, only_assigned: true) do |e|
310
+ - e.control do
311
+ - e.candidate do
312
+ = render 'quick_start_guide'
313
+ ```
314
+
315
+ You can also assign the result of the experiment to a variable:
316
+
317
+ ```ruby
318
+ # In a view helper or directly in the view
319
+ button_class = experiment(:pill_color, actor: current_user, only_assigned: true) do |e|
320
+ e.control { 'btn-default' }
321
+ e.candidate { 'btn-primary' }
322
+ end.run
323
+ ```
324
+
325
+ Note: The `only_assigned` option requires caching to be enabled in your experiment configuration, as it relies on
326
+ checking for cached variant assignments.
327
+
261
328
  ### Rollout strategies
262
329
 
263
330
  While a default rollout strategy can be defined in configuration, it's useful to be able to override this per experiment if needed. You can do this by specifying a specific `default_rollout` override in your experiment class.
@@ -274,7 +341,12 @@ Obviously random assignment might not be the best rollout strategy for you, but
274
341
 
275
342
  ## How it works
276
343
 
277
- The way experiments work is best described using the following decision tree diagram. When an experiment is run, the following logic is executed to resolve what experience should be provided, given how the experiment is defined, and using the context passed to the experiment call.
344
+ The way experiments work is best described using the following decision tree diagram. When an experiment is run, the
345
+ following logic is executed to resolve what experience should be provided, given how the experiment is defined, and
346
+ using the context passed to the experiment call.
347
+
348
+ Note: When using the `only_assigned` option, experiments that have no cached variant will be excluded, preventing new
349
+ variant assignments while maintaining existing ones.
278
350
 
279
351
  ```mermaid
280
352
  graph TD
@@ -292,11 +364,6 @@ graph TD
292
364
  Rollout -->|Cached| VariantB
293
365
  Rollout -->|Cached| VariantN
294
366
 
295
- classDef included fill:#380d75,color:#ffffff,stroke:none
296
- classDef excluded fill:#fca121,stroke:none
297
- classDef cached fill:#2e2e2e,color:#ffffff,stroke:none
298
- classDef default fill:#fff,stroke:#6e49cb
299
-
300
367
  class VariantA,VariantB,VariantN included
301
368
  class Control,Excluded excluded
302
369
  class Cached cached
@@ -514,7 +581,7 @@ class PillColorExperiment < Gitlab::Experiment # OR ApplicationExperiment
514
581
  end
515
582
  ```
516
583
 
517
- Now, enabling or disabling the Flipper feature flag will control if the experiment is enabled or not. If the experiment is enabled, as determined by our custom rollout strategy, the standard resolutuon logic will be executed, and a variant (or control) will be assigned.
584
+ Now, enabling or disabling the Flipper feature flag will control if the experiment is enabled or not. If the experiment is enabled, as determined by our custom rollout strategy, the standard resolution logic will be executed, and a variant (or control) will be assigned.
518
585
 
519
586
  ```ruby
520
587
  experiment(:pill_color).enabled? # => false
@@ -601,6 +668,30 @@ it "stubs experiments while allowing the rollout strategy to assign the variant"
601
668
  end
602
669
  ```
603
670
 
671
+ You can also test the `only_assigned` behavior by stubbing cache states:
672
+
673
+ ```ruby
674
+ it "tests only_assigned behavior with cached variants" do
675
+ # Stub a cached variant to exist
676
+ allow_any_instance_of(Gitlab::Experiment).to receive(:find_variant).and_return('red')
677
+
678
+ experiment_instance = experiment(:pill_color, actor: user, only_assigned: true)
679
+
680
+ expect(experiment_instance).not_to be_excluded
681
+ expect(experiment_instance.run).to eq('red')
682
+ end
683
+
684
+ it "tests only_assigned behavior without cached variants" do
685
+ # Stub no cached variant
686
+ allow_any_instance_of(Gitlab::Experiment).to receive(:find_variant).and_return(nil)
687
+
688
+ experiment_instance = experiment(:pill_color, actor: user, only_assigned: true)
689
+
690
+ expect(experiment_instance).to be_excluded
691
+ expect(experiment_instance.run).to eq('blue') # control behavior
692
+ end
693
+ ```
694
+
604
695
  ### Registered behaviors matcher
605
696
 
606
697
  It's useful to test our registered behaviors, as well as their return values when we implement anything complex in them. The `register_behavior` matcher is useful for this.
@@ -616,7 +707,7 @@ end
616
707
 
617
708
  ### Exclusion and segmentation matchers
618
709
 
619
- You can also easily test your experiment classes using the `exclude`, `segment` metchers.
710
+ You can also easily test your experiment classes using the `exclude`, `segment` matchers.
620
711
 
621
712
  ```ruby
622
713
  let(:excluded) { double(first_name: 'Richard', created_at: Time.current) }
@@ -685,6 +776,8 @@ Run `bundle exec rake` to run the tests. You can also run `bundle exec pry` for
685
776
 
686
777
  Bug reports and merge requests are welcome on GitLab at https://gitlab.com/gitlab-org/ruby/gems/gitlab-experiment. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
687
778
 
779
+ Make sure to include a changelog entry in your commit message and read the [changelog entries section](https://docs.gitlab.com/ee/development/changelog.html).
780
+
688
781
  ## Release process
689
782
 
690
783
  Please refer to the [Release Process](docs/release_process.md).
@@ -46,20 +46,24 @@ module Gitlab
46
46
  def cache_variant(specified = nil, &block)
47
47
  return (specified.presence || yield) unless cache.store
48
48
 
49
- result = migrated_cache_fetch(cache.store, &block)
49
+ result = migrated_cache_fetch(cache.store) || find_variant(&block)
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)
58
+ end
59
+
56
60
  def cache_key(key = nil, suffix: nil)
57
61
  "#{[name, suffix].compact.join('_')}:#{key || context.signature[:key]}"
58
62
  end
59
63
 
60
64
  private
61
65
 
62
- def migrated_cache_fetch(store, &block)
66
+ def migrated_cache_fetch(store)
63
67
  migrations = context.signature[:migration_keys]&.map { |key| cache_key(key) } || []
64
68
  migrations.find do |old_key|
65
69
  value = store.read(old_key)
@@ -69,7 +73,7 @@ module Gitlab
69
73
  store.write(cache_key, value)
70
74
  store.delete(old_key)
71
75
  break value
72
- end || store.fetch(cache_key, &block)
76
+ end
73
77
  end
74
78
  end
75
79
  end
@@ -5,9 +5,9 @@ module Gitlab
5
5
  class Context
6
6
  include Cookies
7
7
 
8
- DNT_REGEXP = /^(true|t|yes|y|1|on)$/i.freeze
8
+ DNT_REGEXP = /^(true|t|yes|y|1|on)$/i
9
9
 
10
- attr_reader :request
10
+ attr_reader :request, :only_assigned
11
11
 
12
12
  def initialize(experiment, **initial_value)
13
13
  @experiment = experiment
@@ -26,6 +26,7 @@ module Gitlab
26
26
  return @value if value.nil?
27
27
 
28
28
  value = value.dup # dup so we don't mutate
29
+ @only_assigned = value.delete(:only_assigned)
29
30
  reinitialize(value.delete(:request))
30
31
  key(value.delete(:sticky_to))
31
32
 
@@ -49,9 +49,10 @@ module Gitlab
49
49
 
50
50
  case distribution_rules
51
51
  when Array # run through the rules until finding an acceptable one
52
- behavior_names[distribution_rules.find_index { |percent| crc % 100 <= total += percent }]
52
+ index = distribution_rules.find_index { |percent| crc % 100 < total += percent unless percent == 0 }
53
+ behavior_names[index]
53
54
  when Hash # run through the variant names until finding an acceptable one
54
- distribution_rules.find { |_, percent| crc % 100 <= total += percent }.first
55
+ distribution_rules.find { |_, percent| crc % 100 < total += percent unless percent == 0 }.first
55
56
  else # assume even distribution on no rules
56
57
  behavior_names.empty? ? nil : behavior_names[crc % behavior_names.length]
57
58
  end
@@ -72,6 +73,10 @@ module Gitlab
72
73
  raise InvalidRolloutRules, "the distribution rules don't match the number of behaviors defined"
73
74
  end
74
75
 
76
+ if distributions.any? { |percent| percent < 0 }
77
+ raise InvalidRolloutRules, "the distribution percentage cannot be negative"
78
+ end
79
+
75
80
  return if distributions.sum == 100
76
81
 
77
82
  raise InvalidRolloutRules, 'the distribution percentages should add up to 100'
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Gitlab
4
4
  class Experiment
5
- VERSION = '0.9.1'
5
+ VERSION = '1.0.0'
6
6
  end
7
7
  end
@@ -158,7 +158,11 @@ module Gitlab
158
158
  def excluded?
159
159
  return @_excluded if defined?(@_excluded)
160
160
 
161
- @_excluded = !run_callbacks(exclusion_callback_chain) { :not_excluded }
161
+ @_excluded = !run_callbacks(exclusion_callback_chain) { :not_excluded } || only_assigned?
162
+ end
163
+
164
+ def only_assigned?
165
+ !!context.only_assigned && find_variant.blank?
162
166
  end
163
167
 
164
168
  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: 0.9.1
4
+ version: 1.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:
11
+ date: 2025-09-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -58,14 +58,14 @@ dependencies:
58
58
  requirements:
59
59
  - - "~>"
60
60
  - !ruby/object:Gem::Version
61
- version: 10.1.0
61
+ version: 12.0.1
62
62
  type: :development
63
63
  prerelease: false
64
64
  version_requirements: !ruby/object:Gem::Requirement
65
65
  requirements:
66
66
  - - "~>"
67
67
  - !ruby/object:Gem::Version
68
- version: 10.1.0
68
+ version: 12.0.1
69
69
  - !ruby/object:Gem::Dependency
70
70
  name: lefthook
71
71
  requirement: !ruby/object:Gem::Requirement
@@ -100,28 +100,28 @@ dependencies:
100
100
  requirements:
101
101
  - - "~>"
102
102
  - !ruby/object:Gem::Version
103
- version: 2.20.2
103
+ version: 2.24.0
104
104
  type: :development
105
105
  prerelease: false
106
106
  version_requirements: !ruby/object:Gem::Requirement
107
107
  requirements:
108
108
  - - "~>"
109
109
  - !ruby/object:Gem::Version
110
- version: 2.20.2
110
+ version: 2.24.0
111
111
  - !ruby/object:Gem::Dependency
112
112
  name: rubocop-rspec
113
113
  requirement: !ruby/object:Gem::Requirement
114
114
  requirements:
115
115
  - - "~>"
116
116
  - !ruby/object:Gem::Version
117
- version: 2.22.0
117
+ version: 2.27.1
118
118
  type: :development
119
119
  prerelease: false
120
120
  version_requirements: !ruby/object:Gem::Requirement
121
121
  requirements:
122
122
  - - "~>"
123
123
  - !ruby/object:Gem::Version
124
- version: 2.22.0
124
+ version: 2.27.1
125
125
  - !ruby/object:Gem::Dependency
126
126
  name: flipper
127
127
  requirement: !ruby/object:Gem::Requirement
@@ -184,14 +184,14 @@ dependencies:
184
184
  requirements:
185
185
  - - "~>"
186
186
  - !ruby/object:Gem::Version
187
- version: 2.1.0
187
+ version: 3.1.0
188
188
  type: :development
189
189
  prerelease: false
190
190
  version_requirements: !ruby/object:Gem::Requirement
191
191
  requirements:
192
192
  - - "~>"
193
193
  - !ruby/object:Gem::Version
194
- version: 2.1.0
194
+ version: 3.1.0
195
195
  description:
196
196
  email:
197
197
  - gitlab_rubygems@gitlab.com
@@ -199,33 +199,23 @@ executables: []
199
199
  extensions: []
200
200
  extra_rdoc_files: []
201
201
  files:
202
- - lib/generators/gitlab
203
- - lib/generators/gitlab/experiment
202
+ - LICENSE.txt
203
+ - README.md
204
204
  - lib/generators/gitlab/experiment/USAGE
205
205
  - lib/generators/gitlab/experiment/experiment_generator.rb
206
- - lib/generators/gitlab/experiment/install
207
206
  - lib/generators/gitlab/experiment/install/install_generator.rb
208
- - lib/generators/gitlab/experiment/install/templates
209
207
  - lib/generators/gitlab/experiment/install/templates/POST_INSTALL
210
208
  - lib/generators/gitlab/experiment/install/templates/application_experiment.rb.tt
211
209
  - lib/generators/gitlab/experiment/install/templates/initializer.rb.tt
212
- - lib/generators/gitlab/experiment/templates
213
210
  - lib/generators/gitlab/experiment/templates/experiment.rb.tt
214
- - lib/generators/rspec
215
- - lib/generators/rspec/experiment
216
211
  - lib/generators/rspec/experiment/experiment_generator.rb
217
- - lib/generators/rspec/experiment/templates
218
212
  - lib/generators/rspec/experiment/templates/experiment_spec.rb.tt
219
- - lib/generators/test_unit
220
- - lib/generators/test_unit/experiment
221
213
  - lib/generators/test_unit/experiment/experiment_generator.rb
222
- - lib/generators/test_unit/experiment/templates
223
214
  - lib/generators/test_unit/experiment/templates/experiment_test.rb.tt
224
- - lib/gitlab/experiment
215
+ - lib/gitlab/experiment.rb
225
216
  - lib/gitlab/experiment/base_interface.rb
226
- - lib/gitlab/experiment/cache
227
- - lib/gitlab/experiment/cache/redis_hash_store.rb
228
217
  - lib/gitlab/experiment/cache.rb
218
+ - lib/gitlab/experiment/cache/redis_hash_store.rb
229
219
  - lib/gitlab/experiment/callbacks.rb
230
220
  - lib/gitlab/experiment/configuration.rb
231
221
  - lib/gitlab/experiment/context.rb
@@ -235,19 +225,14 @@ files:
235
225
  - lib/gitlab/experiment/errors.rb
236
226
  - lib/gitlab/experiment/middleware.rb
237
227
  - lib/gitlab/experiment/nestable.rb
238
- - lib/gitlab/experiment/rollout
228
+ - lib/gitlab/experiment/rollout.rb
239
229
  - lib/gitlab/experiment/rollout/percent.rb
240
230
  - lib/gitlab/experiment/rollout/random.rb
241
231
  - lib/gitlab/experiment/rollout/round_robin.rb
242
- - lib/gitlab/experiment/rollout.rb
243
232
  - lib/gitlab/experiment/rspec.rb
244
- - lib/gitlab/experiment/test_behaviors
245
233
  - lib/gitlab/experiment/test_behaviors/trackable.rb
246
234
  - lib/gitlab/experiment/variant.rb
247
235
  - lib/gitlab/experiment/version.rb
248
- - lib/gitlab/experiment.rb
249
- - LICENSE.txt
250
- - README.md
251
236
  homepage: https://gitlab.com/gitlab-org/ruby/gems/gitlab-experiment
252
237
  licenses:
253
238
  - MIT
@@ -260,14 +245,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
260
245
  requirements:
261
246
  - - ">="
262
247
  - !ruby/object:Gem::Version
263
- version: '2.6'
248
+ version: '3.0'
264
249
  required_rubygems_version: !ruby/object:Gem::Requirement
265
250
  requirements:
266
251
  - - ">="
267
252
  - !ruby/object:Gem::Version
268
253
  version: '0'
269
254
  requirements: []
270
- rubygems_version: 3.3.26
255
+ rubygems_version: 3.5.22
271
256
  signing_key:
272
257
  specification_version: 4
273
258
  summary: GitLab experimentation library.