gitlab-experiment 0.4.2 → 0.4.7

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: 865d51c081670824fc7ffb48d4bc764fd5370606d3aa63b2196421b5570506e7
4
- data.tar.gz: a007f872a3d56ee81c9925117f603e4930d1b7d689078edb074a2ba5567833f2
3
+ metadata.gz: a26425622afb7aa3f341ff403192963e276876b65902237018f852117168a19a
4
+ data.tar.gz: 0a5238c73a4b0217810b4f8161df6452b683b21a6573f1b657517302cef9cef0
5
5
  SHA512:
6
- metadata.gz: 03d401a71b952b74519a21fa851a87ee9e104ccd19011af37ac41c36bec5fd63db3e68c62c05beb34ab296d354e00e15f013dd34a547c3b4c5c94474b89096cc
7
- data.tar.gz: 2bb0260e40d4689446c1317273d85140fae9f56394821fda96c7bbc5d1acbe441f3e4024bf00ec05812f64f5588897137fc8061e790d871c5719742f6cedf516
6
+ metadata.gz: 567ca93e3f931c167bc1766490b07e1535db72e3b5da5986715412746354945ed799dab4c075e91c5015aa922bf5d7e5a82bdc940808aa8100c8897d5ee623b7
7
+ data.tar.gz: 3f053ed2ad8bdfc2201aacaef9c723332fba260f4f4cb54c65a92539d74ec100297540c34c97579c99723f0d98e98dc57cbe383a0731128d623b0a58b4786d7c
data/README.md CHANGED
@@ -15,7 +15,7 @@ When we discuss the behavior of this gem, we'll use terms like experiment, conte
15
15
  - `candidate` defines that there's one experimental code path.
16
16
  - `variant(s)` is used when more than one experimental code path exists.
17
17
 
18
- Candidate and variant are the same concept, but simplify how we speak about experimental paths.<br clear="all">
18
+ Candidate and variant are the same concept, but simplify how we speak about experimental paths -- this concept is also widely referred to as the "experiment group".<br clear="all">
19
19
 
20
20
  [[_TOC_]]
21
21
 
@@ -100,6 +100,9 @@ Here are some examples of what you can introduce once you have a custom experime
100
100
 
101
101
  ```ruby
102
102
  class NotificationToggleExperiment < ApplicationExperiment
103
+ # Exclude any users that aren't me.
104
+ exclude :all_but_me
105
+
103
106
  # Segment any account less than 2 weeks old into the candidate, without
104
107
  # asking the variant resolver to decide which variant to provide.
105
108
  segment :account_age, variant: :candidate
@@ -107,17 +110,21 @@ class NotificationToggleExperiment < ApplicationExperiment
107
110
  # Define the default control behavior, which can be overridden at
108
111
  # experiment time.
109
112
  def control_behavior
110
- render_toggle
113
+ # render_toggle
111
114
  end
112
115
 
113
116
  # Define the default candidate behavior, which can be overridden
114
117
  # at experiment time.
115
118
  def candidate_behavior
116
- render_button
119
+ # render_button
117
120
  end
118
121
 
119
122
  private
120
123
 
124
+ def all_but_me
125
+ context.actor&.username == 'jejacks0n'
126
+ end
127
+
121
128
  def account_age
122
129
  context.actor && context.actor.created_at < 2.weeks.ago
123
130
  end
@@ -177,7 +184,7 @@ Generally, defining segmentation rules is a better way to approach routing into
177
184
  experiment(:notification_toggle, :no_interface, actor: user) do |e|
178
185
  e.use { render_toggle } # control
179
186
  e.try { render_button } # candidate
180
- e.try(:no_interface) { no_interface! } # variant
187
+ e.try(:no_interface) { no_interface! } # no_interface variant
181
188
  end
182
189
  ```
183
190
 
@@ -222,6 +229,27 @@ When an experiment is run, the segmentation rules are executed in the order they
222
229
 
223
230
  This means that any user with the name `'jejacks0n'`, regardless of account age, will always be provided the experience as defined in "variant_one".
224
231
 
232
+ ### Exclusion rules
233
+
234
+ Exclusion rules are similar to segmentation rules, but are intended to determine if a context should even be considered as something we should track events towards. Exclusion means we don't care about the events in relation to the given context.
235
+
236
+ ```ruby
237
+ class NotificationToggleExperiment < ApplicationExperiment
238
+ exclude { context.actor.username != 'jejacks0n' }
239
+ exclude { context.actor.created_at < 2.weeks.ago }
240
+ end
241
+ ```
242
+
243
+ The previous examples will force all users with a username not matching `'jejacks0n'` and all newish users into the control. No events would be tracked for those.
244
+
245
+ You may need to check exclusion in custom tracking logic, by checking `excluded?` or wrapping your code in a callback block:
246
+
247
+ ```ruby
248
+ run_callbacks(:exclusion_check) do
249
+ track(:my_event, value: expensive_method_call)
250
+ end
251
+ ```
252
+
225
253
  ### Return value
226
254
 
227
255
  By default the return value is a `Gitlab::Experiment` instance. In simple cases you may want only the results of the experiment though. You can call `run` within the block to get the return value of the assigned variant.
@@ -341,7 +369,7 @@ Gitlab::Experiment.configure do |config|
341
369
  end
342
370
  ```
343
371
 
344
- More examples for configuration are available in the provided [rails initializer](lib/generators/gitlab/experiment/install/templates/initializer.rb).
372
+ More examples for configuration are available in the provided [rails initializer](lib/generators/gitlab/experiment/install/templates/initializer.rb.tt).
345
373
 
346
374
  ### Client layer / JavaScript
347
375
 
@@ -388,7 +416,7 @@ Each of these approaches could be desirable given the objectives of your experim
388
416
  ## Development
389
417
 
390
418
  After checking out the repo, run `bundle install` to install dependencies.
391
- Then, run `bundle exec rspec` to run the tests. You can also run `bundle exec pry` for an
419
+ Then, run `bundle exec rake` to run the tests. You can also run `bundle exec pry` for an
392
420
  interactive prompt that will allow you to experiment.
393
421
 
394
422
  ## Contributing
@@ -25,17 +25,17 @@ Gitlab::Experiment.configure do |config|
25
25
  # Logic this project uses to resolve a variant for a given experiment.
26
26
  #
27
27
  # Should return a symbol or string that represents the variant that should
28
- # be assigned.
28
+ # be assigned. Blank or nil values will be defaulted to the control.
29
29
  #
30
30
  # This block is executed within the scope of the experiment and so can access
31
31
  # experiment methods, like `name`, `context`, and `signature`.
32
32
  config.variant_resolver = lambda do |requested_variant|
33
33
  # Run the control, unless a variant was requested in code:
34
- requested_variant || 'control'
34
+ requested_variant
35
35
 
36
36
  # Run the candidate, unless a variant was requested, with a fallback:
37
37
  #
38
- # requested_variant || variant_names.first || 'control'
38
+ # requested_variant || variant_names.first || nil
39
39
  end
40
40
 
41
41
  # Tracking behavior can be implemented to link an event to an experiment.
@@ -30,7 +30,7 @@ module Gitlab
30
30
  raise ArgumentError, 'name is required' if name.nil? && base?
31
31
 
32
32
  instance = constantize(name).new(name, variant_name, **context, &block)
33
- return instance unless block_given?
33
+ return instance unless block
34
34
 
35
35
  instance.context.frozen? ? instance.run : instance.tap(&:run)
36
36
  end
@@ -58,11 +58,9 @@ module Gitlab
58
58
  raise ArgumentError, 'name is required' if name.blank? && self.class.base?
59
59
 
60
60
  @name = self.class.experiment_name(name, suffix: false)
61
- @variant_name = variant_name
62
- @excluded = []
63
- @context = Context.new(self, context)
61
+ @context = Context.new(self, **context)
62
+ @variant_name = cache_variant(variant_name) { nil } if variant_name.present?
64
63
 
65
- exclude { !@context.trackable? }
66
64
  compare { false }
67
65
 
68
66
  yield self if block_given?
@@ -76,26 +74,31 @@ module Gitlab
76
74
  end
77
75
 
78
76
  def variant(value = nil)
77
+ if value.blank? && @variant_name || @resolving_variant
78
+ return Variant.new(name: (@variant_name || :unresolved).to_s)
79
+ end
80
+
79
81
  @variant_name = value unless value.blank?
80
- @variant_name ||= :control if excluded?
81
82
 
82
- resolved = cache { resolve_variant_name }
83
- Variant.new(name: (resolved.presence || :control).to_s)
84
- end
83
+ if enabled?
84
+ @variant_name ||= :control if excluded?
85
85
 
86
- def exclude(&block)
87
- @excluded << block
86
+ @resolving_variant = true
87
+ if (result = cache_variant(@variant_name) { resolve_variant_name }).present?
88
+ @variant_name = result.to_sym
89
+ end
90
+ end
91
+
92
+ Variant.new(name: (@variant_name || :control).to_s)
93
+ ensure
94
+ @resolving_variant = false
88
95
  end
89
96
 
90
97
  def run(variant_name = nil)
91
98
  @result ||= begin
92
99
  variant_name = variant(variant_name).name
93
- run_callbacks(variant_assigned? ? :unsegmented_run : :segmented_run) do
94
- if respond_to?((behavior_name = "#{variant_name}_behavior"))
95
- behaviors[variant_name] ||= -> { send(behavior_name) } # rubocop:disable GitlabSecurity/PublicSend
96
- end
97
-
98
- super(@variant_name = variant_name)
100
+ run_callbacks(run_with_segmenting? ? :segmented_run : :unsegmented_run) do
101
+ super(@variant_name ||= variant_name)
99
102
  end
100
103
  end
101
104
  end
@@ -105,7 +108,7 @@ module Gitlab
105
108
  end
106
109
 
107
110
  def track(action, **event_args)
108
- return if excluded?
111
+ return unless should_track?
109
112
 
110
113
  instance_exec(action, event_args, &Configuration.tracking_behavior)
111
114
  end
@@ -118,20 +121,22 @@ module Gitlab
118
121
  @variant_names ||= behaviors.keys.map(&:to_sym) - [:control]
119
122
  end
120
123
 
121
- def signature
122
- { variant: variant.name, experiment: name }.merge(context.signature)
123
- end
124
+ def behaviors
125
+ @behaviors ||= public_methods.each_with_object(super) do |name, behaviors|
126
+ next unless name.end_with?('_behavior')
124
127
 
125
- def enabled?
126
- true
128
+ behavior_name = name.to_s.sub(/_behavior$/, '')
129
+ behaviors[behavior_name] ||= -> { send(name) } # rubocop:disable GitlabSecurity/PublicSend
130
+ end
127
131
  end
128
132
 
129
- def excluded?
130
- @excluded.any? { |exclude| exclude.call(self) }
133
+ def try(name = nil, &block)
134
+ name = (name || 'candidate').to_s
135
+ behaviors[name] = block
131
136
  end
132
137
 
133
- def variant_assigned?
134
- !@variant_name.nil?
138
+ def signature
139
+ { variant: variant.name, experiment: name }.merge(context.signature)
135
140
  end
136
141
 
137
142
  def id
@@ -143,19 +148,35 @@ module Gitlab
143
148
  "Experiment;#{id}"
144
149
  end
145
150
 
151
+ def enabled?
152
+ true
153
+ end
154
+
155
+ def excluded?
156
+ @excluded ||= !@context.trackable? || # adhere to DNT headers
157
+ !run_callbacks(:exclusion_check) { :not_excluded } # didn't pass exclusion check
158
+ end
159
+
160
+ def should_track?
161
+ enabled? && !excluded?
162
+ end
163
+
146
164
  def key_for(hash)
147
165
  instance_exec(hash, &Configuration.context_hash_strategy)
148
166
  end
149
167
 
150
168
  protected
151
169
 
152
- def resolve_variant_name
153
- return :unresolved if @resolving
170
+ def run_with_segmenting?
171
+ !variant_assigned? && enabled? && !excluded?
172
+ end
173
+
174
+ def variant_assigned?
175
+ !@variant_name.nil?
176
+ end
154
177
 
155
- @resolving = true
156
- result = instance_exec(@variant_name, &Configuration.variant_resolver)
157
- @resolving = false
158
- result
178
+ def resolve_variant_name
179
+ instance_exec(@variant_name, &Configuration.variant_resolver)
159
180
  end
160
181
 
161
182
  def generate_result(variant_name)
@@ -3,20 +3,26 @@
3
3
  module Gitlab
4
4
  class Experiment
5
5
  module Caching
6
- def cache(&block)
7
- return yield unless (cache = Configuration.cache)
6
+ def cache_variant(specified = nil, &block)
7
+ cache = Configuration.cache
8
+ return (specified.presence || yield) unless cache
8
9
 
9
10
  key, migrations = cache_strategy
10
- migrated_cache(cache, migrations || [], key) or cache.fetch(key, &block)
11
+ result = migrated_cache(cache, migrations || [], key) || cache.fetch(key, &block)
12
+ return result unless specified.present?
13
+
14
+ cache.write(cache_key, specified) if result != specified
15
+ specified
16
+ end
17
+
18
+ def cache_key(key = nil)
19
+ "#{name}:#{key || context.signature[:key]}"
11
20
  end
12
21
 
13
22
  private
14
23
 
15
24
  def cache_strategy
16
- [
17
- "#{name}:#{context.signature[:key]}",
18
- context.signature[:migration_keys]&.map { |key| "#{name}:#{key}" }
19
- ]
25
+ [cache_key, context.signature[:migration_keys]&.map { |key| cache_key(key) }]
20
26
  end
21
27
 
22
28
  def migrated_cache(cache, migrations, new_key)
@@ -7,31 +7,34 @@ module Gitlab
7
7
  include ActiveSupport::Callbacks
8
8
 
9
9
  included do
10
- define_callbacks(
11
- :unsegmented_run,
12
- skip_after_callbacks_if_terminated: true
13
- )
14
-
15
- define_callbacks(
16
- :segmented_run,
17
- skip_after_callbacks_if_terminated: false,
18
- terminator: lambda do |target, result_lambda|
19
- result_lambda.call
20
- target.variant_assigned?
21
- end
22
- )
10
+ define_callbacks(:unsegmented_run)
11
+ define_callbacks(:segmented_run)
12
+ define_callbacks(:exclusion_check, skip_after_callbacks_if_terminated: true)
23
13
  end
24
14
 
25
15
  class_methods do
16
+ def exclude(*filter_list, **options, &block)
17
+ filters = filter_list.unshift(block).compact.map do |filter|
18
+ result_lambda = ActiveSupport::Callbacks::CallTemplate.build(filter, self).make_lambda
19
+ lambda do |target|
20
+ throw(:abort) if target.instance_variable_get(:'@excluded') || result_lambda.call(target, nil) == true
21
+ end
22
+ end
23
+
24
+ raise ArgumentError, 'no filters provided' if filters.empty?
25
+
26
+ set_callback(:exclusion_check, :before, *filters, options)
27
+ end
28
+
26
29
  def segment(*filter_list, variant:, **options, &block)
27
30
  filters = filter_list.unshift(block).compact.map do |filter|
28
31
  result_lambda = ActiveSupport::Callbacks::CallTemplate.build(filter, self).make_lambda
29
- ->(target) { target.variant(variant) if result_lambda.call(target, nil) }
32
+ ->(target) { target.variant(variant) if !target.variant_assigned? && result_lambda.call(target, nil) }
30
33
  end
31
34
 
32
35
  raise ArgumentError, 'no filters provided' if filters.empty?
33
36
 
34
- set_callback(:segmented_run, :before, *filters, options, &block)
37
+ set_callback(:segmented_run, :before, *filters, options)
35
38
  end
36
39
  end
37
40
  end
@@ -25,8 +25,9 @@ module Gitlab
25
25
  @cookie_domain = :all
26
26
 
27
27
  # Logic this project uses to resolve a variant for a given experiment.
28
+ # If no variant is determined, the control will be used.
28
29
  @variant_resolver = lambda do |requested_variant|
29
- requested_variant || :control
30
+ requested_variant
30
31
  end
31
32
 
32
33
  # Tracking behavior can be implemented to link an event to an experiment.
@@ -42,6 +42,14 @@ module Gitlab
42
42
  @signature ||= { key: @experiment.key_for(@value), migration_keys: migration_keys }.compact
43
43
  end
44
44
 
45
+ def method_missing(method_name, *)
46
+ @value.include?(method_name.to_sym) ? @value[method_name.to_sym] : super
47
+ end
48
+
49
+ def respond_to_missing?(method_name, *)
50
+ @value.include?(method_name.to_sym) ? true : super
51
+ end
52
+
45
53
  private
46
54
 
47
55
  def process_migrations(value)
@@ -11,7 +11,7 @@ module Gitlab
11
11
  return hash if cookie_jar.nil?
12
12
 
13
13
  resolver = [hash, :actor, cookie_name, cookie_jar.signed[cookie_name]]
14
- resolve_cookie(*resolver) or generate_cookie(*resolver)
14
+ resolve_cookie(*resolver) || generate_cookie(*resolver)
15
15
  end
16
16
 
17
17
  def cookie_jar
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ class Experiment
5
+ module RSpecHelpers
6
+ def stub_experiments(experiments)
7
+ experiments.each do |name, variant|
8
+ variant = :control if variant == false
9
+ raise ArgumentError, 'variant must be a symbol or false' unless variant.is_a?(Symbol)
10
+
11
+ klass = Gitlab::Experiment.send(:constantize, name) # rubocop:disable GitlabSecurity/PublicSend
12
+ allow(klass).to receive(:new).and_wrap_original do |new, *args, &block|
13
+ new.call(*args).tap do |instance|
14
+ allow(instance).to receive(:enabled?).and_return(true)
15
+ allow(instance).to receive(:resolve_variant_name).and_return(variant.to_s)
16
+
17
+ block.call(instance) if block.present?
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+
24
+ module RSpecMatchers
25
+ extend RSpec::Matchers::DSL
26
+
27
+ matcher :exclude do |context|
28
+ ivar = :'@excluded'
29
+
30
+ match do |experiment|
31
+ experiment.context(context)
32
+
33
+ experiment.instance_variable_set(ivar, nil)
34
+ !experiment.run_callbacks(:exclusion_check) { :not_excluded }
35
+ end
36
+
37
+ failure_message do
38
+ %(expected #{context} to be excluded)
39
+ end
40
+
41
+ failure_message_when_negated do
42
+ %(expected #{context} not to be excluded)
43
+ end
44
+ end
45
+
46
+ matcher :segment do |context|
47
+ ivar = :'@variant_name'
48
+
49
+ match do |experiment|
50
+ experiment.context(context)
51
+
52
+ experiment.instance_variable_set(ivar, nil)
53
+ experiment.run_callbacks(:segmented_run)
54
+
55
+ @actual = experiment.instance_variable_get(ivar)
56
+ @expected ? @actual.to_s == @expected.to_s : @actual.present?
57
+ end
58
+
59
+ chain :into do |expected|
60
+ raise ArgumentError, 'variant name must be provided' if expected.blank?
61
+
62
+ @expected = expected.to_s
63
+ end
64
+
65
+ failure_message do
66
+ %(expected #{context} to be segmented#{message_details})
67
+ end
68
+
69
+ failure_message_when_negated do
70
+ %(expected #{context} not to be segmented#{message_details})
71
+ end
72
+
73
+ def message_details
74
+ message = ''
75
+ message += %(\n into: #{@expected}) if @expected
76
+ message += %(\n actual: #{@actual}) if @actual
77
+ message
78
+ end
79
+ end
80
+
81
+ matcher :track do |event, *event_args|
82
+ match do |experiment|
83
+ wrapped(experiment) { |instance| expect(instance).to receive_with(event, *event_args) }
84
+ end
85
+
86
+ match_when_negated do |experiment|
87
+ wrapped(experiment) { |instance| expect(instance).not_to receive_with(event, *event_args) }
88
+ end
89
+
90
+ def wrapped(experiment, &block)
91
+ allow(experiment.class).to receive(:new).and_wrap_original do |new, *new_args|
92
+ new.call(*new_args).tap(&block)
93
+ end
94
+ end
95
+
96
+ def receive_with(*args)
97
+ receive(:track).with(*args) # rubocop:disable CodeReuse/ActiveRecord
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
103
+
104
+ RSpec.configure do |config|
105
+ config.include Gitlab::Experiment::RSpecHelpers
106
+
107
+ config.include Gitlab::Experiment::RSpecMatchers, :experiment
108
+ config.define_derived_metadata(file_path: %r{/spec/experiments/}) do |metadata|
109
+ metadata[:type] = :experiment
110
+ end
111
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Gitlab
4
4
  class Experiment
5
- VERSION = '0.4.2'
5
+ VERSION = '0.4.7'
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.4.2
4
+ version: 0.4.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - GitLab
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-11-19 00:00:00.000000000 Z
11
+ date: 2021-01-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -72,6 +72,7 @@ files:
72
72
  - lib/gitlab/experiment/cookies.rb
73
73
  - lib/gitlab/experiment/dsl.rb
74
74
  - lib/gitlab/experiment/engine.rb
75
+ - lib/gitlab/experiment/rspec.rb
75
76
  - lib/gitlab/experiment/variant.rb
76
77
  - lib/gitlab/experiment/version.rb
77
78
  homepage: https://gitlab.com/gitlab-org/gitlab-experiment