gitlab-experiment 0.6.4 → 0.7.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -4,86 +4,227 @@ require 'singleton'
4
4
  require 'logger'
5
5
  require 'digest'
6
6
 
7
- require 'active_support/deprecation'
8
-
9
7
  module Gitlab
10
8
  class Experiment
11
9
  class Configuration
12
10
  include Singleton
13
11
 
14
- # Prefix all experiment names with a given value. Use `nil` for none.
12
+ # Prefix all experiment names with a given string value.
13
+ # Use `nil` for no prefix.
15
14
  @name_prefix = nil
16
15
 
17
- # The logger is used to log various details of the experiments.
16
+ # The logger can be used to log various details of the experiments.
18
17
  @logger = Logger.new($stdout)
19
18
 
20
19
  # The base class that should be instantiated for basic experiments.
20
+ # It should be a string, so we can constantize it later.
21
21
  @base_class = 'Gitlab::Experiment'
22
22
 
23
- # The caching layer is expected to respond to fetch, like Rails.cache.
23
+ # Require experiments to be defined in a class, with variants registered.
24
+ # This will disallow any anonymous experiments that are run inline
25
+ # without previously defining a class.
26
+ @strict_registration = false
27
+
28
+ # The caching layer is expected to match the Rails.cache interface.
29
+ # If no cache is provided some rollout strategies may behave differently.
30
+ # Use `nil` for no caching.
24
31
  @cache = nil
25
32
 
26
33
  # The domain to use on cookies.
34
+ #
35
+ # When not set, it uses the current host. If you want to provide specific
36
+ # hosts, you use `:all`, or provide an array.
37
+ #
38
+ # Examples:
39
+ # nil, :all, or ['www.gitlab.com', '.gitlab.com']
27
40
  @cookie_domain = :all
28
41
 
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
42
+ # The default rollout strategy.
43
+ #
44
+ # The recommended default rollout strategy when not using caching would
45
+ # be `Gitlab::Experiment::Rollout::Percent` as that will consistently
46
+ # assign the same variant with or without caching.
47
+ #
48
+ # Gitlab::Experiment::Rollout::Base can be inherited to implement your
49
+ # own rollout strategies.
50
+ #
51
+ # Each experiment can specify its own rollout strategy:
52
+ #
53
+ # class ExampleExperiment < ApplicationExperiment
54
+ # default_rollout :random, # :percent, :round_robin,
55
+ # include_control: true # or MyCustomRollout
56
+ # end
57
+ #
58
+ # Included rollout strategies:
59
+ # :percent, (recommended), :round_robin, or :random
60
+ @default_rollout = Rollout.resolve(:percent, include_control: true)
33
61
 
34
62
  # Secret seed used in generating context keys.
63
+ #
64
+ # You'll typically want to use an environment variable or secret value
65
+ # for this.
66
+ #
67
+ # Consider not using one that's shared with other systems, like Rails'
68
+ # SECRET_KEY_BASE for instance. Generate a new secret and utilize that
69
+ # instead.
35
70
  @context_key_secret = nil
36
71
 
37
- # Bit length used by SHA2 in generating context keys - (256, 384 or 512.)
72
+ # Bit length used by SHA2 in generating context keys.
73
+ #
74
+ # Using a higher bit length would require more computation time.
75
+ #
76
+ # Valid bit lengths:
77
+ # 256, 384, or 512
38
78
  @context_key_bit_length = 256
39
79
 
40
80
  # The default base path that the middleware (or rails engine) will be
41
- # mounted.
81
+ # mounted. The middleware enables an instrumentation url, that's similar
82
+ # to links that can be instrumented in email campaigns.
83
+ #
84
+ # Use `nil` if you don't want to mount the middleware.
85
+ #
86
+ # Examples:
87
+ # '/-/experiment', '/redirect', nil
42
88
  @mount_at = nil
43
89
 
44
- # The middleware won't redirect to urls that aren't considered valid.
45
- # Expected to return a boolean value.
46
- @redirect_url_validator = ->(_redirect_url) { true }
47
-
48
- # Logic this project uses to determine inclusion in a given experiment.
90
+ # When using the middleware, links can be instrumented and redirected
91
+ # elsewhere. This can be exploited to make a harmful url look innocuous
92
+ # or that it's a valid url on your domain. To avoid this, you can provide
93
+ # your own logic for what urls will be considered valid and redirected
94
+ # to.
95
+ #
49
96
  # Expected to return a boolean value.
50
- @inclusion_resolver = ->(_requested_variant) { false }
97
+ @redirect_url_validator = lambda do |_redirect_url|
98
+ true
99
+ end
51
100
 
52
101
  # Tracking behavior can be implemented to link an event to an experiment.
102
+ #
103
+ # This block is executed within the scope of the experiment and so can
104
+ # access experiment methods, like `name`, `context`, and `signature`.
53
105
  @tracking_behavior = lambda do |event, args|
106
+ # An example of using a generic logger to track events:
54
107
  Configuration.logger.info("#{self.class.name}[#{name}] #{event}: #{args.merge(signature: signature)}")
108
+
109
+ # Using something like snowplow to track events (in gitlab):
110
+ #
111
+ # Gitlab::Tracking.event(name, event, **args.merge(
112
+ # context: (args[:context] || []) << SnowplowTracker::SelfDescribingJson.new(
113
+ # 'iglu:com.gitlab/gitlab_experiment/jsonschema/0-2-0', signature
114
+ # )
115
+ # ))
116
+ end
117
+
118
+ # Logic designed to respond when a given experiment is nested within
119
+ # another experiment. This can be useful to identify overlaps and when a
120
+ # code path leads to an experiment being nested within another.
121
+ #
122
+ # Reporting complexity can arise when one experiment changes rollout, and
123
+ # a downstream experiment is impacted by that.
124
+ #
125
+ # The base_class or a custom experiment can provide a `nest_experiment`
126
+ # method that implements its own logic that may allow certain experiments
127
+ # to be nested within it.
128
+ #
129
+ # This block is executed within the scope of the experiment and so can
130
+ # access experiment methods, like `name`, `context`, and `signature`.
131
+ #
132
+ # The default exception will include the where the experiment calls were
133
+ # initiated on, so for instance:
134
+ #
135
+ # Gitlab::Experiment::NestingError: unable to nest level2 within level1:
136
+ # level1 initiated by file_name.rb:2
137
+ # level2 initiated by file_name.rb:3
138
+ @nested_behavior = lambda do |nested_experiment|
139
+ raise NestingError.new(experiment: self, nested_experiment: nested_experiment)
55
140
  end
56
141
 
57
142
  # Called at the end of every experiment run, with the result.
143
+ #
144
+ # You may want to track that you've assigned a variant to a given
145
+ # context, or push the experiment into the client or publish results
146
+ # elsewhere like into redis.
147
+ #
148
+ # This block is executed within the scope of the experiment and so can
149
+ # access experiment methods, like `name`, `context`, and `signature`.
58
150
  @publishing_behavior = lambda do |_result|
151
+ # Track the event using our own configured tracking logic.
59
152
  track(:assignment)
153
+
154
+ # Log using our logging system, so the result (which can be large) can
155
+ # be reviewed later if we want to.
156
+ #
157
+ # Lograge::Event.log(experiment: name, result: result, signature: signature)
158
+
159
+ # Experiments that have been run during the request lifecycle can be
160
+ # pushed to the client layer by injecting the published experiments
161
+ # into javascript in a layout or view using something like:
162
+ #
163
+ # = javascript_tag(nonce: content_security_policy_nonce) do
164
+ # window.experiments = #{raw Gitlab::Experiment.published_experiments.to_json};
60
165
  end
61
166
 
62
167
  class << self
63
- # TODO: Added deprecation in release 0.6.0
168
+ # @deprecated
64
169
  def context_hash_strategy=(block)
65
- ActiveSupport::Deprecation.warn('context_hash_strategy has been deprecated, instead configure' \
66
- ' `context_key_secret` and `context_key_bit_length`.')
170
+ deprecated(
171
+ :context_hash_strategy,
172
+ 'instead use `context_key_secret` and `context_key_bit_length`',
173
+ version: '0.7.0'
174
+ )
175
+
67
176
  @__context_hash_strategy = block
68
177
  end
69
178
 
70
- # TODO: Added deprecation in release 0.5.0
179
+ # @deprecated
71
180
  def variant_resolver
72
- ActiveSupport::Deprecation.warn('variant_resolver is deprecated, instead use `inclusion_resolver` with a' \
73
- ' block that returns a boolean.')
74
- @inclusion_resolver
181
+ deprecated(
182
+ :variant_resolver,
183
+ 'instead use `inclusion_resolver` with a block that returns a boolean',
184
+ version: '0.6.5'
185
+ )
186
+
187
+ @__inclusion_resolver
75
188
  end
76
189
 
190
+ # @deprecated
77
191
  def variant_resolver=(block)
78
- ActiveSupport::Deprecation.warn('variant_resolver is deprecated, instead use `inclusion_resolver` with a' \
79
- ' block that returns a boolean.')
80
- @inclusion_resolver = block
192
+ deprecated(
193
+ :variant_resolver,
194
+ 'instead use `inclusion_resolver` with a block that returns a boolean',
195
+ version: '0.6.5'
196
+ )
197
+
198
+ @__inclusion_resolver = block
199
+ end
200
+
201
+ # @deprecated
202
+ def inclusion_resolver=(block)
203
+ deprecated(
204
+ :inclusion_resolver,
205
+ 'instead put this logic into custom rollout strategies',
206
+ version: '0.7.0'
207
+ )
208
+
209
+ @__inclusion_resolver = block
210
+ end
211
+
212
+ # @deprecated
213
+ def inclusion_resolver
214
+ deprecated(
215
+ :inclusion_resolver,
216
+ 'instead put this logic into custom rollout strategies',
217
+ version: '0.7.0'
218
+ )
219
+
220
+ @__inclusion_resolver
81
221
  end
82
222
 
83
223
  attr_accessor(
84
224
  :name_prefix,
85
225
  :logger,
86
226
  :base_class,
227
+ :strict_registration,
87
228
  :cache,
88
229
  :cookie_domain,
89
230
  :context_key_secret,
@@ -91,10 +232,50 @@ module Gitlab
91
232
  :mount_at,
92
233
  :default_rollout,
93
234
  :redirect_url_validator,
94
- :inclusion_resolver,
95
235
  :tracking_behavior,
236
+ :nested_behavior,
96
237
  :publishing_behavior
97
238
  )
239
+
240
+ # Attribute method overrides.
241
+
242
+ def default_rollout=(args) # rubocop:disable Lint/DuplicateMethods
243
+ rollout, options = Array(args)
244
+ if rollout.is_a?(Rollout::Base)
245
+ options = rollout.options
246
+ rollout = rollout.class
247
+
248
+ deprecated(<<~MESSAGE, version: '0.7.0')
249
+ using a rollout instance with `default_rollout` is deprecated and will be removed from {{release}} (instead use `default_rollout = #{rollout.name}, #{options.inspect}`)
250
+ MESSAGE
251
+ end
252
+
253
+ @default_rollout = Rollout.resolve(rollout, options || {})
254
+ end
255
+
256
+ # Internal warning helpers.
257
+
258
+ def deprecated(*args, version:, stack: 0)
259
+ deprecator = deprecator(version)
260
+ args << args.pop.to_s.gsub('{{release}}', "#{deprecator.gem_name} #{deprecator.deprecation_horizon}")
261
+ args << caller_locations(4 + stack)
262
+
263
+ if args.length == 2
264
+ deprecator.warn(*args)
265
+ else
266
+ args[0] = "`#{args[0]}`"
267
+ deprecator.deprecation_warning(*args)
268
+ end
269
+ end
270
+
271
+ private
272
+
273
+ def deprecator(version = VERSION)
274
+ version = Gem::Version.new(version).bump.to_s
275
+
276
+ @__dep_versions ||= {}
277
+ @__dep_versions[version] ||= ActiveSupport::Deprecation.new(version, 'Gitlab::Experiment')
278
+ end
98
279
  end
99
280
  end
100
281
  end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'gitlab/experiment/cookies'
4
-
5
3
  module Gitlab
6
4
  class Experiment
7
5
  class Context
@@ -27,6 +25,7 @@ module Gitlab
27
25
 
28
26
  value = value.dup # dup so we don't mutate
29
27
  reinitialize(value.delete(:request))
28
+ key(value.delete(:sticky_to))
30
29
 
31
30
  @value.merge!(process_migrations(value))
32
31
  end
@@ -34,7 +33,7 @@ module Gitlab
34
33
  def key(key = nil)
35
34
  return @key || @experiment.key_for(value) if key.nil?
36
35
 
37
- @key = key
36
+ @key = @experiment.key_for(key)
38
37
  end
39
38
 
40
39
  def trackable?
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'securerandom'
4
-
5
3
  module Gitlab
6
4
  class Experiment
7
5
  module Cookies
@@ -6,7 +6,8 @@ module Gitlab
6
6
  class Experiment
7
7
  include ActiveModel::Model
8
8
 
9
- # used for generating routes
9
+ # Used for generating routes. We've included the method and `ActiveModel::Model` here because these things don't
10
+ # make sense outside of Rails environments.
10
11
  def self.model_name
11
12
  ActiveModel::Name.new(self, Gitlab)
12
13
  end
@@ -4,5 +4,26 @@ module Gitlab
4
4
  class Experiment
5
5
  Error = Class.new(StandardError)
6
6
  InvalidRolloutRules = Class.new(Error)
7
+ UnregisteredExperiment = Class.new(Error)
8
+ ExistingBehaviorError = Class.new(Error)
9
+ BehaviorMissingError = Class.new(Error)
10
+
11
+ class NestingError < Error
12
+ def initialize(experiment:, nested_experiment:)
13
+ messages = []
14
+ experiments = [nested_experiment, experiment]
15
+
16
+ callers = caller_locations
17
+ callers.select.with_index do |caller, index|
18
+ next if caller.label != 'experiment'
19
+
20
+ messages << " #{experiments[messages.length].name} initiated by #{callers[index + 1]}"
21
+ end
22
+
23
+ messages << ["unable to nest #{nested_experiment.name} within #{experiment.name}:"]
24
+
25
+ super(messages.reverse.join("\n"))
26
+ end
27
+ end
7
28
  end
8
29
  end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ class Experiment
5
+ module Nestable
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ set_callback :run, :around, :manage_nested_stack
10
+ end
11
+
12
+ def nest_experiment(nested_experiment)
13
+ instance_exec(nested_experiment, &Configuration.nested_behavior)
14
+ end
15
+
16
+ private
17
+
18
+ def manage_nested_stack
19
+ Stack.push(self)
20
+ yield
21
+ ensure
22
+ Stack.pop
23
+ end
24
+
25
+ class Stack
26
+ include Singleton
27
+
28
+ delegate :pop, :length, :size, :[], to: :stack
29
+
30
+ class << self
31
+ delegate :pop, :push, :length, :size, :[], to: :instance
32
+ end
33
+
34
+ def initialize
35
+ @thread_key = "#{self.class};#{object_id}".to_sym
36
+ end
37
+
38
+ def push(instance)
39
+ stack.last&.nest_experiment(instance)
40
+ stack.push(instance)
41
+ end
42
+
43
+ private
44
+
45
+ def stack
46
+ Thread.current[@thread_key] ||= []
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -1,30 +1,41 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'zlib'
3
+ require "zlib"
4
4
 
5
+ # The percent rollout strategy is the most comprehensive included with Gitlab::Experiment. It allows specifying the
6
+ # percentages per variant using an array, a hash, or will default to even distribution when no rules are provided.
7
+ #
8
+ # A given experiment id (context key) will always be given the same variant assignment.
9
+ #
10
+ # Example configuration usage:
11
+ #
12
+ # config.default_rollout = Gitlab::Experiment::Rollout::Percent.new
13
+ #
14
+ # Example class usage:
15
+ #
16
+ # class PillColorExperiment < ApplicationExperiment
17
+ # control { }
18
+ # variant(:red) { }
19
+ # variant(:blue) { }
20
+ #
21
+ # # Even distribution between all behaviors.
22
+ # default_rollout :percent
23
+ #
24
+ # # With specific distribution percentages.
25
+ # default_rollout :percent, distribution: { control: 25, red: 30, blue: 45 }
26
+ # end
27
+ #
5
28
  module Gitlab
6
29
  class Experiment
7
30
  module Rollout
8
31
  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
32
+ protected
22
33
 
23
34
  def validate!
24
35
  case distribution_rules
25
36
  when nil then nil
26
37
  when Array, Hash
27
- if distribution_rules.length != variant_names.length
38
+ if distribution_rules.length != behavior_names.length
28
39
  raise InvalidRolloutRules, "the distribution rules don't match the number of variants defined"
29
40
  end
30
41
  else
@@ -32,6 +43,20 @@ module Gitlab
32
43
  end
33
44
  end
34
45
 
46
+ def execute_assignment
47
+ crc = normalized_id
48
+ total = 0
49
+
50
+ case distribution_rules
51
+ when Array # run through the rules until finding an acceptable one
52
+ behavior_names[distribution_rules.find_index { |percent| crc % 100 <= total += percent }]
53
+ when Hash # run through the variant names until finding an acceptable one
54
+ distribution_rules.find { |_, percent| crc % 100 <= total += percent }.first
55
+ else # assume even distribution on no rules
56
+ behavior_names.empty? ? nil : behavior_names[crc % behavior_names.length]
57
+ end
58
+ end
59
+
35
60
  private
36
61
 
37
62
  def normalized_id
@@ -39,7 +64,7 @@ module Gitlab
39
64
  end
40
65
 
41
66
  def distribution_rules
42
- @options[:distribution]
67
+ options[:distribution]
43
68
  end
44
69
  end
45
70
  end
@@ -1,13 +1,34 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # The random rollout strategy will randomly assign a variant when the context is determined to be within the experiment
4
+ # group.
5
+ #
6
+ # If caching is enabled this is a predicable and consistent assignment that will eventually assign a variant (since
7
+ # control isn't cached) but if caching isn't enabled, assignment will be random each time.
8
+ #
9
+ # Example configuration usage:
10
+ #
11
+ # config.default_rollout = Gitlab::Experiment::Rollout::Random.new
12
+ #
13
+ # Example class usage:
14
+ #
15
+ # class PillColorExperiment < ApplicationExperiment
16
+ # control { }
17
+ # variant(:red) { }
18
+ # variant(:blue) { }
19
+ #
20
+ # # Randomize between all behaviors, with a mostly even distribution).
21
+ # default_rollout :random
22
+ # end
23
+ #
3
24
  module Gitlab
4
25
  class Experiment
5
26
  module Rollout
6
27
  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
28
+ protected
29
+
30
+ def execute_assignment
31
+ behavior_names.sample # pick a random variant
11
32
  end
12
33
  end
13
34
  end
@@ -1,21 +1,38 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # The round robin strategy will assign the next variant in the list, looping back to the first variant after all
4
+ # variants have been assigned. This is useful for very small sample sizes where very even distribution can be required.
5
+ #
6
+ # Requires a cache to be configured.
7
+ #
8
+ # Keeps track of the number of assignments into the experiment group, and uses this to rotate "round robin" style
9
+ # through the variants that are defined.
10
+ #
11
+ # Example configuration usage:
12
+ #
13
+ # config.default_rollout = Gitlab::Experiment::Rollout::RoundRobin.new
14
+ #
15
+ # Example class usage:
16
+ #
17
+ # class PillColorExperiment < ApplicationExperiment
18
+ # control { }
19
+ # variant(:red) { }
20
+ # variant(:blue) { }
21
+ #
22
+ # # Rotate evenly between all behaviors.
23
+ # default_rollout :round_robin
24
+ # end
25
+ #
3
26
  module Gitlab
4
27
  class Experiment
5
28
  module Rollout
6
29
  class RoundRobin < Base
7
30
  KEY_NAME = :last_round_robin_variant
8
31
 
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]
32
+ protected
33
+
34
+ def execute_assignment
35
+ behavior_names[(cache.attr_inc(KEY_NAME) - 1) % behavior_names.size]
19
36
  end
20
37
  end
21
38
  end