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 +4 -4
- data/README.md +31 -3
- data/lib/gitlab/experiment.rb +31 -26
- data/lib/gitlab/experiment/callbacks.rb +14 -0
- data/lib/gitlab/experiment/context.rb +8 -0
- data/lib/gitlab/experiment/rspec_matchers.rb +91 -0
- data/lib/gitlab/experiment/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 51c0f257bd0cea5fa7b970e764ac5cce9f24c5a844673f05579d1c902a46fc39
|
4
|
+
data.tar.gz: ddd93dc46cdde532f20edd5209651d511105157ee9dd13d58c5a60cb229cd3ca
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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.
|
data/lib/gitlab/experiment.rb
CHANGED
@@ -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
|
-
|
87
|
-
|
88
|
-
|
89
|
-
@
|
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:
|
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(
|
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
|
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
|
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.
|
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-
|
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
|