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 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.