gitlab-experiment 0.6.1 → 0.6.5

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: 0d1ad31e8b977491ccdd1a9d21fc348482d3942c80a3e2935a6ccabc515b4243
4
- data.tar.gz: bdf9a4b67bb2b5a72931c081fed88ac246853a2e90f4f7f5c08bf7bdb9d8f55e
3
+ metadata.gz: 9651e4cc4941d879e7c006f5865ff71989babd942bfcc09a86eb5be89f17e38c
4
+ data.tar.gz: d23055b9c808ae680c05762d60a569306069578a8555f87daf816a717cef0c28
5
5
  SHA512:
6
- metadata.gz: dc1cc5077144d4e5514b6804599bba8e6829ad6472e5e362cde88940995b174f9989733fea17da91be92dc172694a4394ee0db7974efba8d6eb4e77e769a57ee
7
- data.tar.gz: 958cf74fa36b1588fd7f83776e87c7f8c92120cb8a6d421c09a4bd207adffdfcfe69ea295dd1e6a49d371225988f6fde3f559baf64d755a0c9176a33e7efddca
6
+ metadata.gz: af40d50563c3938f089d415ebece34be76a79f8b1c13f7f5a4d6c62f035a7fe07147a4ab9ad475f00372d94be7b5c35f6c54ee2f866c3b0875748774cb04f1bb
7
+ data.tar.gz: fd4b8c69bc49351a7050d5ab6665c42855cf1d26b18e4a4e1983fbbeba2a8487b1523b6d1f824f0862d0fd16d425565eaf4ce179120a2e55bbbbeef53ae03a2f
data/README.md CHANGED
@@ -47,7 +47,7 @@ We'll name our experiment `notification_toggle`. This name is prefixed based on
47
47
 
48
48
  When you implement an experiment you'll need to provide a name, and a context. The name can show up in tracking calls, and potentially other aspects. The context determines the variant assigned, and should be consistent between calls. We'll discuss migrating context in later examples.
49
49
 
50
- A context "key" represents the unique id of a context. It allows us to give the same experience between different calls to the experiment and can be used in caching.
50
+ A context "key" represents the unique id of a context. It allows us to give the same experience between different calls to the experiment and can be used in caching. This is how an experiment remains "sticky" to a given context.
51
51
 
52
52
  Now in our experiment we're going to render one of two views: the control will be our current view, and the candidate will be the new toggle button with a confirmation flow.
53
53
 
@@ -74,6 +74,12 @@ experiment(:notification_toggle, actor: user) do |e|
74
74
  end
75
75
  ```
76
76
 
77
+ You can specify what the experiment should be "sticky" to by providing a `:sticky_to` option. By default this will be the entire context provided, but this can be overridden manually if needed.
78
+
79
+ ```ruby
80
+ experiment(:notification_toggle, actor: user, project: project, sticky_to: project) #...
81
+ ```
82
+
77
83
  Understanding how an experiment can change behavior is important in evaluating its performance.
78
84
 
79
85
  To this end, we track events that are important by calling the same experiment elsewhere in code. By using the same context, you'll have consistent behavior and the ability to track events to it.
@@ -56,6 +56,16 @@ Gitlab::Experiment.configure do |config|
56
56
  # '/-/experiment', '/redirect', nil
57
57
  config.mount_at = '/experiment'
58
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
68
+
59
69
  # Logic this project uses to determine inclusion in a given experiment.
60
70
  #
61
71
  # Expected to return a boolean value.
@@ -29,7 +29,8 @@ module Gitlab
29
29
 
30
30
  def from_param(id)
31
31
  %r{/?(?<name>.*):(?<key>.*)$} =~ id
32
- Gitlab::Experiment.new(name).tap { |e| e.context.key(key) }
32
+ name = CGI.unescape(name) if name
33
+ constantize(name).new(name).tap { |e| e.context.key(key) }
33
34
  end
34
35
  end
35
36
 
@@ -9,6 +9,7 @@ module Gitlab
9
9
  include ActiveSupport::Callbacks
10
10
 
11
11
  included do
12
+ define_callbacks(:run)
12
13
  define_callbacks(:unsegmented)
13
14
  define_callbacks(:segmentation_check)
14
15
  define_callbacks(:exclusion_check, skip_after_callbacks_if_terminated: true)
@@ -39,17 +39,19 @@ module Gitlab
39
39
 
40
40
  # The default base path that the middleware (or rails engine) will be
41
41
  # mounted.
42
- @mount_at = '/experiment'
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 }
43
47
 
44
48
  # Logic this project uses to determine inclusion in a given experiment.
45
49
  # Expected to return a boolean value.
46
- @inclusion_resolver = lambda do |requested_variant|
47
- false
48
- end
50
+ @inclusion_resolver = ->(_requested_variant) { false }
49
51
 
50
52
  # Tracking behavior can be implemented to link an event to an experiment.
51
53
  @tracking_behavior = lambda do |event, args|
52
- Configuration.logger.info "Gitlab::Experiment[#{name}] #{event}: #{args.merge(signature: signature)}"
54
+ Configuration.logger.info("#{self.class.name}[#{name}] #{event}: #{args.merge(signature: signature)}")
53
55
  end
54
56
 
55
57
  # Called at the end of every experiment run, with the result.
@@ -88,6 +90,7 @@ module Gitlab
88
90
  :context_key_bit_length,
89
91
  :mount_at,
90
92
  :default_rollout,
93
+ :redirect_url_validator,
91
94
  :inclusion_resolver,
92
95
  :tracking_behavior,
93
96
  :publishing_behavior
@@ -27,6 +27,7 @@ module Gitlab
27
27
 
28
28
  value = value.dup # dup so we don't mutate
29
29
  reinitialize(value.delete(:request))
30
+ key(value.delete(:sticky_to))
30
31
 
31
32
  @value.merge!(process_migrations(value))
32
33
  end
@@ -34,7 +35,7 @@ module Gitlab
34
35
  def key(key = nil)
35
36
  return @key || @experiment.key_for(value) if key.nil?
36
37
 
37
- @key = key
38
+ @key = @experiment.key_for(key)
38
39
  end
39
40
 
40
41
  def trackable?
@@ -61,16 +62,18 @@ module Gitlab
61
62
  private
62
63
 
63
64
  def process_migrations(value)
64
- add_migration(value.delete(:migrated_from))
65
- add_migration(value.delete(:migrated_with), merge: true)
65
+ add_unmerged_migration(value.delete(:migrated_from))
66
+ add_merged_migration(value.delete(:migrated_with))
66
67
 
67
68
  migrate_cookie(value, "#{@experiment.name}_id")
68
69
  end
69
70
 
70
- def add_migration(value, merge: false)
71
- return unless value.is_a?(Hash)
71
+ def add_unmerged_migration(value = {})
72
+ @migrations[:unmerged] << value if value.is_a?(Hash)
73
+ end
72
74
 
73
- @migrations[merge ? :merged : :unmerged] << value
75
+ def add_merged_migration(value = {})
76
+ @migrations[:merged] << value if value.is_a?(Hash)
74
77
  end
75
78
 
76
79
  def migration_keys
@@ -23,7 +23,7 @@ module Gitlab
23
23
  return hash if cookie.to_s.empty?
24
24
  return hash.merge(key => cookie) if hash[key].nil?
25
25
 
26
- add_migration(key => cookie)
26
+ add_unmerged_migration(key => cookie)
27
27
  cookie_jar.delete(cookie_name, domain: domain)
28
28
 
29
29
  hash
@@ -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,35 +1,46 @@
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
- def self.include_dsl
7
- if defined?(ActionController)
8
- ActionController::Base.include(Dsl)
9
- ActionController::Base.helper_method(:experiment)
10
- end
15
+ isolate_namespace Experiment
11
16
 
12
- return unless defined?(ActionMailer)
17
+ initializer('gitlab_experiment.include_dsl') { include_dsl }
18
+ initializer('gitlab_experiment.mount_engine') { |app| mount_engine(app, Configuration.mount_at) }
13
19
 
14
- ActionMailer::Base.include(Dsl)
15
- ActionMailer::Base.helper_method(:experiment)
20
+ private
21
+
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
- def add_middleware(app, base_path)
19
- return if base_path.blank?
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.clone.without(:script_name)
32
+ resources :experiments, path: '/', only: :show
33
+ end
20
34
 
21
- app.config.middleware.use(Middleware, base_path)
35
+ app.config.middleware.use(Middleware, mount_at)
22
36
  app.routes.append do
23
- direct :experiment_redirect do |experiment, to_url|
24
- [base_path, experiment.to_param].join('/') + "?#{to_url}"
37
+ mount Engine, at: mount_at, as: :experiment_engine
38
+ direct(:experiment_redirect) do |ex, options|
39
+ url = options[:url]
40
+ "#{engine.url_helpers.experiment_url(ex)}?#{url}"
25
41
  end
26
42
  end
27
43
  end
28
-
29
- config.after_initialize { include_dsl }
30
- initializer 'gitlab_experiment.add_middleware' do |app|
31
- add_middleware(app, Configuration.mount_at)
32
- end
33
44
  end
34
45
  end
35
46
  end
@@ -4,5 +4,6 @@ module Gitlab
4
4
  class Experiment
5
5
  Error = Class.new(StandardError)
6
6
  InvalidRolloutRules = Class.new(Error)
7
+ NestingError = Class.new(Error)
7
8
  end
8
9
  end
@@ -6,8 +6,8 @@ module Gitlab
6
6
  def self.redirect(id, url)
7
7
  raise Error, 'no url to redirect to' if url.blank?
8
8
 
9
- Gitlab::Experiment.from_param(id).tap { |e| e.track('visited', url: url) }
10
- [303, { 'Location' => url }, []]
9
+ experiment = Gitlab::Experiment.from_param(id)
10
+ [303, { 'Location' => experiment.process_redirect_url(url) || raise(Error, 'not redirecting') }, []]
11
11
  end
12
12
 
13
13
  def initialize(app, base_path)
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ class Experiment
5
+ module Nestable
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ set_callback :run, :around, :manage_nested_stack
10
+ end
11
+
12
+ def nest_experiment(other)
13
+ raise NestingError, "unable to nest the #{other.name} experiment within the #{name} experiment"
14
+ end
15
+
16
+ private
17
+
18
+ def manage_nested_stack
19
+ Stack.push(self)
20
+ yield
21
+ ensure
22
+ Stack.pop
23
+ end
24
+
25
+ class Stack
26
+ include Singleton
27
+
28
+ @stack = []
29
+
30
+ class << self
31
+ delegate :pop, :length, :size, :[], to: :@stack
32
+
33
+ def push(instance)
34
+ @stack.last&.nest_experiment(instance)
35
+ @stack.push(instance)
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -2,6 +2,10 @@
2
2
 
3
3
  module Gitlab
4
4
  class Experiment
5
+ module TestBehaviors
6
+ autoload :Trackable, 'gitlab/experiment/test_behaviors/trackable.rb'
7
+ end
8
+
5
9
  module RSpecHelpers
6
10
  def stub_experiments(experiments, times = nil)
7
11
  experiments.each { |experiment| wrapped_experiment(experiment, times) }
@@ -82,7 +86,7 @@ module Gitlab
82
86
  extend RSpec::Matchers::DSL
83
87
 
84
88
  def require_experiment(experiment, matcher_name, classes: false)
85
- klass = experiment.class == Class ? experiment : experiment.class
89
+ klass = experiment.instance_of?(Class) ? experiment : experiment.class
86
90
  unless klass <= Gitlab::Experiment
87
91
  raise(
88
92
  ArgumentError,
@@ -234,6 +238,10 @@ RSpec.configure do |config|
234
238
 
235
239
  config.before(:each, :experiment) do
236
240
  RequestStore.clear!
241
+
242
+ if defined?(Gitlab::Experiment::TestBehaviors::TrackedStructure)
243
+ Gitlab::Experiment::TestBehaviors::TrackedStructure.reset!
244
+ end
237
245
  end
238
246
 
239
247
  config.include Gitlab::Experiment::RSpecMatchers, :experiment
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ class Experiment
5
+ module TestBehaviors
6
+ module Trackable
7
+ private
8
+
9
+ def manage_nested_stack
10
+ TrackedStructure.push(self)
11
+ super
12
+ ensure
13
+ TrackedStructure.pop
14
+ end
15
+ end
16
+
17
+ class TrackedStructure
18
+ include Singleton
19
+
20
+ # dependency tracking
21
+ @flat = {}
22
+ @stack = []
23
+
24
+ # structure tracking
25
+ @tree = { name: nil, count: 0, children: {} }
26
+ @node = @tree
27
+
28
+ class << self
29
+ def reset!
30
+ # dependency tracking
31
+ @flat = {}
32
+ @stack = []
33
+
34
+ # structure tracking
35
+ @tree = { name: nil, count: 0, children: {} }
36
+ @node = @tree
37
+ end
38
+
39
+ def hierarchy
40
+ @tree[:children]
41
+ end
42
+
43
+ def dependencies
44
+ @flat
45
+ end
46
+
47
+ def push(instance)
48
+ # dependency tracking
49
+ @flat[instance.name] = ((@flat[instance.name] || []) + @stack.map(&:name)).uniq
50
+ @stack.push(instance)
51
+
52
+ # structure tracking
53
+ @last = @node
54
+ @node = @node[:children][instance.name] ||= { name: instance.name, count: 0, children: {} }
55
+ @node[:count] += 1
56
+ end
57
+
58
+ def pop
59
+ # dependency tracking
60
+ @stack.pop
61
+
62
+ # structure tracking
63
+ @node = @last
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Gitlab
4
4
  class Experiment
5
- VERSION = '0.6.1'
5
+ VERSION = '0.6.5'
6
6
  end
7
7
  end
@@ -19,6 +19,7 @@ require 'gitlab/experiment/cookies'
19
19
  require 'gitlab/experiment/context'
20
20
  require 'gitlab/experiment/dsl'
21
21
  require 'gitlab/experiment/middleware'
22
+ require 'gitlab/experiment/nestable'
22
23
  require 'gitlab/experiment/variant'
23
24
  require 'gitlab/experiment/version'
24
25
  require 'gitlab/experiment/engine' if defined?(Rails::Engine)
@@ -28,6 +29,7 @@ module Gitlab
28
29
  include BaseInterface
29
30
  include Cache
30
31
  include Callbacks
32
+ include Nestable
31
33
 
32
34
  class << self
33
35
  def default_rollout(rollout = nil, options = {})
@@ -103,7 +105,9 @@ module Gitlab
103
105
  end
104
106
 
105
107
  def run(variant_name = nil)
106
- @result ||= super(variant(variant_name).name)
108
+ return @result if context.frozen?
109
+
110
+ @result = run_callbacks(:run) { super(variant(variant_name).name) }
107
111
  rescue Scientist::BehaviorMissing => e
108
112
  raise Error, e
109
113
  end
@@ -120,6 +124,13 @@ module Gitlab
120
124
  instance_exec(action, event_args, &Configuration.tracking_behavior)
121
125
  end
122
126
 
127
+ def process_redirect_url(url)
128
+ return unless Configuration.redirect_url_validator&.call(url)
129
+
130
+ track('visited', url: url)
131
+ url # return the url, which allows for mutation
132
+ end
133
+
123
134
  def enabled?
124
135
  true
125
136
  end
@@ -148,6 +159,8 @@ module Gitlab
148
159
  return instance_exec(source, seed, &block)
149
160
  end
150
161
 
162
+ return source if source.is_a?(String)
163
+
151
164
  source = source.keys + source.values if source.is_a?(Hash)
152
165
 
153
166
  ingredients = Array(source).map { |v| identify(v) }
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.6.1
4
+ version: 0.6.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - GitLab
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-06-22 00:00:00.000000000 Z
11
+ date:
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -65,38 +65,55 @@ executables: []
65
65
  extensions: []
66
66
  extra_rdoc_files: []
67
67
  files:
68
- - LICENSE.txt
69
- - README.md
70
- - lib/generators/gitlab/experiment/USAGE
71
- - lib/generators/gitlab/experiment/experiment_generator.rb
68
+ - lib/generators/gitlab
69
+ - lib/generators/gitlab/experiment
70
+ - lib/generators/gitlab/experiment/install
72
71
  - lib/generators/gitlab/experiment/install/install_generator.rb
73
- - lib/generators/gitlab/experiment/install/templates/POST_INSTALL
72
+ - lib/generators/gitlab/experiment/install/templates
74
73
  - lib/generators/gitlab/experiment/install/templates/application_experiment.rb.tt
75
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
76
79
  - lib/generators/gitlab/experiment/templates/experiment.rb.tt
77
- - lib/generators/rspec/experiment/experiment_generator.rb
78
- - lib/generators/rspec/experiment/templates/experiment_spec.rb.tt
80
+ - lib/generators/test_unit
81
+ - lib/generators/test_unit/experiment
79
82
  - lib/generators/test_unit/experiment/experiment_generator.rb
83
+ - lib/generators/test_unit/experiment/templates
80
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
81
90
  - lib/gitlab/experiment.rb
82
- - lib/gitlab/experiment/base_interface.rb
83
- - lib/gitlab/experiment/cache.rb
91
+ - lib/gitlab/experiment
92
+ - lib/gitlab/experiment/variant.rb
93
+ - lib/gitlab/experiment/middleware.rb
94
+ - lib/gitlab/experiment/test_behaviors
95
+ - lib/gitlab/experiment/test_behaviors/trackable.rb
96
+ - lib/gitlab/experiment/cache
84
97
  - lib/gitlab/experiment/cache/redis_hash_store.rb
98
+ - lib/gitlab/experiment/errors.rb
85
99
  - lib/gitlab/experiment/callbacks.rb
86
- - lib/gitlab/experiment/configuration.rb
100
+ - lib/gitlab/experiment/rollout.rb
101
+ - lib/gitlab/experiment/base_interface.rb
102
+ - lib/gitlab/experiment/nestable.rb
87
103
  - lib/gitlab/experiment/context.rb
88
- - lib/gitlab/experiment/cookies.rb
89
- - lib/gitlab/experiment/dsl.rb
90
104
  - lib/gitlab/experiment/engine.rb
91
- - lib/gitlab/experiment/errors.rb
92
- - lib/gitlab/experiment/middleware.rb
93
- - lib/gitlab/experiment/rollout.rb
94
- - lib/gitlab/experiment/rollout/percent.rb
105
+ - lib/gitlab/experiment/rspec.rb
106
+ - lib/gitlab/experiment/rollout
95
107
  - lib/gitlab/experiment/rollout/random.rb
96
108
  - lib/gitlab/experiment/rollout/round_robin.rb
97
- - lib/gitlab/experiment/rspec.rb
98
- - lib/gitlab/experiment/variant.rb
109
+ - lib/gitlab/experiment/rollout/percent.rb
110
+ - lib/gitlab/experiment/cache.rb
99
111
  - lib/gitlab/experiment/version.rb
112
+ - lib/gitlab/experiment/cookies.rb
113
+ - lib/gitlab/experiment/configuration.rb
114
+ - lib/gitlab/experiment/dsl.rb
115
+ - LICENSE.txt
116
+ - README.md
100
117
  homepage: https://gitlab.com/gitlab-org/gitlab-experiment
101
118
  licenses:
102
119
  - MIT
@@ -116,7 +133,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
116
133
  - !ruby/object:Gem::Version
117
134
  version: '0'
118
135
  requirements: []
119
- rubygems_version: 3.2.20
136
+ rubygems_version: 3.1.6
120
137
  signing_key:
121
138
  specification_version: 4
122
139
  summary: GitLab experiment library built on top of scientist.