gitlab-experiment 0.4.2 → 0.4.7

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