gitlab-experiment 0.6.5 → 0.7.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -4,86 +4,231 @@ 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
+ # Gitlab::Experiment::Rollout::Percent, (recommended)
60
+ # Gitlab::Experiment::Rollout::RoundRobin, or
61
+ # Gitlab::Experiment::Rollout::Random
62
+ @default_rollout = Gitlab::Experiment::Rollout::Percent.new(
63
+ include_control: true # include control in possible assignments
64
+ )
33
65
 
34
66
  # Secret seed used in generating context keys.
67
+ #
68
+ # You'll typically want to use an environment variable or secret value
69
+ # for this.
70
+ #
71
+ # Consider not using one that's shared with other systems, like Rails'
72
+ # SECRET_KEY_BASE for instance. Generate a new secret and utilize that
73
+ # instead.
35
74
  @context_key_secret = nil
36
75
 
37
- # Bit length used by SHA2 in generating context keys - (256, 384 or 512.)
76
+ # Bit length used by SHA2 in generating context keys.
77
+ #
78
+ # Using a higher bit length would require more computation time.
79
+ #
80
+ # Valid bit lengths:
81
+ # 256, 384, or 512
38
82
  @context_key_bit_length = 256
39
83
 
40
84
  # The default base path that the middleware (or rails engine) will be
41
- # mounted.
85
+ # mounted. The middleware enables an instrumentation url, that's similar
86
+ # to links that can be instrumented in email campaigns.
87
+ #
88
+ # Use `nil` if you don't want to mount the middleware.
89
+ #
90
+ # Examples:
91
+ # '/-/experiment', '/redirect', nil
42
92
  @mount_at = nil
43
93
 
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.
94
+ # When using the middleware, links can be instrumented and redirected
95
+ # elsewhere. This can be exploited to make a harmful url look innocuous
96
+ # or that it's a valid url on your domain. To avoid this, you can provide
97
+ # your own logic for what urls will be considered valid and redirected
98
+ # to.
99
+ #
49
100
  # Expected to return a boolean value.
50
- @inclusion_resolver = ->(_requested_variant) { false }
101
+ @redirect_url_validator = lambda do |_redirect_url|
102
+ true
103
+ end
51
104
 
52
105
  # Tracking behavior can be implemented to link an event to an experiment.
106
+ #
107
+ # This block is executed within the scope of the experiment and so can
108
+ # access experiment methods, like `name`, `context`, and `signature`.
53
109
  @tracking_behavior = lambda do |event, args|
110
+ # An example of using a generic logger to track events:
54
111
  Configuration.logger.info("#{self.class.name}[#{name}] #{event}: #{args.merge(signature: signature)}")
112
+
113
+ # Using something like snowplow to track events (in gitlab):
114
+ #
115
+ # Gitlab::Tracking.event(name, event, **args.merge(
116
+ # context: (args[:context] || []) << SnowplowTracker::SelfDescribingJson.new(
117
+ # 'iglu:com.gitlab/gitlab_experiment/jsonschema/0-2-0', signature
118
+ # )
119
+ # ))
120
+ end
121
+
122
+ # Logic designed to respond when a given experiment is nested within
123
+ # another experiment. This can be useful to identify overlaps and when a
124
+ # code path leads to an experiment being nested within another.
125
+ #
126
+ # Reporting complexity can arise when one experiment changes rollout, and
127
+ # a downstream experiment is impacted by that.
128
+ #
129
+ # The base_class or a custom experiment can provide a `nest_experiment`
130
+ # method that implements its own logic that may allow certain experiments
131
+ # to be nested within it.
132
+ #
133
+ # This block is executed within the scope of the experiment and so can
134
+ # access experiment methods, like `name`, `context`, and `signature`.
135
+ #
136
+ # The default exception will include the where the experiment calls were
137
+ # initiated on, so for instance:
138
+ #
139
+ # Gitlab::Experiment::NestingError: unable to nest level2 within level1:
140
+ # level1 initiated by file_name.rb:2
141
+ # level2 initiated by file_name.rb:3
142
+ @nested_behavior = lambda do |nested_experiment|
143
+ raise NestingError.new(experiment: self, nested_experiment: nested_experiment)
55
144
  end
56
145
 
57
146
  # Called at the end of every experiment run, with the result.
147
+ #
148
+ # You may want to track that you've assigned a variant to a given
149
+ # context, or push the experiment into the client or publish results
150
+ # elsewhere like into redis.
151
+ #
152
+ # This block is executed within the scope of the experiment and so can
153
+ # access experiment methods, like `name`, `context`, and `signature`.
58
154
  @publishing_behavior = lambda do |_result|
155
+ # Track the event using our own configured tracking logic.
59
156
  track(:assignment)
157
+
158
+ # Log using our logging system, so the result (which can be large) can
159
+ # be reviewed later if we want to.
160
+ #
161
+ # Lograge::Event.log(experiment: name, result: result, signature: signature)
162
+
163
+ # Experiments that have been run during the request lifecycle can be
164
+ # pushed to the client layer by injecting the published experiments
165
+ # into javascript in a layout or view using something like:
166
+ #
167
+ # = javascript_tag(nonce: content_security_policy_nonce) do
168
+ # window.experiments = #{raw Gitlab::Experiment.published_experiments.to_json};
60
169
  end
61
170
 
62
171
  class << self
63
- # TODO: Added deprecation in release 0.6.0
172
+ # @deprecated
64
173
  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`.')
174
+ deprecated(
175
+ :context_hash_strategy,
176
+ 'instead use `context_key_secret` and `context_key_bit_length`',
177
+ version: '0.7.0'
178
+ )
179
+
67
180
  @__context_hash_strategy = block
68
181
  end
69
182
 
70
- # TODO: Added deprecation in release 0.5.0
183
+ # @deprecated
71
184
  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
185
+ deprecated(
186
+ :variant_resolver,
187
+ 'instead use `inclusion_resolver` with a block that returns a boolean',
188
+ version: '0.6.5'
189
+ )
190
+
191
+ @__inclusion_resolver
75
192
  end
76
193
 
194
+ # @deprecated
77
195
  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
196
+ deprecated(
197
+ :variant_resolver,
198
+ 'instead use `inclusion_resolver` with a block that returns a boolean',
199
+ version: '0.6.5'
200
+ )
201
+
202
+ @__inclusion_resolver = block
203
+ end
204
+
205
+ # @deprecated
206
+ def inclusion_resolver=(block)
207
+ deprecated(
208
+ :inclusion_resolver,
209
+ 'instead put this logic into custom rollout strategies',
210
+ version: '0.7.0'
211
+ )
212
+
213
+ @__inclusion_resolver = block
214
+ end
215
+
216
+ # @deprecated
217
+ def inclusion_resolver
218
+ deprecated(
219
+ :inclusion_resolver,
220
+ 'instead put this logic into custom rollout strategies',
221
+ version: '0.7.0'
222
+ )
223
+
224
+ @__inclusion_resolver
81
225
  end
82
226
 
83
227
  attr_accessor(
84
228
  :name_prefix,
85
229
  :logger,
86
230
  :base_class,
231
+ :strict_registration,
87
232
  :cache,
88
233
  :cookie_domain,
89
234
  :context_key_secret,
@@ -91,10 +236,33 @@ module Gitlab
91
236
  :mount_at,
92
237
  :default_rollout,
93
238
  :redirect_url_validator,
94
- :inclusion_resolver,
95
239
  :tracking_behavior,
240
+ :nested_behavior,
96
241
  :publishing_behavior
97
242
  )
243
+
244
+ # Internal helpers warnings.
245
+ def deprecated(*args, version:, stack: 0)
246
+ deprecator = deprecator(version)
247
+ args << args.pop.to_s.gsub('{{release}}', "#{deprecator.gem_name} #{deprecator.deprecation_horizon}")
248
+ args << caller_locations(4 + stack)
249
+
250
+ if args.length == 2
251
+ deprecator.warn(*args)
252
+ else
253
+ args[0] = "`#{args[0]}`"
254
+ deprecator.deprecation_warning(*args)
255
+ end
256
+ end
257
+
258
+ private
259
+
260
+ def deprecator(version = VERSION)
261
+ version = Gem::Version.new(version).bump.to_s
262
+
263
+ @__dep_versions ||= {}
264
+ @__dep_versions[version] ||= ActiveSupport::Deprecation.new(version, 'Gitlab::Experiment')
265
+ end
98
266
  end
99
267
  end
100
268
  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
@@ -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,6 +4,26 @@ module Gitlab
4
4
  class Experiment
5
5
  Error = Class.new(StandardError)
6
6
  InvalidRolloutRules = Class.new(Error)
7
- NestingError = 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
8
28
  end
9
29
  end
@@ -9,8 +9,8 @@ module Gitlab
9
9
  set_callback :run, :around, :manage_nested_stack
10
10
  end
11
11
 
12
- def nest_experiment(other)
13
- raise NestingError, "unable to nest the #{other.name} experiment within the #{name} experiment"
12
+ def nest_experiment(nested_experiment)
13
+ instance_exec(nested_experiment, &Configuration.nested_behavior)
14
14
  end
15
15
 
16
16
  private
@@ -1,30 +1,39 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'zlib'
4
-
3
+ # The percent rollout strategy is the most comprehensive included with Gitlab::Experiment. It allows specifying the
4
+ # percentages per variant using an array, a hash, or will default to even distribution when no rules are provided.
5
+ #
6
+ # A given experiment id (context key) will always be given the same variant assignment.
7
+ #
8
+ # Example configuration usage:
9
+ #
10
+ # config.default_rollout = Gitlab::Experiment::Rollout::Percent.new
11
+ #
12
+ # Example class usage:
13
+ #
14
+ # class PillColorExperiment < ApplicationExperiment
15
+ # control { }
16
+ # variant(:red) { }
17
+ # variant(:blue) { }
18
+ #
19
+ # # Even distribution between all behaviors.
20
+ # default_rollout :percent
21
+ #
22
+ # # With specific distribution percentages.
23
+ # default_rollout :percent, distribution: { control: 25, red: 30, blue: 45 }
24
+ # end
25
+ #
5
26
  module Gitlab
6
27
  class Experiment
7
28
  module Rollout
8
29
  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
30
+ protected
22
31
 
23
32
  def validate!
24
33
  case distribution_rules
25
34
  when nil then nil
26
35
  when Array, Hash
27
- if distribution_rules.length != variant_names.length
36
+ if distribution_rules.length != behavior_names.length
28
37
  raise InvalidRolloutRules, "the distribution rules don't match the number of variants defined"
29
38
  end
30
39
  else
@@ -32,6 +41,20 @@ module Gitlab
32
41
  end
33
42
  end
34
43
 
44
+ def execute_assigment
45
+ crc = normalized_id
46
+ total = 0
47
+
48
+ case distribution_rules
49
+ when Array # run through the rules until finding an acceptable one
50
+ behavior_names[distribution_rules.find_index { |percent| crc % 100 <= total += percent }]
51
+ when Hash # run through the variant names until finding an acceptable one
52
+ distribution_rules.find { |_, percent| crc % 100 <= total += percent }.first
53
+ else # assume even distribution on no rules
54
+ behavior_names.empty? ? nil : behavior_names[crc % behavior_names.length]
55
+ end
56
+ end
57
+
35
58
  private
36
59
 
37
60
  def normalized_id
@@ -39,7 +62,7 @@ module Gitlab
39
62
  end
40
63
 
41
64
  def distribution_rules
42
- @options[:distribution]
65
+ options[:distribution]
43
66
  end
44
67
  end
45
68
  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_assigment
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_assigment
35
+ behavior_names[(cache.attr_inc(KEY_NAME) - 1) % behavior_names.size]
19
36
  end
20
37
  end
21
38
  end
@@ -14,27 +14,63 @@ module Gitlab
14
14
  end
15
15
 
16
16
  class Base
17
- attr_reader :experiment
17
+ DEFAULT_OPTIONS = {
18
+ include_control: false
19
+ }.freeze
20
+
21
+ attr_reader :experiment, :options
18
22
 
19
23
  delegate :variant_names, :cache, :id, to: :experiment
20
24
 
21
25
  def initialize(options = {})
22
- @options = options
23
- # validate! # we want to validate here, but we can't yet
26
+ @options = DEFAULT_OPTIONS.merge(options)
24
27
  end
25
28
 
26
- def rollout_for(experiment)
29
+ def for(experiment)
30
+ raise ArgumentError, 'you must provide an experiment instance' unless experiment.class <= Gitlab::Experiment
31
+
27
32
  @experiment = experiment
28
- validate! # until we have variant registration we can only validate here
29
- execute
33
+
34
+ self
35
+ end
36
+
37
+ def enabled?
38
+ require_experiment(__method__)
39
+
40
+ true
41
+ end
42
+
43
+ def resolve
44
+ require_experiment(__method__)
45
+
46
+ return nil if @experiment.respond_to?(:experiment_group?) && !@experiment.experiment_group?
47
+
48
+ validate! # allow the rollout strategy to validate itself
49
+
50
+ assignment = execute_assigment
51
+ assignment == :control ? nil : assignment # avoid caching control
30
52
  end
31
53
 
54
+ protected
55
+
32
56
  def validate!
33
57
  # base is always valid
34
58
  end
35
59
 
36
- def execute
37
- variant_names.first
60
+ def execute_assigment
61
+ behavior_names.first
62
+ end
63
+
64
+ private
65
+
66
+ def require_experiment(method_name)
67
+ return if @experiment.present?
68
+
69
+ raise ArgumentError, "you need to call `for` with an experiment instance before chaining `#{method_name}`"
70
+ end
71
+
72
+ def behavior_names
73
+ options[:include_control] ? [:control] + variant_names : variant_names
38
74
  end
39
75
  end
40
76
  end