gitlab-experiment 0.5.2 → 0.6.2
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 +39 -17
- data/lib/gitlab/experiment.rb +41 -11
- data/lib/gitlab/experiment/base_interface.rb +14 -1
- data/lib/gitlab/experiment/configuration.rb +34 -16
- data/lib/gitlab/experiment/context.rb +7 -1
- data/lib/gitlab/experiment/dsl.rb +4 -0
- data/lib/gitlab/experiment/engine.rb +31 -9
- 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 +5 -1
- data/lib/gitlab/experiment/variant.rb +5 -1
- data/lib/gitlab/experiment/version.rb +1 -1
- metadata +48 -18
- 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: cd00eb06f769b8268e0e02e7d48b38dbef6be029c8f4d02b127e9f2b44f9dbc3
|
|
4
|
+
data.tar.gz: 7a3b9c5357489afd4b14bd6411536b1a2484c50f44934cc646b20abd1607ab0b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '08ea7e0b62c92e7674a72271e388dd104878ad2b9328ad016fba758762e244b51da1a089cec0d74e1db11fba4da3e2bc59524ef2839424295fa3c7307c73cb87'
|
|
7
|
+
data.tar.gz: 7ecfc7bbaa0803b67ccfd642c7c06baec43cde54b41cc768a7c1a1537e5c6cae6e13a355bdf342d4cdaebef15f5a1b99e76676c87612d857affb865bb543e07d
|
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,41 @@ 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'
|
|
58
|
+
|
|
59
|
+
# When using the middleware, links can be instrumented and redirected
|
|
60
|
+
# elsewhere. This can be exploited to make a harmful url look innocuous or
|
|
61
|
+
# that it's a valid url on your domain. To avoid this, you can provide your
|
|
62
|
+
# own logic for what urls will be considered valid and redirected to.
|
|
63
|
+
#
|
|
64
|
+
# Expected to return a boolean value.
|
|
65
|
+
config.redirect_url_validator = lambda do |redirect_url|
|
|
66
|
+
true
|
|
67
|
+
end
|
|
33
68
|
|
|
34
69
|
# Logic this project uses to determine inclusion in a given experiment.
|
|
35
70
|
#
|
|
@@ -80,17 +115,4 @@ Gitlab::Experiment.configure do |config|
|
|
|
80
115
|
#
|
|
81
116
|
# Lograge::Event.log(experiment: name, result: result, signature: signature)
|
|
82
117
|
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
118
|
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,10 @@ 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)
|
|
34
37
|
end
|
|
35
38
|
|
|
36
39
|
def exclude(*filter_list, **options, &block)
|
|
@@ -44,6 +47,10 @@ module Gitlab
|
|
|
44
47
|
target.variant(variant) if target.instance_variable_get(:@variant_name).nil? && callback.call(target, nil)
|
|
45
48
|
end
|
|
46
49
|
end
|
|
50
|
+
|
|
51
|
+
def published_experiments
|
|
52
|
+
RequestStore.store[:published_gitlab_experiments] || {}
|
|
53
|
+
end
|
|
47
54
|
end
|
|
48
55
|
|
|
49
56
|
def name
|
|
@@ -74,9 +81,7 @@ module Gitlab
|
|
|
74
81
|
|
|
75
82
|
if enabled?
|
|
76
83
|
@resolving_variant = true
|
|
77
|
-
@variant_name
|
|
78
|
-
result = cache_variant(@variant_name) { resolve_variant_name }
|
|
79
|
-
@variant_name = result.to_sym if result.present?
|
|
84
|
+
@variant_name = cached_variant_resolver(@variant_name)
|
|
80
85
|
end
|
|
81
86
|
|
|
82
87
|
run_callbacks(segmentation_callback_chain) do
|
|
@@ -87,10 +92,10 @@ module Gitlab
|
|
|
87
92
|
@resolving_variant = false
|
|
88
93
|
end
|
|
89
94
|
|
|
90
|
-
def rollout(rollout = nil)
|
|
91
|
-
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?
|
|
92
97
|
|
|
93
|
-
@rollout = Rollout.resolve(rollout)
|
|
98
|
+
@rollout = Rollout.resolve(rollout).new(options)
|
|
94
99
|
end
|
|
95
100
|
|
|
96
101
|
def exclude!
|
|
@@ -99,10 +104,14 @@ module Gitlab
|
|
|
99
104
|
|
|
100
105
|
def run(variant_name = nil)
|
|
101
106
|
@result ||= super(variant(variant_name).name)
|
|
107
|
+
rescue Scientist::BehaviorMissing => e
|
|
108
|
+
raise Error, e
|
|
102
109
|
end
|
|
103
110
|
|
|
104
111
|
def publish(result)
|
|
105
112
|
instance_exec(result, &Configuration.publishing_behavior)
|
|
113
|
+
|
|
114
|
+
(RequestStore.store[:published_gitlab_experiments] ||= {})[name] = signature.merge(excluded: excluded?)
|
|
106
115
|
end
|
|
107
116
|
|
|
108
117
|
def track(action, **event_args)
|
|
@@ -111,6 +120,13 @@ module Gitlab
|
|
|
111
120
|
instance_exec(action, event_args, &Configuration.tracking_behavior)
|
|
112
121
|
end
|
|
113
122
|
|
|
123
|
+
def process_redirect_url(url)
|
|
124
|
+
return unless Configuration.redirect_url_validator&.call(url)
|
|
125
|
+
|
|
126
|
+
track('visited', url: url)
|
|
127
|
+
url # return the url, which allows for mutation
|
|
128
|
+
end
|
|
129
|
+
|
|
114
130
|
def enabled?
|
|
115
131
|
true
|
|
116
132
|
end
|
|
@@ -133,12 +149,26 @@ module Gitlab
|
|
|
133
149
|
{ variant: variant.name, experiment: name }.merge(context.signature)
|
|
134
150
|
end
|
|
135
151
|
|
|
136
|
-
def key_for(
|
|
137
|
-
|
|
152
|
+
def key_for(source, seed = name)
|
|
153
|
+
# TODO: Added deprecation in release 0.6.0
|
|
154
|
+
if (block = Configuration.instance_variable_get(:@__context_hash_strategy))
|
|
155
|
+
return instance_exec(source, seed, &block)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
source = source.keys + source.values if source.is_a?(Hash)
|
|
159
|
+
|
|
160
|
+
ingredients = Array(source).map { |v| identify(v) }
|
|
161
|
+
ingredients.unshift(seed).unshift(Configuration.context_key_secret)
|
|
162
|
+
|
|
163
|
+
Digest::SHA2.new(Configuration.context_key_bit_length).hexdigest(ingredients.join('|'))
|
|
138
164
|
end
|
|
139
165
|
|
|
140
166
|
protected
|
|
141
167
|
|
|
168
|
+
def identify(object)
|
|
169
|
+
(object.respond_to?(:to_global_id) ? object.to_global_id : object).to_s
|
|
170
|
+
end
|
|
171
|
+
|
|
142
172
|
def segmentation_callback_chain
|
|
143
173
|
return :segmentation_check if @variant_name.nil? && enabled? && !excluded?
|
|
144
174
|
|
|
@@ -146,7 +176,7 @@ module Gitlab
|
|
|
146
176
|
end
|
|
147
177
|
|
|
148
178
|
def resolve_variant_name
|
|
149
|
-
rollout.
|
|
179
|
+
rollout.rollout_for(self) if experiment_group?
|
|
150
180
|
end
|
|
151
181
|
end
|
|
152
182
|
end
|
|
@@ -26,6 +26,11 @@ module Gitlab
|
|
|
26
26
|
|
|
27
27
|
experiment_name(name).classify.safe_constantize || Configuration.base_class.constantize
|
|
28
28
|
end
|
|
29
|
+
|
|
30
|
+
def from_param(id)
|
|
31
|
+
%r{/?(?<name>.*):(?<key>.*)$} =~ id
|
|
32
|
+
constantize(name).new(name).tap { |e| e.context.key(key) }
|
|
33
|
+
end
|
|
29
34
|
end
|
|
30
35
|
|
|
31
36
|
def initialize(name = nil, variant_name = nil, **context)
|
|
@@ -45,9 +50,10 @@ module Gitlab
|
|
|
45
50
|
end
|
|
46
51
|
|
|
47
52
|
def id
|
|
48
|
-
"#{name}:#{
|
|
53
|
+
"#{name}:#{context.key}"
|
|
49
54
|
end
|
|
50
55
|
alias_method :session_id, :id
|
|
56
|
+
alias_method :to_param, :id
|
|
51
57
|
|
|
52
58
|
def flipper_id
|
|
53
59
|
"Experiment;#{id}"
|
|
@@ -72,6 +78,13 @@ module Gitlab
|
|
|
72
78
|
false
|
|
73
79
|
end
|
|
74
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?
|
|
86
|
+
end
|
|
87
|
+
|
|
75
88
|
def generate_result(variant_name)
|
|
76
89
|
observation = Scientist::Observation.new(variant_name, self, &behaviors[variant_name])
|
|
77
90
|
Scientist::Result.new(self, [observation], observation)
|
|
@@ -26,18 +26,32 @@ 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 = nil
|
|
43
|
+
|
|
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 }
|
|
31
47
|
|
|
32
48
|
# Logic this project uses to determine inclusion in a given experiment.
|
|
33
49
|
# Expected to return a boolean value.
|
|
34
|
-
@inclusion_resolver =
|
|
35
|
-
false
|
|
36
|
-
end
|
|
50
|
+
@inclusion_resolver = ->(_requested_variant) { false }
|
|
37
51
|
|
|
38
52
|
# Tracking behavior can be implemented to link an event to an experiment.
|
|
39
53
|
@tracking_behavior = lambda do |event, args|
|
|
40
|
-
Configuration.logger.info
|
|
54
|
+
Configuration.logger.info("#{self.class.name}[#{name}] #{event}: #{args.merge(signature: signature)}")
|
|
41
55
|
end
|
|
42
56
|
|
|
43
57
|
# Called at the end of every experiment run, with the result.
|
|
@@ -45,23 +59,24 @@ module Gitlab
|
|
|
45
59
|
track(:assignment)
|
|
46
60
|
end
|
|
47
61
|
|
|
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
62
|
class << self
|
|
63
|
+
# TODO: Added deprecation in release 0.6.0
|
|
64
|
+
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`.')
|
|
67
|
+
@__context_hash_strategy = block
|
|
68
|
+
end
|
|
69
|
+
|
|
55
70
|
# TODO: Added deprecation in release 0.5.0
|
|
56
71
|
def variant_resolver
|
|
57
72
|
ActiveSupport::Deprecation.warn('variant_resolver is deprecated, instead use `inclusion_resolver` with a' \
|
|
58
|
-
'block that returns a boolean.')
|
|
73
|
+
' block that returns a boolean.')
|
|
59
74
|
@inclusion_resolver
|
|
60
75
|
end
|
|
61
76
|
|
|
62
77
|
def variant_resolver=(block)
|
|
63
78
|
ActiveSupport::Deprecation.warn('variant_resolver is deprecated, instead use `inclusion_resolver` with a' \
|
|
64
|
-
'block that returns a boolean.')
|
|
79
|
+
' block that returns a boolean.')
|
|
65
80
|
@inclusion_resolver = block
|
|
66
81
|
end
|
|
67
82
|
|
|
@@ -71,11 +86,14 @@ module Gitlab
|
|
|
71
86
|
:base_class,
|
|
72
87
|
:cache,
|
|
73
88
|
:cookie_domain,
|
|
89
|
+
:context_key_secret,
|
|
90
|
+
:context_key_bit_length,
|
|
91
|
+
:mount_at,
|
|
74
92
|
:default_rollout,
|
|
93
|
+
:redirect_url_validator,
|
|
75
94
|
:inclusion_resolver,
|
|
76
95
|
:tracking_behavior,
|
|
77
|
-
:publishing_behavior
|
|
78
|
-
:context_hash_strategy
|
|
96
|
+
:publishing_behavior
|
|
79
97
|
)
|
|
80
98
|
end
|
|
81
99
|
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, *)
|
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
module Gitlab
|
|
4
4
|
class Experiment
|
|
5
5
|
module Dsl
|
|
6
|
+
def self.include_in(klass, with_helper: false)
|
|
7
|
+
klass.include(self).tap { |base| base.helper_method(:experiment) if with_helper }
|
|
8
|
+
end
|
|
9
|
+
|
|
6
10
|
def experiment(name, variant_name = nil, **context, &block)
|
|
7
11
|
raise ArgumentError, 'name is required' if name.nil?
|
|
8
12
|
|
|
@@ -1,21 +1,43 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'active_model'
|
|
4
|
+
|
|
3
5
|
module Gitlab
|
|
4
6
|
class Experiment
|
|
7
|
+
include ActiveModel::Model
|
|
8
|
+
|
|
9
|
+
# used for generating routes
|
|
10
|
+
def self.model_name
|
|
11
|
+
ActiveModel::Name.new(self, Gitlab)
|
|
12
|
+
end
|
|
13
|
+
|
|
5
14
|
class Engine < ::Rails::Engine
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
end
|
|
15
|
+
isolate_namespace Experiment
|
|
16
|
+
|
|
17
|
+
initializer('gitlab_experiment.include_dsl') { include_dsl }
|
|
18
|
+
initializer('gitlab_experiment.mount_engine') { |app| mount_engine(app, Configuration.mount_at) }
|
|
11
19
|
|
|
12
|
-
|
|
20
|
+
private
|
|
13
21
|
|
|
14
|
-
|
|
15
|
-
|
|
22
|
+
def include_dsl
|
|
23
|
+
Dsl.include_in(ActionController::Base, with_helper: true) if defined?(ActionController)
|
|
24
|
+
Dsl.include_in(ActionMailer::Base, with_helper: true) if defined?(ActionMailer)
|
|
16
25
|
end
|
|
17
26
|
|
|
18
|
-
|
|
27
|
+
def mount_engine(app, mount_at)
|
|
28
|
+
return if mount_at.blank?
|
|
29
|
+
|
|
30
|
+
engine = routes do
|
|
31
|
+
default_url_options app.routes.default_url_options
|
|
32
|
+
resources :experiments, path: '/', only: :show
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
app.config.middleware.use(Middleware, mount_at)
|
|
36
|
+
app.routes.append do
|
|
37
|
+
mount Engine, at: mount_at, as: :experiment_engine
|
|
38
|
+
direct(:experiment_redirect) { |ex, url:| "#{engine.url_helpers.experiment_url(ex)}?#{url}" }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
19
41
|
end
|
|
20
42
|
end
|
|
21
43
|
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
|
+
experiment = Gitlab::Experiment.from_param(id)
|
|
10
|
+
[303, { 'Location' => experiment.process_redirect_url(url) || raise(Error, 'not redirecting') }, []]
|
|
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
|
|
@@ -82,7 +82,7 @@ module Gitlab
|
|
|
82
82
|
extend RSpec::Matchers::DSL
|
|
83
83
|
|
|
84
84
|
def require_experiment(experiment, matcher_name, classes: false)
|
|
85
|
-
klass = experiment.
|
|
85
|
+
klass = experiment.instance_of?(Class) ? experiment : experiment.class
|
|
86
86
|
unless klass <= Gitlab::Experiment
|
|
87
87
|
raise(
|
|
88
88
|
ArgumentError,
|
|
@@ -232,6 +232,10 @@ RSpec.configure do |config|
|
|
|
232
232
|
config.include Gitlab::Experiment::RSpecHelpers
|
|
233
233
|
config.include Gitlab::Experiment::Dsl
|
|
234
234
|
|
|
235
|
+
config.before(:each, :experiment) do
|
|
236
|
+
RequestStore.clear!
|
|
237
|
+
end
|
|
238
|
+
|
|
235
239
|
config.include Gitlab::Experiment::RSpecMatchers, :experiment
|
|
236
240
|
config.define_derived_metadata(file_path: Regexp.new('/spec/experiments/')) do |metadata|
|
|
237
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.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- GitLab
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date:
|
|
11
|
+
date:
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|
|
@@ -24,6 +24,20 @@ 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
|
|
@@ -51,36 +65,52 @@ executables: []
|
|
|
51
65
|
extensions: []
|
|
52
66
|
extra_rdoc_files: []
|
|
53
67
|
files:
|
|
54
|
-
-
|
|
55
|
-
-
|
|
56
|
-
- lib/generators/gitlab/experiment/
|
|
57
|
-
- lib/generators/gitlab/experiment/experiment_generator.rb
|
|
68
|
+
- lib/generators/gitlab
|
|
69
|
+
- lib/generators/gitlab/experiment
|
|
70
|
+
- lib/generators/gitlab/experiment/install
|
|
58
71
|
- lib/generators/gitlab/experiment/install/install_generator.rb
|
|
59
|
-
- lib/generators/gitlab/experiment/install/templates
|
|
72
|
+
- lib/generators/gitlab/experiment/install/templates
|
|
60
73
|
- lib/generators/gitlab/experiment/install/templates/application_experiment.rb.tt
|
|
61
74
|
- lib/generators/gitlab/experiment/install/templates/initializer.rb.tt
|
|
75
|
+
- lib/generators/gitlab/experiment/install/templates/POST_INSTALL
|
|
76
|
+
- lib/generators/gitlab/experiment/USAGE
|
|
77
|
+
- lib/generators/gitlab/experiment/experiment_generator.rb
|
|
78
|
+
- lib/generators/gitlab/experiment/templates
|
|
62
79
|
- lib/generators/gitlab/experiment/templates/experiment.rb.tt
|
|
63
|
-
- lib/generators/
|
|
64
|
-
- lib/generators/
|
|
80
|
+
- lib/generators/test_unit
|
|
81
|
+
- lib/generators/test_unit/experiment
|
|
65
82
|
- lib/generators/test_unit/experiment/experiment_generator.rb
|
|
83
|
+
- lib/generators/test_unit/experiment/templates
|
|
66
84
|
- lib/generators/test_unit/experiment/templates/experiment_test.rb.tt
|
|
85
|
+
- lib/generators/rspec
|
|
86
|
+
- lib/generators/rspec/experiment
|
|
87
|
+
- lib/generators/rspec/experiment/experiment_generator.rb
|
|
88
|
+
- lib/generators/rspec/experiment/templates
|
|
89
|
+
- lib/generators/rspec/experiment/templates/experiment_spec.rb.tt
|
|
67
90
|
- lib/gitlab/experiment.rb
|
|
68
|
-
- lib/gitlab/experiment
|
|
69
|
-
- lib/gitlab/experiment/
|
|
91
|
+
- lib/gitlab/experiment
|
|
92
|
+
- lib/gitlab/experiment/variant.rb
|
|
93
|
+
- lib/gitlab/experiment/middleware.rb
|
|
94
|
+
- lib/gitlab/experiment/cache
|
|
70
95
|
- lib/gitlab/experiment/cache/redis_hash_store.rb
|
|
96
|
+
- lib/gitlab/experiment/errors.rb
|
|
71
97
|
- lib/gitlab/experiment/callbacks.rb
|
|
72
|
-
- lib/gitlab/experiment/
|
|
98
|
+
- lib/gitlab/experiment/rollout.rb
|
|
99
|
+
- lib/gitlab/experiment/base_interface.rb
|
|
73
100
|
- lib/gitlab/experiment/context.rb
|
|
74
|
-
- lib/gitlab/experiment/cookies.rb
|
|
75
|
-
- lib/gitlab/experiment/dsl.rb
|
|
76
101
|
- lib/gitlab/experiment/engine.rb
|
|
77
|
-
- lib/gitlab/experiment/
|
|
78
|
-
- lib/gitlab/experiment/rollout
|
|
102
|
+
- lib/gitlab/experiment/rspec.rb
|
|
103
|
+
- lib/gitlab/experiment/rollout
|
|
79
104
|
- lib/gitlab/experiment/rollout/random.rb
|
|
80
105
|
- lib/gitlab/experiment/rollout/round_robin.rb
|
|
81
|
-
- lib/gitlab/experiment/
|
|
82
|
-
- lib/gitlab/experiment/
|
|
106
|
+
- lib/gitlab/experiment/rollout/percent.rb
|
|
107
|
+
- lib/gitlab/experiment/cache.rb
|
|
83
108
|
- lib/gitlab/experiment/version.rb
|
|
109
|
+
- lib/gitlab/experiment/cookies.rb
|
|
110
|
+
- lib/gitlab/experiment/configuration.rb
|
|
111
|
+
- lib/gitlab/experiment/dsl.rb
|
|
112
|
+
- LICENSE.txt
|
|
113
|
+
- README.md
|
|
84
114
|
homepage: https://gitlab.com/gitlab-org/gitlab-experiment
|
|
85
115
|
licenses:
|
|
86
116
|
- MIT
|
|
@@ -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
|