gitlab-experiment 0.4.12 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 231c8403f2957e01a3b94cb0b1d28463ec56a609afd0116d2761057ecd99dcaf
4
- data.tar.gz: 836395307a137302746e0dabed9cda973cc47250c7a4df233c2ddcfe4db3db33
3
+ metadata.gz: 31e4979de3006211bcd1f63882215ae9213c3a22de25e7cca8546f3fce2ef649
4
+ data.tar.gz: cb865da8c87f019755194c98e073720f5f246b5a2c57e6d511c2a280d78ff996
5
5
  SHA512:
6
- metadata.gz: e4eab7b1929389e75ec99569e81e9cf2c6f91407b3bef711080db796597093b5106e999be5a005c401d4e125d7bc244911acc30099670d5439329b56663bc196
7
- data.tar.gz: f4bb5c44f9511ef2a6e58a5887daa1d387619099884262da63ccdfdb259dbf24c95cf4170afa67f0169ae4d13676166659494c608f7fc61b9cfc6f6e0dbed20b
6
+ metadata.gz: 7906faeb21deaa6e0be322c94c0fd45ea200b5419ac5aacd2d2656b416ac08c21654e84f0ef3d2db5bb33c819dc73020267488a6f7f1ac045b66591ec04ae334
7
+ data.tar.gz: e0f9328d5c8f1eca74681e749e01cc6ccf513d619b3c4aa522c45d08a4692d370a96d97437975285fa26dcebad0a7e0a3a8356724b980902ba1b86c8fa82f0a1
data/README.md CHANGED
@@ -3,7 +3,7 @@ GitLab Experiment
3
3
 
4
4
  <img alt="experiment" src="/uploads/60990b2dbf4c0406bbf8b7f998de2dea/experiment.png" align="right" width="40%">
5
5
 
6
- Here at GitLab, we run experiments as A/B/n tests and review the data the experiment generates. From that data, we determine the best performing variant and promote it as the new default code path. Or revert back to the control if no variant outperformed it.
6
+ Here at GitLab, we run experiments as A/B/n tests and review the data the experiment generates. From that data, we determine the best performing variant and promote it as the new default code path. Or revert back to the control if no variant outperformed it. You can read our [Experiment Guide](https://docs.gitlab.com/ee/development/experiment_guide/) docs if you're curious about how we use this gem internally.
7
7
 
8
8
  This library provides a clean and elegant DSL (domain specific language) to define, run, and track your GitLab experiment.
9
9
 
@@ -64,7 +64,7 @@ end
64
64
 
65
65
  You can define the experiment using simple control/candidate paths, or provide named variants.
66
66
 
67
- Handling multi-variant experiments is up to the configuration you provide around resolving variants. But in our example we may want to try with and without the confirmation. We can run any number of variations in our experiments this way.
67
+ Handling [multivariate](https://en.wikipedia.org/wiki/Multivariate_statistics) experiments is up to the configuration you provide around resolving variants. But in our example we may want to try with and without the confirmation. We can run any number of variations in our experiments this way.
68
68
 
69
69
  ```ruby
70
70
  experiment(:notification_toggle, actor: user) do |e|
@@ -149,37 +149,13 @@ experiment(:notification_toggle, actor: user) do |e|
149
149
  end
150
150
  ```
151
151
 
152
- <details>
153
- <summary>You can also use the lower level class interface...</summary>
154
-
155
- ### Using the `.run` approach
156
-
157
- This is useful if you haven't included the DSL and so don't have access to the `experiment` method, but still want to execute an experiment. This is ultimately what the `experiment` method calls through to, and the method signatures are the same.
158
-
159
- ```ruby
160
- exp = Gitlab::Experiment.run(:notification_toggle, actor: user) do |e|
161
- # Context may be passed in the block, but must be finalized before calling
162
- # run or track.
163
- e.context(project: project) # add the project to the context
164
-
165
- # Define the control and candidate variant.
166
- e.use { render_toggle } # control
167
- e.try { render_button } # candidate
168
- end
169
-
170
- # Track an event on the experiment we've defined.
171
- exp.track(:clicked_button)
172
- ```
173
-
174
- </details>
175
-
176
152
  <details>
177
153
  <summary>You can also specify the variant to use for segmentation...</summary>
178
154
 
179
- ### Specifying variant
180
-
181
155
  Generally, defining segmentation rules is a better way to approach routing into specific variants, but it's possible to explicitly specify the variant when running an experiment. It's important to know what this might do to your data during rollout, so use this with careful consideration.
182
156
 
157
+ Any time a specific variant is provided (including `:control`) it will be cached for that context, if caching is enabled.
158
+
183
159
  ```ruby
184
160
  experiment(:notification_toggle, :no_interface, actor: user) do |e|
185
161
  e.use { render_toggle } # control
@@ -360,6 +336,37 @@ experiment(:example, actor: user, project: project)
360
336
 
361
337
  For edge cases, you can pass the cookie through by assigning it yourself -- e.g. `actor: request.cookie_jar.signed['example_actor']`. The cookie name is the full experiment name (including any configured prefix) with `_actor` appended -- e.g. `gitlab_notification_toggle_actor` for the `:notification_toggle` experiment key with a configured prefix of `gitlab`.
362
338
 
339
+ ## How it works
340
+
341
+ The way the gem works is best described using the following decision tree illustration. When an experiment is run, the following logic is executed to resolve what experience should be provided, given how the experiment is defined, and the context provided.
342
+
343
+ ```mermaid
344
+ graph TD
345
+ GP[General Pool/Population] --> Enabled?
346
+ Enabled? -->|Yes| Cached?[Cached? / Pre-segmented?]
347
+ Enabled? -->|No| Excluded[Control / No Tracking]
348
+ Cached? -->|No| Excluded?
349
+ Cached? -->|Yes| Cached[Cached Value]
350
+ Excluded? -->|Yes / Cached| Excluded
351
+ Excluded? -->|No| Segmented?
352
+ Segmented? -->|Yes / Cached| VariantA
353
+ Segmented? -->|No| Included?[Experiment Group?]
354
+ Included? -->|Yes| Rollout
355
+ Included? -->|No| Control
356
+ Rollout -->|Cached| VariantA
357
+ Rollout -->|Cached| VariantB
358
+ Rollout -->|Cached| VariantC
359
+
360
+ classDef included fill:#380d75,color:#ffffff,stroke:none
361
+ classDef excluded fill:#fca121,stroke:none
362
+ classDef cached fill:#2e2e2e,color:#ffffff,stroke:none
363
+ classDef default fill:#fff,stroke:#6e49cb
364
+
365
+ class VariantA,VariantB,VariantC included
366
+ class Control,Excluded excluded
367
+ class Cached cached
368
+ ```
369
+
363
370
  ## Configuration
364
371
 
365
372
  This gem needs to be configured before being used in a meaningful way.
@@ -534,7 +541,7 @@ https://gitlab.com/gitlab-org/gitlab-experiment. This project is intended to be
534
541
  safe, welcoming space for collaboration, and contributors are expected to adhere
535
542
  to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
536
543
 
537
- ## Release Process
544
+ ## Release process
538
545
 
539
546
  Please refer to the [Release Process](docs/release_process.md).
540
547
 
@@ -543,7 +550,7 @@ Please refer to the [Release Process](docs/release_process.md).
543
550
  The gem is available as open source under the terms of the
544
551
  [MIT License](http://opensource.org/licenses/MIT).
545
552
 
546
- ## Code of Conduct
553
+ ## Code of conduct
547
554
 
548
555
  Everyone interacting in the `Gitlab::Experiment` project’s codebases, issue trackers,
549
556
  chat rooms and mailing lists is expected to follow the
@@ -22,20 +22,23 @@ Gitlab::Experiment.configure do |config|
22
22
  # `['www.gitlab.com', '.gitlab.com']`.
23
23
  config.cookie_domain = :all
24
24
 
25
- # Logic this project uses to resolve a variant for a given experiment.
25
+ # The default rollout strategy that works for single and multi-variants.
26
26
  #
27
- # Should return a symbol or string that represents the variant that should
28
- # be assigned. Blank or nil values will be defaulted to the control.
27
+ # You can provide your own rollout strategies and override them per
28
+ # experiment.
29
+ #
30
+ # Examples include:
31
+ # Rollout::First, Rollout::Random, Rollout::RoundRobin
32
+ config.default_rollout = Gitlab::Experiment::Rollout::First
33
+
34
+ # Logic this project uses to determine inclusion in a given experiment.
35
+ #
36
+ # Expected to return a boolean value.
29
37
  #
30
38
  # This block is executed within the scope of the experiment and so can access
31
39
  # experiment methods, like `name`, `context`, and `signature`.
32
- config.variant_resolver = lambda do |requested_variant|
33
- # Run the control, unless a variant was requested in code:
34
- requested_variant
35
-
36
- # Run the candidate, unless a variant was requested, with a fallback:
37
- #
38
- # requested_variant || variant_names.first || nil
40
+ config.inclusion_resolver = lambda do |requested_variant|
41
+ false
39
42
  end
40
43
 
41
44
  # Tracking behavior can be implemented to link an event to an experiment.
@@ -3,11 +3,15 @@
3
3
  require 'scientist'
4
4
  require 'active_support/callbacks'
5
5
  require 'active_support/cache'
6
+ require 'active_support/concern'
6
7
  require 'active_support/core_ext/object/blank'
7
8
  require 'active_support/core_ext/string/inflections'
9
+ require 'active_support/core_ext/module/delegation'
8
10
 
11
+ require 'gitlab/experiment/base_interface'
9
12
  require 'gitlab/experiment/cache'
10
13
  require 'gitlab/experiment/callbacks'
14
+ require 'gitlab/experiment/rollout'
11
15
  require 'gitlab/experiment/configuration'
12
16
  require 'gitlab/experiment/cookies'
13
17
  require 'gitlab/experiment/context'
@@ -18,57 +22,29 @@ require 'gitlab/experiment/engine' if defined?(Rails::Engine)
18
22
 
19
23
  module Gitlab
20
24
  class Experiment
21
- include Scientist::Experiment
25
+ include BaseInterface
22
26
  include Cache
23
27
  include Callbacks
24
28
 
25
29
  class << self
26
- def configure
27
- yield Configuration
28
- end
29
-
30
- def run(name = nil, variant_name = nil, **context, &block)
31
- raise ArgumentError, 'name is required' if name.nil? && base?
32
-
33
- instance = constantize(name).new(name, variant_name, **context, &block)
34
- return instance unless block
35
-
36
- instance.context.frozen? ? instance.run : instance.tap(&:run)
37
- end
38
-
39
- def experiment_name(name = nil, suffix: true, suffix_word: 'experiment')
40
- name = (name.presence || self.name).to_s.underscore.sub(%r{(?<char>[_/]|)#{suffix_word}$}, '')
41
- name = "#{name}#{Regexp.last_match(:char) || '_'}#{suffix_word}"
42
- suffix ? name : name.sub(/_#{suffix_word}$/, '')
43
- end
30
+ def default_rollout(rollout = nil)
31
+ return @rollout ||= Configuration.default_rollout if rollout.blank?
44
32
 
45
- def base?
46
- self == Gitlab::Experiment || name == Configuration.base_class
47
- end
48
-
49
- private
50
-
51
- def constantize(name = nil)
52
- return self if name.nil?
53
-
54
- experiment_name(name).classify.safe_constantize || Configuration.base_class.constantize
33
+ @rollout = Rollout.resolve(rollout)
55
34
  end
56
35
  end
57
36
 
58
- def initialize(name = nil, variant_name = nil, **context)
59
- raise ArgumentError, 'name is required' if name.blank? && self.class.base?
60
-
61
- @name = self.class.experiment_name(name, suffix: false)
62
- @context = Context.new(self, **context)
63
- @variant_name = cache_variant(variant_name) { nil } if variant_name.present?
64
-
65
- compare { false }
37
+ def name
38
+ [Configuration.name_prefix, @name].compact.join('_')
39
+ end
66
40
 
67
- yield self if block_given?
41
+ def use(&block)
42
+ try(:control, &block)
68
43
  end
69
44
 
70
- def inspect
71
- "#<#{self.class.name || 'AnonymousClass'}:#{format('0x%016X', __id__)} @name=#{name} @context=#{context.value}>"
45
+ def try(name = nil, &block)
46
+ name = (name || :candidate).to_s
47
+ behaviors[name] = block
72
48
  end
73
49
 
74
50
  def context(value = nil)
@@ -99,6 +75,12 @@ module Gitlab
99
75
  @resolving_variant = false
100
76
  end
101
77
 
78
+ def rollout(rollout = nil)
79
+ return @rollout ||= self.class.default_rollout if rollout.blank?
80
+
81
+ @rollout = Rollout.resolve(rollout)
82
+ end
83
+
102
84
  def run(variant_name = nil)
103
85
  @result ||= super(variant(variant_name).name)
104
86
  end
@@ -113,41 +95,6 @@ module Gitlab
113
95
  instance_exec(action, event_args, &Configuration.tracking_behavior)
114
96
  end
115
97
 
116
- def name
117
- [Configuration.name_prefix, @name].compact.join('_')
118
- end
119
-
120
- def variant_names
121
- @variant_names ||= behaviors.keys.map(&:to_sym) - [:control]
122
- end
123
-
124
- def behaviors
125
- @behaviors ||= public_methods.each_with_object(super) do |name, behaviors|
126
- next unless name.end_with?('_behavior')
127
-
128
- behavior_name = name.to_s.sub(/_behavior$/, '')
129
- behaviors[behavior_name] ||= -> { send(name) } # rubocop:disable GitlabSecurity/PublicSend
130
- end
131
- end
132
-
133
- def try(name = nil, &block)
134
- name = (name || 'candidate').to_s
135
- behaviors[name] = block
136
- end
137
-
138
- def signature
139
- { variant: variant.name, experiment: name }.merge(context.signature)
140
- end
141
-
142
- def id
143
- "#{name}:#{key_for(context.value)}"
144
- end
145
- alias_method :session_id, :id
146
-
147
- def flipper_id
148
- "Experiment;#{id}"
149
- end
150
-
151
98
  def enabled?
152
99
  true
153
100
  end
@@ -159,10 +106,18 @@ module Gitlab
159
106
  !run_callbacks(:exclusion_check) { :not_excluded } # didn't pass exclusion check
160
107
  end
161
108
 
109
+ def experiment_group?
110
+ instance_exec(@variant_name, &Configuration.inclusion_resolver)
111
+ end
112
+
162
113
  def should_track?
163
114
  enabled? && !excluded?
164
115
  end
165
116
 
117
+ def signature
118
+ { variant: variant.name, experiment: name }.merge(context.signature)
119
+ end
120
+
166
121
  def key_for(hash)
167
122
  instance_exec(hash, &Configuration.context_hash_strategy)
168
123
  end
@@ -175,17 +130,8 @@ module Gitlab
175
130
  :unsegmented
176
131
  end
177
132
 
178
- def variant_assigned?
179
- !@variant_name.nil?
180
- end
181
-
182
133
  def resolve_variant_name
183
- instance_exec(@variant_name, &Configuration.variant_resolver)
184
- end
185
-
186
- def generate_result(variant_name)
187
- observation = Scientist::Observation.new(variant_name, self, &behaviors[variant_name])
188
- Scientist::Result.new(self, [observation], observation)
134
+ rollout.new(self).execute if experiment_group?
189
135
  end
190
136
  end
191
137
  end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ class Experiment
5
+ module BaseInterface
6
+ extend ActiveSupport::Concern
7
+
8
+ # don't `include` here so we don't override the default scientist class
9
+ Scientist::Experiment.send(:append_features, self) # rubocop:disable GitlabSecurity/PublicSend
10
+
11
+ class_methods do
12
+ include Scientist::Experiment::RaiseOnMismatch
13
+
14
+ def configure
15
+ yield Configuration
16
+ end
17
+
18
+ def experiment_name(name = nil, suffix: true, suffix_word: 'experiment')
19
+ name = (name.presence || self.name).to_s.underscore.sub(%r{(?<char>[_/]|)#{suffix_word}$}, '')
20
+ name = "#{name}#{Regexp.last_match(:char) || '_'}#{suffix_word}"
21
+ suffix ? name : name.sub(/_#{suffix_word}$/, '')
22
+ end
23
+
24
+ def base?
25
+ self == Gitlab::Experiment || name == Configuration.base_class
26
+ end
27
+
28
+ def constantize(name = nil)
29
+ return self if name.nil?
30
+
31
+ experiment_name(name).classify.safe_constantize || Configuration.base_class.constantize
32
+ end
33
+ end
34
+
35
+ def initialize(name = nil, variant_name = nil, **context)
36
+ raise ArgumentError, 'name is required' if name.blank? && self.class.base?
37
+
38
+ @name = self.class.experiment_name(name, suffix: false)
39
+ @context = Context.new(self, **context)
40
+ @variant_name = cache_variant(variant_name) { nil } if variant_name.present?
41
+
42
+ compare { false }
43
+
44
+ yield self if block_given?
45
+ end
46
+
47
+ def inspect
48
+ "#<#{self.class.name || 'AnonymousClass'}:#{format('0x%016X', __id__)} @name=#{name} @context=#{context.value}>"
49
+ end
50
+
51
+ def id
52
+ "#{name}:#{key_for(context.value)}"
53
+ end
54
+ alias_method :session_id, :id
55
+
56
+ def flipper_id
57
+ "Experiment;#{id}"
58
+ end
59
+
60
+ def variant_names
61
+ @variant_names ||= behaviors.keys.map(&:to_sym) - [:control]
62
+ end
63
+
64
+ def behaviors
65
+ @behaviors ||= public_methods.each_with_object(super) do |name, behaviors|
66
+ next unless name.end_with?('_behavior')
67
+
68
+ behavior_name = name.to_s.sub(/_behavior$/, '')
69
+ behaviors[behavior_name] ||= -> { send(name) } # rubocop:disable GitlabSecurity/PublicSend
70
+ end
71
+ end
72
+
73
+ protected
74
+
75
+ def variant_assigned?
76
+ !@variant_name.nil?
77
+ end
78
+
79
+ def generate_result(variant_name)
80
+ observation = Scientist::Observation.new(variant_name, self, &behaviors[variant_name])
81
+ Scientist::Result.new(self, [observation], observation)
82
+ end
83
+ end
84
+ end
85
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'active_support/cache'
4
+
3
5
  module Gitlab
4
6
  class Experiment
5
7
  module Cache
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'active_support/callbacks'
4
+
3
5
  module Gitlab
4
6
  class Experiment
5
7
  module Callbacks
@@ -4,6 +4,8 @@ require 'singleton'
4
4
  require 'logger'
5
5
  require 'digest'
6
6
 
7
+ require 'active_support/deprecation'
8
+
7
9
  module Gitlab
8
10
  class Experiment
9
11
  class Configuration
@@ -24,10 +26,13 @@ module Gitlab
24
26
  # The domain to use on cookies.
25
27
  @cookie_domain = :all
26
28
 
27
- # Logic this project uses to resolve a variant for a given experiment.
28
- # If no variant is determined, the control will be used.
29
- @variant_resolver = lambda do |requested_variant|
30
- requested_variant
29
+ # The default rollout strategy that works for single and multi-variants.
30
+ @default_rollout = Rollout::First
31
+
32
+ # Logic this project uses to determine inclusion in a given experiment.
33
+ # Expected to return a boolean value.
34
+ @inclusion_resolver = lambda do |requested_variant|
35
+ false
31
36
  end
32
37
 
33
38
  # Tracking behavior can be implemented to link an event to an experiment.
@@ -47,13 +52,27 @@ module Gitlab
47
52
  end
48
53
 
49
54
  class << self
55
+ # TODO: Added deprecation in release 0.5.0
56
+ def variant_resolver
57
+ ActiveSupport::Deprecation.warn('variant_resolver is deprecated, instead use `inclusion_resolver` with a' \
58
+ 'block that returns a boolean.')
59
+ @inclusion_resolver
60
+ end
61
+
62
+ def variant_resolver=(block)
63
+ ActiveSupport::Deprecation.warn('variant_resolver is deprecated, instead use `inclusion_resolver` with a' \
64
+ 'block that returns a boolean.')
65
+ @inclusion_resolver = block
66
+ end
67
+
50
68
  attr_accessor(
51
69
  :name_prefix,
52
70
  :logger,
53
71
  :base_class,
54
72
  :cache,
55
73
  :cookie_domain,
56
- :variant_resolver,
74
+ :default_rollout,
75
+ :inclusion_resolver,
57
76
  :tracking_behavior,
58
77
  :publishing_behavior,
59
78
  :context_hash_strategy
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'gitlab/experiment/cookies'
4
+
3
5
  module Gitlab
4
6
  class Experiment
5
7
  class Context
@@ -4,8 +4,17 @@ module Gitlab
4
4
  class Experiment
5
5
  module Dsl
6
6
  def experiment(name, variant_name = nil, **context, &block)
7
+ raise ArgumentError, 'name is required' if name.nil?
8
+
7
9
  context[:request] ||= request if respond_to?(:request)
8
- Experiment.run(name, variant_name, **context, &block)
10
+
11
+ base = Configuration.base_class.constantize
12
+ klass = base.constantize(name) || base
13
+
14
+ instance = klass.new(name, variant_name, **context, &block)
15
+ return instance unless block
16
+
17
+ instance.context.frozen? ? instance.run : instance.tap(&:run)
9
18
  end
10
19
  end
11
20
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ class Experiment
5
+ module Rollout
6
+ autoload :First, 'gitlab/experiment/rollout/first.rb' # default strategy
7
+ autoload :Random, 'gitlab/experiment/rollout/random.rb'
8
+ autoload :RoundRobin, 'gitlab/experiment/rollout/round_robin.rb'
9
+
10
+ def self.resolve(klass)
11
+ return "#{name}::#{klass.to_s.classify}".constantize if klass.is_a?(Symbol) || klass.is_a?(String)
12
+
13
+ klass
14
+ end
15
+
16
+ class Base
17
+ attr_reader :experiment
18
+
19
+ delegate :variant_names, :cache, to: :experiment
20
+
21
+ def initialize(experiment)
22
+ @experiment = experiment
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ class Experiment
5
+ module Rollout
6
+ class First < Base
7
+ # This rollout strategy just picks the first variant name. It's the
8
+ # default resolver as it assumes a single variant. You should consider
9
+ # using a more advanced rollout if you have multiple variants.
10
+ def execute
11
+ variant_names.first
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ class Experiment
5
+ module Rollout
6
+ class Random < Base
7
+ # Pick a random variant if we're in the experiment group. It doesn't
8
+ # take into account small sample sizes but is useful and performant.
9
+ def execute
10
+ variant_names.sample
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ class Experiment
5
+ module Rollout
6
+ class RoundRobin < Base
7
+ KEY_NAME = :last_round_robin_variant
8
+
9
+ # Requires a cache to be configured.
10
+ #
11
+ # Keeps track of the number of assignments into the experiment group,
12
+ # and uses this to rotate "round robin" style through the variants
13
+ # that are defined.
14
+ #
15
+ # Relatively performant, but requires a cache, and is dependent on the
16
+ # performance of that cache store.
17
+ def execute
18
+ variant_names[(cache.attr_inc(KEY_NAME) - 1) % variant_names.size]
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -6,14 +6,19 @@ module Gitlab
6
6
  def stub_experiments(experiments)
7
7
  experiments.each do |name, variant|
8
8
  variant = :control if variant == false
9
- raise ArgumentError, 'variant must be a symbol or false' unless variant.is_a?(Symbol)
10
9
 
11
- klass = Gitlab::Experiment.send(:constantize, name) # rubocop:disable GitlabSecurity/PublicSend
10
+ base = Configuration.base_class.constantize
11
+ klass = base.constantize(name) || base
12
12
 
13
13
  # We have to use this high level any_instance behavior as there's
14
14
  # not an alternative that allows multiple wrappings of `new`.
15
15
  allow_any_instance_of(klass).to receive(:enabled?).and_return(true)
16
- allow_any_instance_of(klass).to receive(:resolve_variant_name).and_return(variant.to_s)
16
+
17
+ if variant == true # passing true allows the rollout to do its job
18
+ allow_any_instance_of(klass).to receive(:experiment_group?).and_return(true)
19
+ else
20
+ allow_any_instance_of(klass).to receive(:resolve_variant_name).and_return(variant.to_s)
21
+ end
17
22
  end
18
23
  end
19
24
 
@@ -147,14 +152,16 @@ module Gitlab
147
152
  end
148
153
  end
149
154
 
150
- def receive_tracking_call_for(event, *event_args)
151
- receive(:track).with(*[event, *event_args]) do # rubocop:disable CodeReuse/ActiveRecord
155
+ def receive_tracking_call_for(expected_event, *expected_event_args)
156
+ receive(:track).with(*[expected_event, *expected_event_args]).and_wrap_original do |track, event, *event_args|
157
+ track.call(event, *event_args) # call the original
158
+
152
159
  if @expected_variant
153
- expect(@experiment.variant.name).to eq(@expected_variant), failure_message(:variant, event)
160
+ expect(@experiment.variant.name).to eq(@expected_variant), failure_message(:variant, expected_event)
154
161
  end
155
162
 
156
163
  if @expected_context
157
- expect(@experiment.context.value).to include(@expected_context), failure_message(:context, event)
164
+ expect(@experiment.context.value).to include(@expected_context), failure_message(:context, expected_event)
158
165
  end
159
166
  end
160
167
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Gitlab
4
4
  class Experiment
5
- VERSION = '0.4.12'
5
+ VERSION = '0.5.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: 0.4.12
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - GitLab
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-02-17 00:00:00.000000000 Z
11
+ date: 2021-03-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -65,6 +65,7 @@ files:
65
65
  - lib/generators/test_unit/experiment/experiment_generator.rb
66
66
  - lib/generators/test_unit/experiment/templates/experiment_test.rb.tt
67
67
  - lib/gitlab/experiment.rb
68
+ - lib/gitlab/experiment/base_interface.rb
68
69
  - lib/gitlab/experiment/cache.rb
69
70
  - lib/gitlab/experiment/cache/redis_hash_store.rb
70
71
  - lib/gitlab/experiment/callbacks.rb
@@ -73,6 +74,10 @@ files:
73
74
  - lib/gitlab/experiment/cookies.rb
74
75
  - lib/gitlab/experiment/dsl.rb
75
76
  - lib/gitlab/experiment/engine.rb
77
+ - lib/gitlab/experiment/rollout.rb
78
+ - lib/gitlab/experiment/rollout/first.rb
79
+ - lib/gitlab/experiment/rollout/random.rb
80
+ - lib/gitlab/experiment/rollout/round_robin.rb
76
81
  - lib/gitlab/experiment/rspec.rb
77
82
  - lib/gitlab/experiment/variant.rb
78
83
  - lib/gitlab/experiment/version.rb