gitlab-experiment 0.5.4 → 0.6.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9d7bc7a1946d06b5188ccfe761342209779f75cf635d894bb8743519ff2830dc
4
- data.tar.gz: 7a5c347f8b58e5caa2fe814ce013dbf1ca71f6e017148528a0ff2ca74bd562de
3
+ metadata.gz: 0d1ad31e8b977491ccdd1a9d21fc348482d3942c80a3e2935a6ccabc515b4243
4
+ data.tar.gz: bdf9a4b67bb2b5a72931c081fed88ac246853a2e90f4f7f5c08bf7bdb9d8f55e
5
5
  SHA512:
6
- metadata.gz: '01927d9db97ad4b087d445db642aa39615faa68322e2bd0e0b82ae99441478bae3f1939b94956218121fe52d01f432836ba272d0b1ff052537c80f0d0ff107b3'
7
- data.tar.gz: f53702ea5bb97fe6c73427850534b9e156744336a37029dce2b96926b70b7a5c4959708ac626f6ac0435125ad4ffe62f2ba9fd528f756af811c6ff66abc47846
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 like
22
- # `['www.gitlab.com', '.gitlab.com']`.
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::First, Rollout::Random, Rollout::RoundRobin
32
- config.default_rollout = Gitlab::Experiment::Rollout::First
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
@@ -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
- instance_exec(source, seed, &Configuration.context_hash_strategy)
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}:#{key_for(context.value)}"
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: @experiment.key_for(@value), migration_keys: migration_keys }.compact
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,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ class Experiment
5
+ Error = Class.new(StandardError)
6
+ InvalidRolloutRules = Class.new(Error)
7
+ end
8
+ 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
@@ -2,6 +2,10 @@
2
2
 
3
3
  module Gitlab
4
4
  class Experiment
5
- Variant = Struct.new(:name, :payload, keyword_init: true)
5
+ Variant = Struct.new(:name, :payload, keyword_init: true) do
6
+ def group
7
+ name == 'control' ? :control : :experiment
8
+ end
9
+ end
6
10
  end
7
11
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Gitlab
4
4
  class Experiment
5
- VERSION = '0.5.4'
5
+ VERSION = '0.6.1'
6
6
  end
7
7
  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.5.4
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-05-05 00:00:00.000000000 Z
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.17
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.