gitlab-experiment 0.5.4 → 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 +19 -1
- data/lib/gitlab/experiment/base_interface.rb +7 -1
- data/lib/gitlab/experiment/configuration.rb +23 -11
- 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 +8 -1
- data/lib/gitlab/experiment/rollout/percent.rb +47 -0
- data/lib/gitlab/experiment/variant.rb +5 -1
- data/lib/gitlab/experiment/version.rb +1 -1
- metadata +6 -3
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
@@ -9,6 +9,7 @@ require 'active_support/core_ext/object/blank'
|
|
9
9
|
require 'active_support/core_ext/string/inflections'
|
10
10
|
require 'active_support/core_ext/module/delegation'
|
11
11
|
|
12
|
+
require 'gitlab/experiment/errors'
|
12
13
|
require 'gitlab/experiment/base_interface'
|
13
14
|
require 'gitlab/experiment/cache'
|
14
15
|
require 'gitlab/experiment/callbacks'
|
@@ -17,6 +18,7 @@ require 'gitlab/experiment/configuration'
|
|
17
18
|
require 'gitlab/experiment/cookies'
|
18
19
|
require 'gitlab/experiment/context'
|
19
20
|
require 'gitlab/experiment/dsl'
|
21
|
+
require 'gitlab/experiment/middleware'
|
20
22
|
require 'gitlab/experiment/variant'
|
21
23
|
require 'gitlab/experiment/version'
|
22
24
|
require 'gitlab/experiment/engine' if defined?(Rails::Engine)
|
@@ -102,6 +104,8 @@ module Gitlab
|
|
102
104
|
|
103
105
|
def run(variant_name = nil)
|
104
106
|
@result ||= super(variant(variant_name).name)
|
107
|
+
rescue Scientist::BehaviorMissing => e
|
108
|
+
raise Error, e
|
105
109
|
end
|
106
110
|
|
107
111
|
def publish(result)
|
@@ -139,11 +143,25 @@ module Gitlab
|
|
139
143
|
end
|
140
144
|
|
141
145
|
def key_for(source, seed = name)
|
142
|
-
|
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('|'))
|
143
157
|
end
|
144
158
|
|
145
159
|
protected
|
146
160
|
|
161
|
+
def identify(object)
|
162
|
+
(object.respond_to?(:to_global_id) ? object.to_global_id : object).to_s
|
163
|
+
end
|
164
|
+
|
147
165
|
def segmentation_callback_chain
|
148
166
|
return :segmentation_check if @variant_name.nil? && enabled? && !excluded?
|
149
167
|
|
@@ -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
|
+
Gitlab::Experiment.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}"
|
@@ -31,6 +31,16 @@ module Gitlab
|
|
31
31
|
# experiments.
|
32
32
|
@default_rollout = Rollout::Base.new
|
33
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'
|
43
|
+
|
34
44
|
# Logic this project uses to determine inclusion in a given experiment.
|
35
45
|
# Expected to return a boolean value.
|
36
46
|
@inclusion_resolver = lambda do |requested_variant|
|
@@ -47,24 +57,24 @@ module Gitlab
|
|
47
57
|
track(:assignment)
|
48
58
|
end
|
49
59
|
|
50
|
-
# Algorithm that consistently generates a hash key for a given source.
|
51
|
-
@context_hash_strategy = lambda do |source, seed|
|
52
|
-
source = source.keys + source.values if source.is_a?(Hash)
|
53
|
-
data = Array(source).map { |v| (v.respond_to?(:to_global_id) ? v.to_global_id : v).to_s }
|
54
|
-
Digest::MD5.hexdigest(data.unshift(seed).join('|'))
|
55
|
-
end
|
56
|
-
|
57
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
|
+
|
58
68
|
# TODO: Added deprecation in release 0.5.0
|
59
69
|
def variant_resolver
|
60
70
|
ActiveSupport::Deprecation.warn('variant_resolver is deprecated, instead use `inclusion_resolver` with a' \
|
61
|
-
'block that returns a boolean.')
|
71
|
+
' block that returns a boolean.')
|
62
72
|
@inclusion_resolver
|
63
73
|
end
|
64
74
|
|
65
75
|
def variant_resolver=(block)
|
66
76
|
ActiveSupport::Deprecation.warn('variant_resolver is deprecated, instead use `inclusion_resolver` with a' \
|
67
|
-
'block that returns a boolean.')
|
77
|
+
' block that returns a boolean.')
|
68
78
|
@inclusion_resolver = block
|
69
79
|
end
|
70
80
|
|
@@ -74,11 +84,13 @@ module Gitlab
|
|
74
84
|
:base_class,
|
75
85
|
:cache,
|
76
86
|
:cookie_domain,
|
87
|
+
:context_key_secret,
|
88
|
+
:context_key_bit_length,
|
89
|
+
:mount_at,
|
77
90
|
:default_rollout,
|
78
91
|
:inclusion_resolver,
|
79
92
|
:tracking_behavior,
|
80
|
-
:publishing_behavior
|
81
|
-
:context_hash_strategy
|
93
|
+
:publishing_behavior
|
82
94
|
)
|
83
95
|
end
|
84
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,6 +3,7 @@
|
|
3
3
|
module Gitlab
|
4
4
|
class Experiment
|
5
5
|
module Rollout
|
6
|
+
autoload :Percent, 'gitlab/experiment/rollout/percent.rb'
|
6
7
|
autoload :Random, 'gitlab/experiment/rollout/random.rb'
|
7
8
|
autoload :RoundRobin, 'gitlab/experiment/rollout/round_robin.rb'
|
8
9
|
|
@@ -15,17 +16,23 @@ module Gitlab
|
|
15
16
|
class Base
|
16
17
|
attr_reader :experiment
|
17
18
|
|
18
|
-
delegate :variant_names, :cache, to: :experiment
|
19
|
+
delegate :variant_names, :cache, :id, to: :experiment
|
19
20
|
|
20
21
|
def initialize(options = {})
|
21
22
|
@options = options
|
23
|
+
# validate! # we want to validate here, but we can't yet
|
22
24
|
end
|
23
25
|
|
24
26
|
def rollout_for(experiment)
|
25
27
|
@experiment = experiment
|
28
|
+
validate! # until we have variant registration we can only validate here
|
26
29
|
execute
|
27
30
|
end
|
28
31
|
|
32
|
+
def validate!
|
33
|
+
# base is always valid
|
34
|
+
end
|
35
|
+
|
29
36
|
def execute
|
30
37
|
variant_names.first
|
31
38
|
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
|
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
|
@@ -88,7 +88,10 @@ files:
|
|
88
88
|
- lib/gitlab/experiment/cookies.rb
|
89
89
|
- lib/gitlab/experiment/dsl.rb
|
90
90
|
- lib/gitlab/experiment/engine.rb
|
91
|
+
- lib/gitlab/experiment/errors.rb
|
92
|
+
- lib/gitlab/experiment/middleware.rb
|
91
93
|
- lib/gitlab/experiment/rollout.rb
|
94
|
+
- lib/gitlab/experiment/rollout/percent.rb
|
92
95
|
- lib/gitlab/experiment/rollout/random.rb
|
93
96
|
- lib/gitlab/experiment/rollout/round_robin.rb
|
94
97
|
- lib/gitlab/experiment/rspec.rb
|
@@ -113,7 +116,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
113
116
|
- !ruby/object:Gem::Version
|
114
117
|
version: '0'
|
115
118
|
requirements: []
|
116
|
-
rubygems_version: 3.2.
|
119
|
+
rubygems_version: 3.2.20
|
117
120
|
signing_key:
|
118
121
|
specification_version: 4
|
119
122
|
summary: GitLab experiment library built on top of scientist.
|