gitlab-experiment 0.6.2 → 0.7.0

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,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
@@ -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?
@@ -61,16 +60,18 @@ module Gitlab
61
60
  private
62
61
 
63
62
  def process_migrations(value)
64
- add_migration(value.delete(:migrated_from))
65
- add_migration(value.delete(:migrated_with), merge: true)
63
+ add_unmerged_migration(value.delete(:migrated_from))
64
+ add_merged_migration(value.delete(:migrated_with))
66
65
 
67
66
  migrate_cookie(value, "#{@experiment.name}_id")
68
67
  end
69
68
 
70
- def add_migration(value, merge: false)
71
- return unless value.is_a?(Hash)
69
+ def add_unmerged_migration(value = {})
70
+ @migrations[:unmerged] << value if value.is_a?(Hash)
71
+ end
72
72
 
73
- @migrations[merge ? :merged : :unmerged] << value
73
+ def add_merged_migration(value = {})
74
+ @migrations[:merged] << value if value.is_a?(Hash)
74
75
  end
75
76
 
76
77
  def migration_keys
@@ -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
@@ -23,7 +21,7 @@ module Gitlab
23
21
  return hash if cookie.to_s.empty?
24
22
  return hash.merge(key => cookie) if hash[key].nil?
25
23
 
26
- add_migration(key => cookie)
24
+ add_unmerged_migration(key => cookie)
27
25
  cookie_jar.delete(cookie_name, domain: domain)
28
26
 
29
27
  hash
@@ -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
@@ -28,14 +29,17 @@ module Gitlab
28
29
  return if mount_at.blank?
29
30
 
30
31
  engine = routes do
31
- default_url_options app.routes.default_url_options
32
+ default_url_options app.routes.default_url_options.clone.without(:script_name)
32
33
  resources :experiments, path: '/', only: :show
33
34
  end
34
35
 
35
36
  app.config.middleware.use(Middleware, mount_at)
36
37
  app.routes.append do
37
38
  mount Engine, at: mount_at, as: :experiment_engine
38
- direct(:experiment_redirect) { |ex, url:| "#{engine.url_helpers.experiment_url(ex)}?#{url}" }
39
+ direct(:experiment_redirect) do |ex, options|
40
+ url = options[:url]
41
+ "#{engine.url_helpers.experiment_url(ex)}?#{url}"
42
+ end
39
43
  end
40
44
  end
41
45
  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,41 @@
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
+ @stack = []
29
+
30
+ class << self
31
+ delegate :pop, :length, :size, :[], to: :@stack
32
+
33
+ def push(instance)
34
+ @stack.last&.nest_experiment(instance)
35
+ @stack.push(instance)
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -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