gitlab-experiment 0.6.4 → 0.7.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.
@@ -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