gitlab-experiment 0.4.5 → 0.4.6

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