gitlab-experiment 0.5.0 → 0.6.1

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: 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