gitlab-experiment 0.5.0 → 0.6.1

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: 31e4979de3006211bcd1f63882215ae9213c3a22de25e7cca8546f3fce2ef649
4
- data.tar.gz: cb865da8c87f019755194c98e073720f5f246b5a2c57e6d511c2a280d78ff996
3
+ metadata.gz: 0d1ad31e8b977491ccdd1a9d21fc348482d3942c80a3e2935a6ccabc515b4243
4
+ data.tar.gz: bdf9a4b67bb2b5a72931c081fed88ac246853a2e90f4f7f5c08bf7bdb9d8f55e
5
5
  SHA512:
6
- metadata.gz: 7906faeb21deaa6e0be322c94c0fd45ea200b5419ac5aacd2d2656b416ac08c21654e84f0ef3d2db5bb33c819dc73020267488a6f7f1ac045b66591ec04ae334
7
- data.tar.gz: e0f9328d5c8f1eca74681e749e01cc6ccf513d619b3c4aa522c45d08a4692d370a96d97437975285fa26dcebad0a7e0a3a8356724b980902ba1b86c8fa82f0a1
6
+ metadata.gz: dc1cc5077144d4e5514b6804599bba8e6829ad6472e5e362cde88940995b174f9989733fea17da91be92dc172694a4394ee0db7974efba8d6eb4e77e769a57ee
7
+ data.tar.gz: 958cf74fa36b1588fd7f83776e87c7f8c92120cb8a6d421c09a4bd207adffdfcfe69ea295dd1e6a49d371225988f6fde3f559baf64d755a0c9176a33e7efddca
data/README.md CHANGED
@@ -423,6 +423,30 @@ Gitlab::Experiment.configure do |config|
423
423
  end
424
424
  ```
425
425
 
426
+ ### Middleware
427
+
428
+ There are times when you'll need to do link tracking in email templates, or markdown content -- or other places you won't be able to implement tracking. For these cases, gitlab-experiment comes with middleware that will redirect to a given URL while also tracking that the URL was visited.
429
+
430
+ In Rails this middleware is mounted automatically, with a base path of what's been configured for `mount_at`. If this path is empty the middleware won't be mounted at all.
431
+
432
+ Once mounted, the redirect URLs can be generated using the Rails route helpers. If not using Rails, mount the middleware and generate these URLs yourself.
433
+
434
+ ```ruby
435
+ Gitlab::Experiment.configure do |config|
436
+ config.mount_at = '/experiment'
437
+ end
438
+
439
+ ex = experiment(:example, foo: :bar)
440
+
441
+ # using rails path/url helpers
442
+ experiment_redirect_path(ex, 'https//docs.gitlab.com/') # => /experiment/example:[context_key]?https//docs.gitlab.com/
443
+
444
+ # manually
445
+ "#{Gitlab::Experiment.configure.mount_at}/#{ex.to_param}?https//docs.gitlab.com/"
446
+ ```
447
+
448
+ URLS that match the base path will be handled by the middleware and will redirect to the provided redirect path.
449
+
426
450
  ## Testing (rspec support)
427
451
 
428
452
  This gem comes with some rspec helpers and custom matchers. These are in flux at the time of writing.
@@ -18,8 +18,10 @@ Gitlab::Experiment.configure do |config|
18
18
  # The domain to use on cookies.
19
19
  #
20
20
  # When not set, it uses the current host. If you want to provide specific
21
- # hosts, you use `:all`, or provide an array like
22
- # `['www.gitlab.com', '.gitlab.com']`.
21
+ # hosts, you use `:all`, or provide an array.
22
+ #
23
+ # Examples:
24
+ # nil, :all, or ['www.gitlab.com', '.gitlab.com']
23
25
  config.cookie_domain = :all
24
26
 
25
27
  # The default rollout strategy that works for single and multi-variants.
@@ -28,8 +30,31 @@ Gitlab::Experiment.configure do |config|
28
30
  # experiment.
29
31
  #
30
32
  # Examples include:
31
- # Rollout::First, Rollout::Random, Rollout::RoundRobin
32
- config.default_rollout = Gitlab::Experiment::Rollout::First
33
+ # Rollout::Random, or Rollout::RoundRobin
34
+ config.default_rollout = Gitlab::Experiment::Rollout::Percent
35
+
36
+ # Secret seed used in generating context keys.
37
+ #
38
+ # Consider not using one that's shared with other systems, like Rails'
39
+ # SECRET_KEY_BASE. Generate a new secret and utilize that instead.
40
+ @context_key_secret = nil
41
+
42
+ # Bit length used by SHA2 in generating context keys.
43
+ #
44
+ # Using a higher bit length would require more computation time.
45
+ #
46
+ # Valid bit lengths:
47
+ # 256, 384, or 512.
48
+ @context_key_bit_length = 256
49
+
50
+ # The default base path that the middleware (or rails engine) will be
51
+ # mounted. Can be nil if you don't want anything to be mounted automatically.
52
+ #
53
+ # This enables a similar behavior to how links are instrumented in emails.
54
+ #
55
+ # Examples:
56
+ # '/-/experiment', '/redirect', nil
57
+ config.mount_at = '/experiment'
33
58
 
34
59
  # Logic this project uses to determine inclusion in a given experiment.
35
60
  #
@@ -80,17 +105,4 @@ Gitlab::Experiment.configure do |config|
80
105
  #
81
106
  # Lograge::Event.log(experiment: name, result: result, signature: signature)
82
107
  end
83
-
84
- # Algorithm that consistently generates a hash key for a given hash map.
85
- #
86
- # Given a specific context hash map, we need to generate a consistent hash
87
- # key. The logic in here will be used for generating cache keys, and may also
88
- # be used when determining which variant may be presented.
89
- #
90
- # This block is executed within the scope of the experiment and so can access
91
- # experiment methods, like `name`, `context`, and `signature`.
92
- config.context_hash_strategy = lambda do |context|
93
- values = context.values.map { |v| (v.respond_to?(:to_global_id) ? v.to_global_id : v).to_s }
94
- Digest::MD5.hexdigest((context.keys + values).join('|'))
95
- end
96
108
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'scientist'
4
+ require 'request_store'
4
5
  require 'active_support/callbacks'
5
6
  require 'active_support/cache'
6
7
  require 'active_support/concern'
@@ -8,6 +9,7 @@ require 'active_support/core_ext/object/blank'
8
9
  require 'active_support/core_ext/string/inflections'
9
10
  require 'active_support/core_ext/module/delegation'
10
11
 
12
+ require 'gitlab/experiment/errors'
11
13
  require 'gitlab/experiment/base_interface'
12
14
  require 'gitlab/experiment/cache'
13
15
  require 'gitlab/experiment/callbacks'
@@ -16,6 +18,7 @@ require 'gitlab/experiment/configuration'
16
18
  require 'gitlab/experiment/cookies'
17
19
  require 'gitlab/experiment/context'
18
20
  require 'gitlab/experiment/dsl'
21
+ require 'gitlab/experiment/middleware'
19
22
  require 'gitlab/experiment/variant'
20
23
  require 'gitlab/experiment/version'
21
24
  require 'gitlab/experiment/engine' if defined?(Rails::Engine)
@@ -27,10 +30,26 @@ module Gitlab
27
30
  include Callbacks
28
31
 
29
32
  class << self
30
- def default_rollout(rollout = nil)
33
+ def default_rollout(rollout = nil, options = {})
31
34
  return @rollout ||= Configuration.default_rollout if rollout.blank?
32
35
 
33
- @rollout = Rollout.resolve(rollout)
36
+ @rollout = Rollout.resolve(rollout).new(options)
37
+ end
38
+
39
+ def exclude(*filter_list, **options, &block)
40
+ build_callback(:exclusion_check, filter_list.unshift(block), **options) do |target, callback|
41
+ throw(:abort) if target.instance_variable_get(:@excluded) || callback.call(target, nil) == true
42
+ end
43
+ end
44
+
45
+ def segment(*filter_list, variant:, **options, &block)
46
+ build_callback(:segmentation_check, filter_list.unshift(block), **options) do |target, callback|
47
+ target.variant(variant) if target.instance_variable_get(:@variant_name).nil? && callback.call(target, nil)
48
+ end
49
+ end
50
+
51
+ def published_experiments
52
+ RequestStore.store[:published_gitlab_experiments] || {}
34
53
  end
35
54
  end
36
55
 
@@ -38,14 +57,16 @@ module Gitlab
38
57
  [Configuration.name_prefix, @name].compact.join('_')
39
58
  end
40
59
 
41
- def use(&block)
42
- try(:control, &block)
60
+ def control(&block)
61
+ candidate(:control, &block)
43
62
  end
63
+ alias_method :use, :control
44
64
 
45
- def try(name = nil, &block)
65
+ def candidate(name = nil, &block)
46
66
  name = (name || :candidate).to_s
47
67
  behaviors[name] = block
48
68
  end
69
+ alias_method :try, :candidate
49
70
 
50
71
  def context(value = nil)
51
72
  return @context if value.blank?
@@ -59,12 +80,8 @@ module Gitlab
59
80
  return Variant.new(name: (@variant_name || :unresolved).to_s) if @variant_name || @resolving_variant
60
81
 
61
82
  if enabled?
62
- @variant_name ||= :control if excluded?
63
-
64
83
  @resolving_variant = true
65
- if (result = cache_variant(@variant_name) { resolve_variant_name }).present?
66
- @variant_name = result.to_sym
67
- end
84
+ @variant_name = cached_variant_resolver(@variant_name)
68
85
  end
69
86
 
70
87
  run_callbacks(segmentation_callback_chain) do
@@ -75,18 +92,26 @@ module Gitlab
75
92
  @resolving_variant = false
76
93
  end
77
94
 
78
- def rollout(rollout = nil)
79
- return @rollout ||= self.class.default_rollout if rollout.blank?
95
+ def rollout(rollout = nil, options = {})
96
+ return @rollout ||= self.class.default_rollout(nil, options) if rollout.blank?
80
97
 
81
- @rollout = Rollout.resolve(rollout)
98
+ @rollout = Rollout.resolve(rollout).new(options)
99
+ end
100
+
101
+ def exclude!
102
+ @excluded = true
82
103
  end
83
104
 
84
105
  def run(variant_name = nil)
85
106
  @result ||= super(variant(variant_name).name)
107
+ rescue Scientist::BehaviorMissing => e
108
+ raise Error, e
86
109
  end
87
110
 
88
111
  def publish(result)
89
112
  instance_exec(result, &Configuration.publishing_behavior)
113
+
114
+ (RequestStore.store[:published_gitlab_experiments] ||= {})[name] = signature.merge(excluded: excluded?)
90
115
  end
91
116
 
92
117
  def track(action, **event_args)
@@ -102,8 +127,7 @@ module Gitlab
102
127
  def excluded?
103
128
  return @excluded if defined?(@excluded)
104
129
 
105
- @excluded = !@context.trackable? || # adhere to DNT headers
106
- !run_callbacks(:exclusion_check) { :not_excluded } # didn't pass exclusion check
130
+ @excluded = !run_callbacks(:exclusion_check) { :not_excluded }
107
131
  end
108
132
 
109
133
  def experiment_group?
@@ -111,27 +135,41 @@ module Gitlab
111
135
  end
112
136
 
113
137
  def should_track?
114
- enabled? && !excluded?
138
+ enabled? && @context.trackable? && !excluded?
115
139
  end
116
140
 
117
141
  def signature
118
142
  { variant: variant.name, experiment: name }.merge(context.signature)
119
143
  end
120
144
 
121
- def key_for(hash)
122
- instance_exec(hash, &Configuration.context_hash_strategy)
145
+ def key_for(source, seed = name)
146
+ # TODO: Added deprecation in release 0.6.0
147
+ if (block = Configuration.instance_variable_get(:@__context_hash_strategy))
148
+ return instance_exec(source, seed, &block)
149
+ end
150
+
151
+ source = source.keys + source.values if source.is_a?(Hash)
152
+
153
+ ingredients = Array(source).map { |v| identify(v) }
154
+ ingredients.unshift(seed).unshift(Configuration.context_key_secret)
155
+
156
+ Digest::SHA2.new(Configuration.context_key_bit_length).hexdigest(ingredients.join('|'))
123
157
  end
124
158
 
125
159
  protected
126
160
 
161
+ def identify(object)
162
+ (object.respond_to?(:to_global_id) ? object.to_global_id : object).to_s
163
+ end
164
+
127
165
  def segmentation_callback_chain
128
- return :segmentation_check if !variant_assigned? && enabled? && !excluded?
166
+ return :segmentation_check if @variant_name.nil? && enabled? && !excluded?
129
167
 
130
168
  :unsegmented
131
169
  end
132
170
 
133
171
  def resolve_variant_name
134
- rollout.new(self).execute if experiment_group?
172
+ rollout.rollout_for(self) if experiment_group?
135
173
  end
136
174
  end
137
175
  end
@@ -4,13 +4,9 @@ module Gitlab
4
4
  class Experiment
5
5
  module BaseInterface
6
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
7
+ include Scientist::Experiment
10
8
 
11
9
  class_methods do
12
- include Scientist::Experiment::RaiseOnMismatch
13
-
14
10
  def configure
15
11
  yield Configuration
16
12
  end
@@ -30,6 +26,11 @@ module Gitlab
30
26
 
31
27
  experiment_name(name).classify.safe_constantize || Configuration.base_class.constantize
32
28
  end
29
+
30
+ def from_param(id)
31
+ %r{/?(?<name>.*):(?<key>.*)$} =~ id
32
+ Gitlab::Experiment.new(name).tap { |e| e.context.key(key) }
33
+ end
33
34
  end
34
35
 
35
36
  def initialize(name = nil, variant_name = nil, **context)
@@ -49,9 +50,10 @@ module Gitlab
49
50
  end
50
51
 
51
52
  def id
52
- "#{name}:#{key_for(context.value)}"
53
+ "#{name}:#{context.key}"
53
54
  end
54
55
  alias_method :session_id, :id
56
+ alias_method :to_param, :id
55
57
 
56
58
  def flipper_id
57
59
  "Experiment;#{id}"
@@ -72,8 +74,15 @@ module Gitlab
72
74
 
73
75
  protected
74
76
 
75
- def variant_assigned?
76
- !@variant_name.nil?
77
+ def raise_on_mismatches?
78
+ false
79
+ end
80
+
81
+ def cached_variant_resolver(provided_variant)
82
+ return :control if excluded?
83
+
84
+ result = cache_variant(provided_variant) { resolve_variant_name }
85
+ result.to_sym if result.present?
77
86
  end
78
87
 
79
88
  def generate_result(variant_name)
@@ -15,28 +15,17 @@ module Gitlab
15
15
  end
16
16
 
17
17
  class_methods do
18
- def exclude(*filter_list, **options, &block)
19
- filters = filter_list.unshift(block).compact.map do |filter|
20
- result_lambda = ActiveSupport::Callbacks::CallTemplate.build(filter, self).make_lambda
21
- lambda do |target|
22
- throw(:abort) if target.instance_variable_get(:'@excluded') || result_lambda.call(target, nil) == true
23
- end
24
- end
25
-
26
- raise ArgumentError, 'no filters provided' if filters.empty?
27
-
28
- set_callback(:exclusion_check, :before, *filters, options)
29
- end
18
+ private
30
19
 
31
- def segment(*filter_list, variant:, **options, &block)
32
- filters = filter_list.unshift(block).compact.map do |filter|
20
+ def build_callback(chain, filters, **options)
21
+ filters = filters.compact.map do |filter|
33
22
  result_lambda = ActiveSupport::Callbacks::CallTemplate.build(filter, self).make_lambda
34
- ->(target) { target.variant(variant) if !target.variant_assigned? && result_lambda.call(target, nil) }
23
+ ->(target) { yield(target, result_lambda) }
35
24
  end
36
25
 
37
26
  raise ArgumentError, 'no filters provided' if filters.empty?
38
27
 
39
- set_callback(:segmentation_check, :before, *filters, options)
28
+ set_callback(chain, *filters, **options)
40
29
  end
41
30
  end
42
31
  end
@@ -26,8 +26,20 @@ module Gitlab
26
26
  # The domain to use on cookies.
27
27
  @cookie_domain = :all
28
28
 
29
- # The default rollout strategy that works for single and multi-variants.
30
- @default_rollout = Rollout::First
29
+ # The default rollout strategy only works for single variant experiments.
30
+ # It's expected that you use a more advanced rollout for multiple variant
31
+ # experiments.
32
+ @default_rollout = Rollout::Base.new
33
+
34
+ # Secret seed used in generating context keys.
35
+ @context_key_secret = nil
36
+
37
+ # Bit length used by SHA2 in generating context keys - (256, 384 or 512.)
38
+ @context_key_bit_length = 256
39
+
40
+ # The default base path that the middleware (or rails engine) will be
41
+ # mounted.
42
+ @mount_at = '/experiment'
31
43
 
32
44
  # Logic this project uses to determine inclusion in a given experiment.
33
45
  # Expected to return a boolean value.
@@ -45,23 +57,24 @@ module Gitlab
45
57
  track(:assignment)
46
58
  end
47
59
 
48
- # Algorithm that consistently generates a hash key for a given hash map.
49
- @context_hash_strategy = lambda do |hash_map|
50
- values = hash_map.values.map { |v| (v.respond_to?(:to_global_id) ? v.to_global_id : v).to_s }
51
- Digest::MD5.hexdigest(([name] + hash_map.keys + values).join('|'))
52
- end
53
-
54
60
  class << self
61
+ # TODO: Added deprecation in release 0.6.0
62
+ def context_hash_strategy=(block)
63
+ ActiveSupport::Deprecation.warn('context_hash_strategy has been deprecated, instead configure' \
64
+ ' `context_key_secret` and `context_key_bit_length`.')
65
+ @__context_hash_strategy = block
66
+ end
67
+
55
68
  # TODO: Added deprecation in release 0.5.0
56
69
  def variant_resolver
57
70
  ActiveSupport::Deprecation.warn('variant_resolver is deprecated, instead use `inclusion_resolver` with a' \
58
- 'block that returns a boolean.')
71
+ ' block that returns a boolean.')
59
72
  @inclusion_resolver
60
73
  end
61
74
 
62
75
  def variant_resolver=(block)
63
76
  ActiveSupport::Deprecation.warn('variant_resolver is deprecated, instead use `inclusion_resolver` with a' \
64
- 'block that returns a boolean.')
77
+ ' block that returns a boolean.')
65
78
  @inclusion_resolver = block
66
79
  end
67
80
 
@@ -71,11 +84,13 @@ module Gitlab
71
84
  :base_class,
72
85
  :cache,
73
86
  :cookie_domain,
87
+ :context_key_secret,
88
+ :context_key_bit_length,
89
+ :mount_at,
74
90
  :default_rollout,
75
91
  :inclusion_resolver,
76
92
  :tracking_behavior,
77
- :publishing_behavior,
78
- :context_hash_strategy
93
+ :publishing_behavior
79
94
  )
80
95
  end
81
96
  end
@@ -31,6 +31,12 @@ module Gitlab
31
31
  @value.merge!(process_migrations(value))
32
32
  end
33
33
 
34
+ def key(key = nil)
35
+ return @key || @experiment.key_for(value) if key.nil?
36
+
37
+ @key = key
38
+ end
39
+
34
40
  def trackable?
35
41
  !(@request && @request.headers['DNT'].to_s.match?(DNT_REGEXP))
36
42
  end
@@ -41,7 +47,7 @@ module Gitlab
41
47
  end
42
48
 
43
49
  def signature
44
- @signature ||= { key: @experiment.key_for(@value), migration_keys: migration_keys }.compact
50
+ @signature ||= { key: key, migration_keys: migration_keys }.compact
45
51
  end
46
52
 
47
53
  def method_missing(method_name, *)
@@ -15,7 +15,21 @@ module Gitlab
15
15
  ActionMailer::Base.helper_method(:experiment)
16
16
  end
17
17
 
18
+ def add_middleware(app, base_path)
19
+ return if base_path.blank?
20
+
21
+ app.config.middleware.use(Middleware, base_path)
22
+ app.routes.append do
23
+ direct :experiment_redirect do |experiment, to_url|
24
+ [base_path, experiment.to_param].join('/') + "?#{to_url}"
25
+ end
26
+ end
27
+ end
28
+
18
29
  config.after_initialize { include_dsl }
30
+ initializer 'gitlab_experiment.add_middleware' do |app|
31
+ add_middleware(app, Configuration.mount_at)
32
+ end
19
33
  end
20
34
  end
21
35
  end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ class Experiment
5
+ Error = Class.new(StandardError)
6
+ InvalidRolloutRules = Class.new(Error)
7
+ end
8
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ class Experiment
5
+ class Middleware
6
+ def self.redirect(id, url)
7
+ raise Error, 'no url to redirect to' if url.blank?
8
+
9
+ Gitlab::Experiment.from_param(id).tap { |e| e.track('visited', url: url) }
10
+ [303, { 'Location' => url }, []]
11
+ end
12
+
13
+ def initialize(app, base_path)
14
+ @app = app
15
+ @matcher = %r{^#{base_path}/(?<id>.+)}
16
+ end
17
+
18
+ def call(env)
19
+ return @app.call(env) if env['REQUEST_METHOD'] != 'GET' || (match = @matcher.match(env['PATH_INFO'])).nil?
20
+
21
+ Middleware.redirect(match[:id], env['QUERY_STRING'])
22
+ rescue Error
23
+ @app.call(env)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -3,7 +3,7 @@
3
3
  module Gitlab
4
4
  class Experiment
5
5
  module Rollout
6
- autoload :First, 'gitlab/experiment/rollout/first.rb' # default strategy
6
+ autoload :Percent, 'gitlab/experiment/rollout/percent.rb'
7
7
  autoload :Random, 'gitlab/experiment/rollout/random.rb'
8
8
  autoload :RoundRobin, 'gitlab/experiment/rollout/round_robin.rb'
9
9
 
@@ -16,10 +16,25 @@ module Gitlab
16
16
  class Base
17
17
  attr_reader :experiment
18
18
 
19
- delegate :variant_names, :cache, to: :experiment
19
+ delegate :variant_names, :cache, :id, to: :experiment
20
20
 
21
- def initialize(experiment)
21
+ def initialize(options = {})
22
+ @options = options
23
+ # validate! # we want to validate here, but we can't yet
24
+ end
25
+
26
+ def rollout_for(experiment)
22
27
  @experiment = experiment
28
+ validate! # until we have variant registration we can only validate here
29
+ execute
30
+ end
31
+
32
+ def validate!
33
+ # base is always valid
34
+ end
35
+
36
+ def execute
37
+ variant_names.first
23
38
  end
24
39
  end
25
40
  end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zlib'
4
+
5
+ module Gitlab
6
+ class Experiment
7
+ module Rollout
8
+ class Percent < Base
9
+ def execute
10
+ crc = normalized_id
11
+ total = 0
12
+
13
+ case distribution_rules
14
+ # run through the rules until finding an acceptable one
15
+ when Array then variant_names[distribution_rules.find_index { |percent| crc % 100 <= total += percent }]
16
+ # run through the variant names until finding an acceptable one
17
+ when Hash then distribution_rules.find { |_, percent| crc % 100 <= total += percent }.first
18
+ # when there are no rules, assume even distribution
19
+ else variant_names[crc % variant_names.length]
20
+ end
21
+ end
22
+
23
+ def validate!
24
+ case distribution_rules
25
+ when nil then nil
26
+ when Array, Hash
27
+ if distribution_rules.length != variant_names.length
28
+ raise InvalidRolloutRules, "the distribution rules don't match the number of variants defined"
29
+ end
30
+ else
31
+ raise InvalidRolloutRules, 'unknown distribution options type'
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def normalized_id
38
+ Zlib.crc32(id, nil)
39
+ end
40
+
41
+ def distribution_rules
42
+ @options[:distribution]
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -3,44 +3,78 @@
3
3
  module Gitlab
4
4
  class Experiment
5
5
  module RSpecHelpers
6
- def stub_experiments(experiments)
7
- experiments.each do |name, variant|
8
- variant = :control if variant == false
6
+ def stub_experiments(experiments, times = nil)
7
+ experiments.each { |experiment| wrapped_experiment(experiment, times) }
8
+ end
9
+
10
+ def wrapped_experiment(experiment, times = nil, expected = false, &block)
11
+ klass, experiment_name, variant_name = *experiment_details(experiment)
12
+ base_klass = Configuration.base_class.constantize
9
13
 
10
- base = Configuration.base_class.constantize
11
- klass = base.constantize(name) || base
14
+ # Set expectations on experiment classes so we can and_wrap_original with more specific args
15
+ experiment_klasses = base_klass.descendants.reject { |k| k == klass }
16
+ experiment_klasses.push(base_klass).each do |k|
17
+ allow(k).to receive(:new).and_call_original
18
+ end
12
19
 
13
- # We have to use this high level any_instance behavior as there's
14
- # not an alternative that allows multiple wrappings of `new`.
15
- allow_any_instance_of(klass).to receive(:enabled?).and_return(true)
20
+ receiver = receive(:new)
16
21
 
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)
22
+ # Be specific for BaseClass calls
23
+ receiver = receiver.with(experiment_name, any_args) if experiment_name && klass == base_klass
24
+
25
+ receiver.exactly(times).times if times
26
+
27
+ # Set expectations on experiment class of interest
28
+ allow_or_expect_klass = expected ? expect(klass) : allow(klass)
29
+ allow_or_expect_klass.to receiver.and_wrap_original do |method, *original_args, &original_block|
30
+ method.call(*original_args).tap do |e|
31
+ # Stub internal methods before calling the original_block
32
+ allow(e).to receive(:enabled?).and_return(true)
33
+
34
+ if variant_name == true # passing true allows the rollout to do its job
35
+ allow(e).to receive(:experiment_group?).and_return(true)
36
+ else
37
+ allow(e).to receive(:resolve_variant_name).and_return(variant_name.to_s)
38
+ end
39
+
40
+ # Stub/set expectations before calling the original_block
41
+ yield e if block
42
+
43
+ original_block.call(e) if original_block.present?
21
44
  end
22
45
  end
23
46
  end
24
47
 
25
- def wrapped_experiment(experiment, shallow: false, failure: nil, &block)
26
- if shallow
27
- yield experiment if block.present?
28
- return experiment
29
- end
48
+ private
30
49
 
31
- receive_wrapped_new = receive(:new).and_wrap_original do |new, *new_args, &new_block|
32
- instance = new.call(*new_args)
33
- instance.tap(&block) if block.present?
34
- instance.tap(&new_block) if new_block.present?
35
- instance
50
+ def experiment_details(experiment)
51
+ if experiment.is_a?(Symbol)
52
+ experiment_name = experiment
53
+ variant_name = nil
36
54
  end
37
55
 
38
- klass = experiment.class == Class ? experiment : experiment.class
39
- if failure
40
- expect(klass).to receive_wrapped_new, failure
56
+ experiment_name, variant_name = *experiment if experiment.is_a?(Array)
57
+
58
+ base_klass = Configuration.base_class.constantize
59
+ variant_name = experiment.variant.name if experiment.is_a?(base_klass)
60
+
61
+ if experiment.class.name.nil? # Anonymous class instance
62
+ klass = experiment.class
63
+ elsif experiment.instance_of?(Class) # Class level stubbing, eg. "MyExperiment"
64
+ klass = experiment
41
65
  else
42
- allow(klass).to receive_wrapped_new
66
+ experiment_name ||= experiment.instance_variable_get(:@name)
67
+ klass = base_klass.constantize(experiment_name)
43
68
  end
69
+
70
+ if experiment_name && klass == base_klass
71
+ experiment_name = experiment_name.to_sym
72
+
73
+ # For experiment names like: "group/experiment-name"
74
+ experiment_name = experiment_name.to_s if experiment_name.inspect.include?('"')
75
+ end
76
+
77
+ [klass, experiment_name, variant_name]
44
78
  end
45
79
  end
46
80
 
@@ -135,34 +169,41 @@ module Gitlab
135
169
  end
136
170
 
137
171
  chain(:with_context) { |expected_context| @expected_context = expected_context }
138
- chain(:on_any_instance) { @on_self = false }
172
+
173
+ chain(:on_next_instance) { @on_next_instance = true }
139
174
 
140
175
  def expect_tracking_on(experiment, negated, event, *event_args)
141
- @experiment = experiment
142
- @on_self = true if require_experiment(experiment, 'track', classes: !@on_self) && @on_self.nil?
143
- wrapped_experiment(experiment, shallow: @on_self, failure: failure_message(:no_new, event)) do |instance|
144
- @experiment = instance
145
- allow(@experiment).to receive(:track)
176
+ klass = experiment.instance_of?(Class) ? experiment : experiment.class
177
+ unless klass <= Gitlab::Experiment
178
+ raise(
179
+ ArgumentError,
180
+ "track matcher is limited to experiment instances and classes"
181
+ )
182
+ end
183
+
184
+ expectations = proc do |e|
185
+ @experiment = e
186
+ allow(e).to receive(:track).and_call_original
146
187
 
147
188
  if negated
148
- expect(@experiment).not_to receive_tracking_call_for(event, *event_args)
189
+ expect(e).not_to receive(:track).with(*[event, *event_args])
149
190
  else
150
- expect(@experiment).to receive_tracking_call_for(event, *event_args)
151
- end
152
- end
153
- end
191
+ if @expected_variant
192
+ expect(@experiment.variant.name).to eq(@expected_variant), failure_message(:variant, event)
193
+ end
154
194
 
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
195
+ if @expected_context
196
+ expect(@experiment.context.value).to include(@expected_context), failure_message(:context, event)
197
+ end
158
198
 
159
- if @expected_variant
160
- expect(@experiment.variant.name).to eq(@expected_variant), failure_message(:variant, expected_event)
199
+ expect(e).to receive(:track).with(*[event, *event_args]).and_call_original
161
200
  end
201
+ end
162
202
 
163
- if @expected_context
164
- expect(@experiment.context.value).to include(@expected_context), failure_message(:context, expected_event)
165
- end
203
+ if experiment.instance_of?(Class) || @on_next_instance
204
+ wrapped_experiment(experiment, nil, true) { |e| expectations.call(e) }
205
+ else
206
+ expectations.call(experiment)
166
207
  end
167
208
  end
168
209
 
@@ -180,8 +221,6 @@ module Gitlab
180
221
  expected context: #{@expected_context}
181
222
  actual context: #{@experiment.context.value}
182
223
  MESSAGE
183
- when :no_new
184
- %(expected #{@experiment.inspect} to have tracked #{event.inspect}, but no new instances were created)
185
224
  end
186
225
  end
187
226
  end
@@ -193,6 +232,10 @@ RSpec.configure do |config|
193
232
  config.include Gitlab::Experiment::RSpecHelpers
194
233
  config.include Gitlab::Experiment::Dsl
195
234
 
235
+ config.before(:each, :experiment) do
236
+ RequestStore.clear!
237
+ end
238
+
196
239
  config.include Gitlab::Experiment::RSpecMatchers, :experiment
197
240
  config.define_derived_metadata(file_path: Regexp.new('/spec/experiments/')) do |metadata|
198
241
  metadata[:type] = :experiment
@@ -2,6 +2,10 @@
2
2
 
3
3
  module Gitlab
4
4
  class Experiment
5
- Variant = Struct.new(:name, :payload, keyword_init: true)
5
+ Variant = Struct.new(:name, :payload, keyword_init: true) do
6
+ def group
7
+ name == 'control' ? :control : :experiment
8
+ end
9
+ end
6
10
  end
7
11
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Gitlab
4
4
  class Experiment
5
- VERSION = '0.5.0'
5
+ VERSION = '0.6.1'
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.5.0
4
+ version: 0.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - GitLab
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-03-11 00:00:00.000000000 Z
11
+ date: 2021-06-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -24,26 +24,40 @@ dependencies:
24
24
  - - ">="
25
25
  - !ruby/object:Gem::Version
26
26
  version: '3.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: request_store
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '1.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '1.0'
27
41
  - !ruby/object:Gem::Dependency
28
42
  name: scientist
29
43
  requirement: !ruby/object:Gem::Requirement
30
44
  requirements:
31
45
  - - "~>"
32
46
  - !ruby/object:Gem::Version
33
- version: '1.5'
47
+ version: '1.6'
34
48
  - - ">="
35
49
  - !ruby/object:Gem::Version
36
- version: 1.5.0
50
+ version: 1.6.0
37
51
  type: :runtime
38
52
  prerelease: false
39
53
  version_requirements: !ruby/object:Gem::Requirement
40
54
  requirements:
41
55
  - - "~>"
42
56
  - !ruby/object:Gem::Version
43
- version: '1.5'
57
+ version: '1.6'
44
58
  - - ">="
45
59
  - !ruby/object:Gem::Version
46
- version: 1.5.0
60
+ version: 1.6.0
47
61
  description:
48
62
  email:
49
63
  - gitlab_rubygems@gitlab.com
@@ -74,8 +88,10 @@ files:
74
88
  - lib/gitlab/experiment/cookies.rb
75
89
  - lib/gitlab/experiment/dsl.rb
76
90
  - lib/gitlab/experiment/engine.rb
91
+ - lib/gitlab/experiment/errors.rb
92
+ - lib/gitlab/experiment/middleware.rb
77
93
  - lib/gitlab/experiment/rollout.rb
78
- - lib/gitlab/experiment/rollout/first.rb
94
+ - lib/gitlab/experiment/rollout/percent.rb
79
95
  - lib/gitlab/experiment/rollout/random.rb
80
96
  - lib/gitlab/experiment/rollout/round_robin.rb
81
97
  - lib/gitlab/experiment/rspec.rb
@@ -100,7 +116,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
100
116
  - !ruby/object:Gem::Version
101
117
  version: '0'
102
118
  requirements: []
103
- rubygems_version: 3.1.4
119
+ rubygems_version: 3.2.20
104
120
  signing_key:
105
121
  specification_version: 4
106
122
  summary: GitLab experiment library built on top of scientist.
@@ -1,16 +0,0 @@
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