gitlab-experiment 0.5.0 → 0.6.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.
- checksums.yaml +4 -4
- data/README.md +24 -0
- data/lib/generators/gitlab/experiment/install/templates/initializer.rb.tt +29 -17
- data/lib/gitlab/experiment.rb +58 -20
- data/lib/gitlab/experiment/base_interface.rb +17 -8
- data/lib/gitlab/experiment/callbacks.rb +5 -16
- data/lib/gitlab/experiment/configuration.rb +27 -12
- data/lib/gitlab/experiment/context.rb +7 -1
- data/lib/gitlab/experiment/engine.rb +14 -0
- data/lib/gitlab/experiment/errors.rb +8 -0
- data/lib/gitlab/experiment/middleware.rb +27 -0
- data/lib/gitlab/experiment/rollout.rb +18 -3
- data/lib/gitlab/experiment/rollout/percent.rb +47 -0
- data/lib/gitlab/experiment/rspec.rb +90 -47
- data/lib/gitlab/experiment/variant.rb +5 -1
- data/lib/gitlab/experiment/version.rb +1 -1
- metadata +24 -8
- data/lib/gitlab/experiment/rollout/first.rb +0 -16
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 0d1ad31e8b977491ccdd1a9d21fc348482d3942c80a3e2935a6ccabc515b4243
|
4
|
+
data.tar.gz: bdf9a4b67bb2b5a72931c081fed88ac246853a2e90f4f7f5c08bf7bdb9d8f55e
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
22
|
-
#
|
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::
|
32
|
-
config.default_rollout = Gitlab::Experiment::Rollout::
|
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
|
data/lib/gitlab/experiment.rb
CHANGED
@@ -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
|
42
|
-
|
60
|
+
def control(&block)
|
61
|
+
candidate(:control, &block)
|
43
62
|
end
|
63
|
+
alias_method :use, :control
|
44
64
|
|
45
|
-
def
|
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
|
-
|
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 =
|
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(
|
122
|
-
|
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
|
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.
|
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}:#{
|
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
|
76
|
-
|
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
|
-
|
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
|
32
|
-
filters =
|
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) {
|
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(
|
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
|
30
|
-
|
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:
|
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,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 :
|
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(
|
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
|
8
|
-
|
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
|
-
|
11
|
-
|
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
|
-
|
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
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
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
|
-
|
26
|
-
if shallow
|
27
|
-
yield experiment if block.present?
|
28
|
-
return experiment
|
29
|
-
end
|
48
|
+
private
|
30
49
|
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
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
|
-
|
39
|
-
|
40
|
-
|
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
|
-
|
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
|
-
|
172
|
+
|
173
|
+
chain(:on_next_instance) { @on_next_instance = true }
|
139
174
|
|
140
175
|
def expect_tracking_on(experiment, negated, event, *event_args)
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
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(
|
189
|
+
expect(e).not_to receive(:track).with(*[event, *event_args])
|
149
190
|
else
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
end
|
191
|
+
if @expected_variant
|
192
|
+
expect(@experiment.variant.name).to eq(@expected_variant), failure_message(:variant, event)
|
193
|
+
end
|
154
194
|
|
155
|
-
|
156
|
-
|
157
|
-
|
195
|
+
if @expected_context
|
196
|
+
expect(@experiment.context.value).to include(@expected_context), failure_message(:context, event)
|
197
|
+
end
|
158
198
|
|
159
|
-
|
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
|
-
|
164
|
-
|
165
|
-
|
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
|
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.
|
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-
|
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.
|
47
|
+
version: '1.6'
|
34
48
|
- - ">="
|
35
49
|
- !ruby/object:Gem::Version
|
36
|
-
version: 1.
|
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.
|
57
|
+
version: '1.6'
|
44
58
|
- - ">="
|
45
59
|
- !ruby/object:Gem::Version
|
46
|
-
version: 1.
|
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/
|
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.
|
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
|