gitlab-experiment 1.0.0 → 1.2.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: 45c95c2d6d85df98de92096ab00a7cb124ef3cf46f2dfa858465b3c43df8b22d
4
- data.tar.gz: 17716054da0e7aff4c67ddea28c733ec275eaf95934d627bbea40856427ecb68
3
+ metadata.gz: ea78e416b0b830fe748b29e64d0871d4ece35d99bb6c4142fae1a6b1e2bbfb96
4
+ data.tar.gz: ec16e265a80b00380528ba3d2aee36f1da8ffb13b7941a838dcc73e20671aca4
5
5
  SHA512:
6
- metadata.gz: a3549e9ec7f899a6994937545c1afcfaceac52efa373b3e7e594b4cd1a08e7df376d1bf79403704d681ea48a29362358ff36fd5cb0fdff6fca29c79c097c0fbb
7
- data.tar.gz: 7da4141f53babfa226082190ef4c144900df52a8c1c35d03b1270dd4f3f8c2c1be16aad59a9d2da1e833d0a063177b4212744aad9e6a273637c265ea2148d317
6
+ metadata.gz: a767ae547fae832e9053ba91779fc0c1ee85bd16bda1d2a1eb41239fe34a01921b2ef734bea5fbe4724c45f70bed2f3cfbec9c68624d4003c9f30493bf50e99b
7
+ data.tar.gz: cabbdd641f2ee46db0be4209e28866eb7f5437f46f60ba23c0fe82dfb16944620f360506a7ddad3f17d2fcbf149c374a876545473afc474b943518fbfb631a7f
data/README.md CHANGED
@@ -195,7 +195,9 @@ experiment(:pill_color, actor: User.first).run # => "red"
195
195
 
196
196
  ### Exclusion rules
197
197
 
198
- Exclusion rules let us determine if a context should even be considered as something to include in an experiment. If we're excluding something, it means that we don't want to run the experiment in that case. This can be useful if you only want to run experiments on new users for instance.
198
+ Exclusion rules let us determine if a context should even be considered as something to include in an experiment. If
199
+ we're excluding something, it means that we don't want to run the experiment in that case. This can be useful if you
200
+ only want to run experiments on new users for instance.
199
201
 
200
202
  ```ruby
201
203
  class PillColorExperiment < Gitlab::Experiment # OR ApplicationExperiment
@@ -205,15 +207,40 @@ class PillColorExperiment < Gitlab::Experiment # OR ApplicationExperiment
205
207
  end
206
208
  ```
207
209
 
208
- In the previous example, we'll exclude all users named `'Richard'` as well as any account older than 2 weeks old. Not only will they be immediately given the control behavior, but no events will be tracked in these cases either.
210
+ In the previous example, we'll exclude all users named `'Richard'` as well as any account older than 2 weeks old. Not
211
+ only will they be immediately given the control behavior, but no events will be tracked in these cases either.
209
212
 
210
- Exclusion rules are executed in the order they're defined. The first exclusion rule to produce a truthy result will halt execution of further exclusion checks.
213
+ Exclusion rules are executed in the order they're defined. The first exclusion rule to produce a truthy result will halt
214
+ execution of further exclusion checks.
211
215
 
212
- Note: Although tracking calls will be ignored on all exclusions, you may want to check exclusion yourself in expensive custom logic by calling the `should_track?` or `excluded?` methods.
216
+ #### Excluding from within the experiment block
213
217
 
214
- Note: When using exclusion rules it's important to understand that the control assignment is cached, which improves future experiment run performance but can be a gotcha around caching.
218
+ You can also exclude contexts dynamically from within the experiment block using the `exclude!` method. This provides a
219
+ convenient way to include exclusion logic directly within the experiment call:
215
220
 
216
- Note: Exclusion rules aren't the best way to determine if an experiment is enabled. There's an `enabled?` method that can be overridden to have a high-level way of determining if an experiment should be running and tracking at all. This `enabled?` check should be as efficient as possible because it's the first early opt out path an experiment can implement. This can be seen in [How it works](#how-it-works).
221
+ ```ruby
222
+ experiment(:pill_color, actor: current_user) do |e|
223
+ e.exclude! unless can?(current_user, :manage, project)
224
+
225
+ e.control { 'blue' }
226
+ e.candidate { 'red' }
227
+ end
228
+ ```
229
+
230
+ This approach keeps the experiment logic wrapped nicely within the experiment block, rather than requiring you to wrap
231
+ the entire experiment call in conditional logic. When `exclude!` is called, the experiment will be excluded and return
232
+ the control behavior without tracking any events.
233
+
234
+ Note: Although tracking calls will be ignored on all exclusions, you may want to check exclusion yourself in expensive
235
+ custom logic by calling the `should_track?` or `excluded?` methods.
236
+
237
+ Note: When using exclusion rules it's important to understand that the control assignment is cached, which improves
238
+ future experiment run performance but can be a gotcha around caching.
239
+
240
+ Note: Exclusion rules aren't the best way to determine if an experiment is enabled. There's an `enabled?` method that
241
+ can be overridden to have a high-level way of determining if an experiment should be running and tracking at all. This
242
+ `enabled?` check should be as efficient as possible because it's the first early opt out path an experiment can
243
+ implement. This can be seen in [How it works](#how-it-works).
217
244
 
218
245
  ### Segmentation rules
219
246
 
@@ -642,20 +669,52 @@ end
642
669
 
643
670
  ### Stub helpers
644
671
 
645
- You can stub experiment variant resolution using the `stub_experiments` helper. Pass a hash of experiment names and the variant each should resolve to.
672
+ You can stub experiment variant resolution using the `stub_experiments` helper. The helper supports multiple formats for
673
+ flexibility:
674
+
675
+ **Simple hash format:**
646
676
 
647
677
  ```ruby
648
- it "stubs experiments to resolve to a specific variant" do
678
+ it "stubs experiments using hash format" do
649
679
  stub_experiments(pill_color: :red)
650
680
 
651
681
  experiment(:pill_color) do |e|
652
682
  expect(e).to be_enabled
653
683
  expect(e.assigned.name).to eq('red')
654
684
  end
655
- end
685
+ end
656
686
  ```
657
687
 
658
- In special cases you can use a boolean `true` instead of a variant name. This allows the rollout strategy to resolve the variant however it wants to, but is otherwise just making sure the experiment is considered enabled.
688
+ **Hash format with options:**
689
+
690
+ ```ruby
691
+ it "stubs experiments with assigned option" do
692
+ stub_experiments(pill_color: { variant: :red, assigned: true })
693
+
694
+ experiment(:pill_color) do |e|
695
+ expect(e).to be_enabled
696
+ expect(e.assigned.name).to eq('red')
697
+ end
698
+ end
699
+ ```
700
+
701
+ **Mixed formats (symbols and hashes together):**
702
+
703
+ ```ruby
704
+ it "stubs multiple experiments with mixed formats" do
705
+ stub_experiments(
706
+ pill_color: :red,
707
+ hippy: { variant: :free_love, assigned: true },
708
+ yuppie: :financial_success
709
+ )
710
+
711
+ expect(experiment(:pill_color).assigned.name).to eq(:red)
712
+ expect(experiment(:hippy).assigned.name).to eq(:free_love)
713
+ expect(experiment(:yuppie).assigned.name).to eq(:financial_success)
714
+ end
715
+ ```
716
+
717
+ **Boolean true (allows rollout strategy to assign):**
659
718
 
660
719
  ```ruby
661
720
  it "stubs experiments while allowing the rollout strategy to assign the variant" do
@@ -668,30 +727,36 @@ it "stubs experiments while allowing the rollout strategy to assign the variant"
668
727
  end
669
728
  ```
670
729
 
671
- You can also test the `only_assigned` behavior by stubbing cache states:
730
+ #### Testing `only_assigned` behavior
731
+
732
+ When you use the `assigned: true` option in `stub_experiments`, the `find_variant` method is automatically stubbed
733
+ to return the specified variant. This allows you to test the `only_assigned` behavior:
672
734
 
673
735
  ```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
-
736
+ it "tests only_assigned behavior with a cached variant" do
737
+ stub_experiments(pill_color: { variant: :red, assigned: true })
738
+
678
739
  experiment_instance = experiment(:pill_color, actor: user, only_assigned: true)
679
-
740
+
680
741
  expect(experiment_instance).not_to be_excluded
681
742
  expect(experiment_instance.run).to eq('red')
682
743
  end
683
744
 
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
-
745
+ it "tests only_assigned behavior without a cached variant" do
746
+ stub_experiments(pill_color: :red)
747
+
688
748
  experiment_instance = experiment(:pill_color, actor: user, only_assigned: true)
689
-
749
+
690
750
  expect(experiment_instance).to be_excluded
691
- expect(experiment_instance.run).to eq('blue') # control behavior
751
+ expect(experiment_instance.run).to eq('red')
692
752
  end
693
753
  ```
694
754
 
755
+ **Note:** The `assigned: true` option only works correctly when caching is disabled. When caching is enabled,
756
+ `find_variant` will attempt to read from the actual cache store rather than using the stub. In this case, you can
757
+ populate the cache naturally by running the experiment first to assign and cache a variant before testing with
758
+ `only_assigned: true`.
759
+
695
760
  ### Registered behaviors matcher
696
761
 
697
762
  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.
@@ -770,7 +835,14 @@ Each of these approaches could be desirable given the objectives of your experim
770
835
 
771
836
  After cloning the repo, run `bundle install` to install dependencies.
772
837
 
773
- Run `bundle exec rake` to run the tests. You can also run `bundle exec pry` for an interactive prompt that will allow you to experiment.
838
+ ## Running tests
839
+
840
+ The test suite requires Redis to be running. [Install](https://redis.io/docs/latest/operate/oss_and_stack/install/archive/install-redis/) and start Redis (`redis-server`) before running tests.
841
+
842
+ Once Redis is running, execute the tests:
843
+ `bundle exec rake`
844
+
845
+ You can also run `bundle exec pry` for an interactive prompt that will allow you to experiment.
774
846
 
775
847
  ## Contributing
776
848
 
@@ -31,6 +31,14 @@ Gitlab::Experiment.configure do |config|
31
31
  # nil, :all, or ['www.gitlab.com', '.gitlab.com']
32
32
  config.cookie_domain = :all
33
33
 
34
+ # Mark experiment cookies as secure (HTTPS only).
35
+ #
36
+ # When set to true, cookies will have the secure flag set, meaning they
37
+ # will only be sent over HTTPS connections. Defaults to true.
38
+ #
39
+ # Set to false in development/test environments if needed:
40
+ # config.secure_cookie = Rails.env.production?
41
+
34
42
  # The default rollout strategy.
35
43
  #
36
44
  # The recommended default rollout strategy when not using caching would
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'rails_helper'
3
+ require 'spec_helper'
4
4
 
5
5
  <% module_namespacing do -%>
6
6
  RSpec.describe <%= class_name %>Experiment do
@@ -44,6 +44,15 @@ module Gitlab
44
44
  "#{experiment.name}_id"
45
45
  end
46
46
 
47
+ # Mark experiment cookies as secure (HTTPS only).
48
+ #
49
+ # When set to true, cookies will have the secure flag set, meaning they
50
+ # will only be sent over HTTPS connections. Defaults to true.
51
+ #
52
+ # Set to false in development/test environments if needed:
53
+ # config.secure_cookie = Rails.env.production?
54
+ @secure_cookie = true
55
+
47
56
  # The default rollout strategy.
48
57
  #
49
58
  # The recommended default rollout strategy when not using caching would
@@ -177,6 +186,7 @@ module Gitlab
177
186
  :cache,
178
187
  :cookie_domain,
179
188
  :cookie_name,
189
+ :secure_cookie,
180
190
  :context_key_secret,
181
191
  :context_key_bit_length,
182
192
  :mount_at,
@@ -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: true, domain: domain, httponly: true
35
+ value: cookie, secure: Configuration.secure_cookie, domain: domain, httponly: true
36
36
  }
37
37
 
38
38
  hash.merge(key => cookie)
@@ -6,7 +6,7 @@ module Gitlab
6
6
  autoload :Trackable, 'gitlab/experiment/test_behaviors/trackable.rb'
7
7
  end
8
8
 
9
- WrappedExperiment = Struct.new(:klass, :experiment_name, :variant_name, :expectation_chain, :blocks)
9
+ WrappedExperiment = Struct.new(:klass, :experiment_name, :variant_name, :expectation_chain, :blocks, :assigned)
10
10
 
11
11
  module RSpecMocks
12
12
  @__gitlab_experiment_receivers = {}
@@ -44,6 +44,12 @@ module Gitlab
44
44
  # Call the original method if we specified simply `true`.
45
45
  wrapped.variant_name == true ? method.call : wrapped.variant_name
46
46
  }
47
+
48
+ # Stub find_variant only if caching is not enabled
49
+ unless Configuration.cache
50
+ variant_return_value = wrapped.assigned ? wrapped.variant_name.to_s : nil
51
+ allow(instance).to receive(:find_variant).and_return(variant_return_value)
52
+ end
47
53
  end
48
54
  end
49
55
 
@@ -51,11 +57,11 @@ module Gitlab
51
57
  end
52
58
 
53
59
  def wrapped_experiment(experiment, remock: false, &block)
54
- klass, experiment_name, variant_name = *extract_experiment_details(experiment)
60
+ klass, experiment_name, variant_name, assigned = *extract_experiment_details(experiment)
55
61
 
56
62
  wrapped_experiment = wrapped_experiments[experiment_name] =
57
63
  (!remock && wrapped_experiments[experiment_name]) ||
58
- WrappedExperiment.new(klass, experiment_name, variant_name, wrapped_experiment_chain_for(klass), [])
64
+ WrappedExperiment.new(klass, experiment_name, variant_name, wrapped_experiment_chain_for(klass), [], assigned)
59
65
 
60
66
  wrapped_experiment.blocks << block if block
61
67
  wrapped_experiment
@@ -84,9 +90,16 @@ module Gitlab
84
90
  def extract_experiment_details(experiment)
85
91
  experiment_name = nil
86
92
  variant_name = nil
87
-
88
- experiment_name = experiment if experiment.is_a?(Symbol)
89
- experiment_name, variant_name = *experiment if experiment.is_a?(Array)
93
+ assigned = nil
94
+
95
+ if experiment.is_a?(Array)
96
+ # From normalize_experiments: [experiment_name, variant_name_or_config]
97
+ experiment_name, variant_name = *experiment
98
+ assigned = variant_name.is_a?(Hash) ? variant_name.delete(:assigned) : nil
99
+ variant_name = variant_name[:variant] if variant_name.is_a?(Hash)
100
+ elsif experiment.is_a?(Symbol)
101
+ experiment_name = experiment
102
+ end
90
103
 
91
104
  base_klass = Configuration.base_class.constantize
92
105
  variant_name = experiment.assigned.name if experiment.is_a?(base_klass)
@@ -94,7 +107,7 @@ module Gitlab
94
107
  resolved_klass = experiment_klass(experiment) { base_klass.constantize(experiment_name) }
95
108
  experiment_name ||= experiment.instance_variable_get(:@_name)
96
109
 
97
- [resolved_klass, experiment_name.to_s, variant_name]
110
+ [resolved_klass, experiment_name.to_s, variant_name, assigned]
98
111
  end
99
112
 
100
113
  def experiment_klass(experiment, &block)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Gitlab
4
4
  class Experiment
5
- VERSION = '1.0.0'
5
+ VERSION = '1.2.0'
6
6
  end
7
7
  end
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.0.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - GitLab
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-09-29 00:00:00.000000000 Z
11
+ date: 2025-12-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport