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 +4 -4
- data/README.md +101 -8
- data/lib/gitlab/experiment/cache.rb +7 -3
- data/lib/gitlab/experiment/context.rb +3 -2
- data/lib/gitlab/experiment/rollout/percent.rb +7 -2
- data/lib/gitlab/experiment/version.rb +1 -1
- data/lib/gitlab/experiment.rb +5 -1
- metadata +17 -32
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 45c95c2d6d85df98de92096ab00a7cb124ef3cf46f2dfa858465b3c43df8b22d
|
4
|
+
data.tar.gz: 17716054da0e7aff4c67ddea28c733ec275eaf95934d627bbea40856427ecb68
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
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
|
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`
|
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
|
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
|
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
|
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
|
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
|
-
|
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
|
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'
|
data/lib/gitlab/experiment.rb
CHANGED
@@ -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.
|
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:
|
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:
|
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.
|
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.
|
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.
|
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.
|
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:
|
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:
|
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
|
-
-
|
203
|
-
-
|
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: '
|
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.
|
255
|
+
rubygems_version: 3.5.22
|
271
256
|
signing_key:
|
272
257
|
specification_version: 4
|
273
258
|
summary: GitLab experimentation library.
|