gitlab-experiment 0.4.5 → 0.4.6

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: 81476752521c6ead83308324fdcf360db7b8182bab340c9ffe7ad4eec21b998e
4
- data.tar.gz: 006d94ff92104bef820be7d6c7c9638b20a6e0a4a533439fca056b4cba31a0bc
3
+ metadata.gz: 51c0f257bd0cea5fa7b970e764ac5cce9f24c5a844673f05579d1c902a46fc39
4
+ data.tar.gz: ddd93dc46cdde532f20edd5209651d511105157ee9dd13d58c5a60cb229cd3ca
5
5
  SHA512:
6
- metadata.gz: a5644f3cf6798ddc27034b8857cf03bb77904b3b5c8a80b84c398134ad143b919e16ddc23746a58e63b71d0464c92ec5cce682ddb2039e471b55cfeb42e3cd4a
7
- data.tar.gz: 2dd2adf62427f96a9745d9681ee87b5621acf43fc4dae33ffefdadfad5af6ec3a0ad1e79e601a4d6260e61fa64faf635af9ae753e3fb474e033af7d2d7d55381
6
+ metadata.gz: 243606b6b55ebe069296c564ad801a6b25075a85d2b2ca38eb47e836b75b655fa9472fbcf0a0cb01c83093b97d529f8b2aec04389d79119064b5021c975b0042
7
+ data.tar.gz: 1900d8b44458d2cf1e2086cc48d5e3ccc19160926c1dc9b827b7a93d042edf7354e1eeb35cae12e0c33f133aef39f605dbd5446edf987c1d99a38264d3b97d72
data/README.md CHANGED
@@ -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.
@@ -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
- @excluded = []
62
61
  @context = Context.new(self, **context)
63
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?
@@ -81,27 +79,25 @@ module Gitlab
81
79
  end
82
80
 
83
81
  @variant_name = value unless value.blank?
84
- @variant_name ||= :control if excluded?
85
82
 
86
- @resolving_variant = true
87
- resolved = :control
88
- if (result = cache_variant(@variant_name) { resolve_variant_name }).present?
89
- @variant_name = resolved = result.to_sym
83
+ if enabled?
84
+ @variant_name ||= :control if excluded?
85
+
86
+ @resolving_variant = true
87
+ if (result = cache_variant(@variant_name) { resolve_variant_name }).present?
88
+ @variant_name = result.to_sym
89
+ end
90
90
  end
91
91
 
92
- Variant.new(name: resolved.to_s)
92
+ Variant.new(name: (@variant_name || :control).to_s)
93
93
  ensure
94
94
  @resolving_variant = false
95
95
  end
96
96
 
97
- def exclude(&block)
98
- @excluded << block
99
- end
100
-
101
97
  def run(variant_name = nil)
102
98
  @result ||= begin
103
99
  variant_name = variant(variant_name).name
104
- run_callbacks(variant_assigned? ? :unsegmented_run : :segmented_run) do
100
+ run_callbacks(run_with_segmenting? ? :segmented_run : :unsegmented_run) do
105
101
  super(@variant_name ||= variant_name)
106
102
  end
107
103
  end
@@ -112,7 +108,7 @@ module Gitlab
112
108
  end
113
109
 
114
110
  def track(action, **event_args)
115
- return if excluded?
111
+ return unless should_track?
116
112
 
117
113
  instance_exec(action, event_args, &Configuration.tracking_behavior)
118
114
  end
@@ -143,18 +139,6 @@ module Gitlab
143
139
  { variant: variant.name, experiment: name }.merge(context.signature)
144
140
  end
145
141
 
146
- def enabled?
147
- true
148
- end
149
-
150
- def excluded?
151
- @excluded.any? { |exclude| exclude.call(self) }
152
- end
153
-
154
- def variant_assigned?
155
- !@variant_name.nil?
156
- end
157
-
158
142
  def id
159
143
  "#{name}:#{key_for(context.value)}"
160
144
  end
@@ -170,6 +154,27 @@ module Gitlab
170
154
 
171
155
  protected
172
156
 
157
+ def enabled?
158
+ true
159
+ end
160
+
161
+ def excluded?
162
+ @excluded ||= !@context.trackable? || # adhere to DNT headers
163
+ !run_callbacks(:exclusion_check) { :not_excluded } # didn't pass exclusion check
164
+ end
165
+
166
+ def should_track?
167
+ enabled? && !excluded?
168
+ end
169
+
170
+ def run_with_segmenting?
171
+ !variant_assigned? && enabled? && !excluded?
172
+ end
173
+
174
+ def variant_assigned?
175
+ !@variant_name.nil?
176
+ end
177
+
173
178
  def resolve_variant_name
174
179
  instance_exec(@variant_name, &Configuration.variant_resolver)
175
180
  end
@@ -9,9 +9,23 @@ module Gitlab
9
9
  included do
10
10
  define_callbacks(:unsegmented_run)
11
11
  define_callbacks(:segmented_run)
12
+ define_callbacks(:exclusion_check, skip_after_callbacks_if_terminated: true)
12
13
  end
13
14
 
14
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
+
15
29
  def segment(*filter_list, variant:, **options, &block)
16
30
  filters = filter_list.unshift(block).compact.map do |filter|
17
31
  result_lambda = ActiveSupport::Callbacks::CallTemplate.build(filter, self).make_lambda
@@ -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)
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ class Experiment
5
+ module RspecMatchers
6
+ extend RSpec::Matchers::DSL
7
+
8
+ matcher :exclude do |context|
9
+ ivar = :'@excluded'
10
+
11
+ match do |experiment|
12
+ experiment.context(context)
13
+
14
+ experiment.instance_variable_set(ivar, nil)
15
+ !experiment.run_callbacks(:exclusion_check) { :not_excluded }
16
+ end
17
+
18
+ failure_message do
19
+ %(expected #{context} to be excluded)
20
+ end
21
+
22
+ failure_message_when_negated do
23
+ %(expected #{context} not to be excluded)
24
+ end
25
+ end
26
+
27
+ matcher :segment do |context|
28
+ ivar = :'@variant_name'
29
+
30
+ match do |experiment|
31
+ experiment.context(context)
32
+
33
+ experiment.instance_variable_set(ivar, nil)
34
+ experiment.run_callbacks(:segmented_run)
35
+
36
+ @actual = experiment.instance_variable_get(ivar)
37
+ @expected ? @actual.to_s == @expected.to_s : @actual.present?
38
+ end
39
+
40
+ chain :into do |expected|
41
+ raise ArgumentError, 'variant name must be provided' if expected.blank?
42
+
43
+ @expected = expected.to_s
44
+ end
45
+
46
+ failure_message do
47
+ %(expected #{context} to be segmented#{message_details})
48
+ end
49
+
50
+ failure_message_when_negated do
51
+ %(expected #{context} not to be segmented#{message_details})
52
+ end
53
+
54
+ def message_details
55
+ message = ''
56
+ message += %(\n into: #{@expected}) if @expected
57
+ message += %(\n actual: #{@actual}) if @actual
58
+ message
59
+ end
60
+ end
61
+
62
+ matcher :track do |event, *event_args|
63
+ match do |experiment|
64
+ wrapped(experiment) { |instance| expect(instance).to receive_with(event, *event_args) }
65
+ end
66
+
67
+ match_when_negated do |experiment|
68
+ wrapped(experiment) { |instance| expect(instance).not_to receive_with(event, *event_args) }
69
+ end
70
+
71
+ def wrapped(experiment, &block)
72
+ allow(experiment.class).to receive(:new).and_wrap_original do |new, *new_args|
73
+ new.call(*new_args).tap(&block)
74
+ end
75
+ end
76
+
77
+ def receive_with(*args)
78
+ receive(:track).with(*args) # rubocop:disable CodeReuse/ActiveRecord
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
84
+
85
+ RSpec.configure do |config|
86
+ config.define_derived_metadata(file_path: Regexp.new('/spec/experiments/')) do |metadata|
87
+ metadata[:type] = :experiment
88
+ end
89
+
90
+ config.include Gitlab::Experiment::RspecMatchers, :experiment
91
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Gitlab
4
4
  class Experiment
5
- VERSION = '0.4.5'
5
+ VERSION = '0.4.6'
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.5
4
+ version: 0.4.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - GitLab
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-01-12 00:00:00.000000000 Z
11
+ date: 2021-01-21 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_matchers.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