trailguide 0.2.1 → 0.3.0
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 +191 -293
- data/app/views/trail_guide/admin/experiments/_btn_join.html.erb +1 -1
- data/app/views/trail_guide/admin/experiments/_header.html.erb +3 -3
- data/config/initializers/admin.rb +19 -0
- data/config/initializers/experiment.rb +261 -0
- data/config/initializers/trailguide.rb +6 -279
- data/lib/trail_guide/adapters.rb +2 -0
- data/lib/trail_guide/adapters/experiments.rb +8 -0
- data/lib/trail_guide/adapters/experiments/redis.rb +48 -0
- data/lib/trail_guide/adapters/participants/cookie.rb +1 -0
- data/lib/trail_guide/adapters/participants/unity.rb +9 -1
- data/lib/trail_guide/adapters/variants.rb +8 -0
- data/lib/trail_guide/adapters/variants/redis.rb +52 -0
- data/lib/trail_guide/admin/engine.rb +1 -0
- data/lib/trail_guide/algorithms.rb +4 -0
- data/lib/trail_guide/algorithms/algorithm.rb +29 -0
- data/lib/trail_guide/algorithms/bandit.rb +9 -18
- data/lib/trail_guide/algorithms/distributed.rb +8 -15
- data/lib/trail_guide/algorithms/random.rb +2 -12
- data/lib/trail_guide/algorithms/static.rb +34 -0
- data/lib/trail_guide/algorithms/weighted.rb +5 -17
- data/lib/trail_guide/catalog.rb +79 -35
- data/lib/trail_guide/config.rb +2 -4
- data/lib/trail_guide/engine.rb +2 -1
- data/lib/trail_guide/experiments/base.rb +41 -24
- data/lib/trail_guide/experiments/combined_config.rb +4 -0
- data/lib/trail_guide/experiments/config.rb +59 -30
- data/lib/trail_guide/experiments/participant.rb +4 -2
- data/lib/trail_guide/helper.rb +4 -216
- data/lib/trail_guide/helper/experiment_proxy.rb +160 -0
- data/lib/trail_guide/helper/helper_proxy.rb +62 -0
- data/lib/trail_guide/metrics/config.rb +2 -0
- data/lib/trail_guide/metrics/goal.rb +17 -15
- data/lib/trail_guide/participant.rb +10 -2
- data/lib/trail_guide/unity.rb +17 -8
- data/lib/trail_guide/variant.rb +15 -11
- data/lib/trail_guide/version.rb +2 -2
- metadata +13 -3
data/lib/trail_guide/helper.rb
CHANGED
@@ -1,3 +1,6 @@
|
|
1
|
+
require 'trail_guide/helper/helper_proxy'
|
2
|
+
require 'trail_guide/helper/experiment_proxy'
|
3
|
+
|
1
4
|
module TrailGuide
|
2
5
|
module Helper
|
3
6
|
def trailguide(key=nil, **opts, &block)
|
@@ -13,6 +16,7 @@ module TrailGuide
|
|
13
16
|
@trailguide_participant
|
14
17
|
end
|
15
18
|
|
19
|
+
# TODO maybe move this to the experiment proxy so it can be configured per-experiment
|
16
20
|
def trailguide_excluded_request?
|
17
21
|
@trailguide_excluded_request ||= instance_exec(self, &TrailGuide.configuration.request_filter)
|
18
22
|
end
|
@@ -61,221 +65,5 @@ module TrailGuide
|
|
61
65
|
instance_exec(&@ip_address_filter_proc)
|
62
66
|
end
|
63
67
|
end
|
64
|
-
|
65
|
-
class HelperProxy
|
66
|
-
attr_reader :context
|
67
|
-
|
68
|
-
def initialize(context, participant: nil)
|
69
|
-
@context = context
|
70
|
-
@participant = participant
|
71
|
-
end
|
72
|
-
|
73
|
-
def new(key)
|
74
|
-
ExperimentProxy.new(context, key, participant: participant)
|
75
|
-
end
|
76
|
-
|
77
|
-
def choose!(key, **opts, &block)
|
78
|
-
new(key).choose!(**opts, &block)
|
79
|
-
end
|
80
|
-
alias_method :enroll!, :choose!
|
81
|
-
|
82
|
-
def choose(key, **opts, &block)
|
83
|
-
new(key).choose(**opts, &block)
|
84
|
-
end
|
85
|
-
alias_method :enroll, :choose
|
86
|
-
|
87
|
-
def run!(key, **opts)
|
88
|
-
new(key).run!(**opts)
|
89
|
-
end
|
90
|
-
|
91
|
-
def run(key, **opts)
|
92
|
-
new(key).run(**opts)
|
93
|
-
end
|
94
|
-
|
95
|
-
def render!(key, **opts)
|
96
|
-
new(key).render!(**opts)
|
97
|
-
end
|
98
|
-
|
99
|
-
def render(key, **opts)
|
100
|
-
new(key).render(**opts)
|
101
|
-
end
|
102
|
-
|
103
|
-
def convert!(key, checkpoint=nil, **opts, &block)
|
104
|
-
new(key).convert!(checkpoint, **opts, &block)
|
105
|
-
end
|
106
|
-
|
107
|
-
def convert(key, checkpoint=nil, **opts, &block)
|
108
|
-
new(key).convert(checkpoint, **opts, &block)
|
109
|
-
end
|
110
|
-
|
111
|
-
def participant
|
112
|
-
@participant ||= context.send(:trailguide_participant)
|
113
|
-
end
|
114
|
-
|
115
|
-
def context_type
|
116
|
-
if context.is_a?(ActionView::Context)
|
117
|
-
:template
|
118
|
-
elsif context.is_a?(ActionController::Base)
|
119
|
-
:controller
|
120
|
-
end
|
121
|
-
end
|
122
|
-
end
|
123
|
-
|
124
|
-
class ExperimentProxy < HelperProxy
|
125
|
-
attr_reader :key
|
126
|
-
|
127
|
-
def initialize(context, key, **opts)
|
128
|
-
super(context, **opts)
|
129
|
-
@key = key.to_s.underscore.to_sym
|
130
|
-
end
|
131
|
-
|
132
|
-
def choose!(**opts, &block)
|
133
|
-
raise NoExperimentsError, key if experiments.empty?
|
134
|
-
raise TooManyExperimentsError, "Selecting a variant requires a single experiment, but `#{key}` matches more than one experiment." if experiments.length > 1
|
135
|
-
raise TooManyExperimentsError, "Selecting a variant requires a single experiment, but `#{key}` refers to a combined experiment." if experiment.combined?
|
136
|
-
opts = {override: override_variant, excluded: exclude_visitor?}.merge(opts)
|
137
|
-
variant = experiment.choose!(**opts)
|
138
|
-
if block_given?
|
139
|
-
yield variant, opts[:metadata]
|
140
|
-
else
|
141
|
-
variant
|
142
|
-
end
|
143
|
-
end
|
144
|
-
alias_method :enroll!, :choose!
|
145
|
-
|
146
|
-
def choose(**opts, &block)
|
147
|
-
choose!(**opts, &block)
|
148
|
-
rescue NoExperimentsError => e
|
149
|
-
raise e
|
150
|
-
rescue => e
|
151
|
-
TrailGuide.logger.error e
|
152
|
-
experiment.control
|
153
|
-
end
|
154
|
-
alias_method :enroll, :choose
|
155
|
-
|
156
|
-
def run!(methods: nil, **opts)
|
157
|
-
choose!(**opts) do |variant, metadata|
|
158
|
-
varmeth = methods[variant.name] if methods
|
159
|
-
varmeth ||= variant.name
|
160
|
-
|
161
|
-
unless context.respond_to?(varmeth, true)
|
162
|
-
if context_type == :controller
|
163
|
-
raise NoVariantMethodError,
|
164
|
-
"Undefined local method `#{varmeth}`. You must define a controller method matching the variant `#{variant.name}` in your experiment `#{key}`. In this case it looks like you need to define #{context.class.name}##{varmeth}(metadata={})"
|
165
|
-
elsif context_type == :template
|
166
|
-
raise NoVariantMethodError,
|
167
|
-
"Undefined local method `#{varmeth}`. You must define a helper method matching the variant `#{variant.name}` in your experiment `#{key}`. In this case it looks like you need to define ApplicationHelper##{varmeth}(metadata={})"
|
168
|
-
else
|
169
|
-
raise NoVariantMethodError,
|
170
|
-
"Undefined local method `#{varmeth}`. You must define a method matching the variant `#{variant.name}` in your experiment `#{key}`. In this case it looks like you need to define #{context.class.name}##{varmeth}(metadata={})"
|
171
|
-
end
|
172
|
-
end
|
173
|
-
|
174
|
-
arguments = context.method(varmeth).parameters
|
175
|
-
if arguments.empty?
|
176
|
-
context.send(varmeth)
|
177
|
-
elsif arguments.length > 1 || arguments[0][0] == :rest
|
178
|
-
context.send(varmeth, variant, **variant.metadata)
|
179
|
-
elsif arguments.length == 1
|
180
|
-
context.send(varmeth, **variant.metadata)
|
181
|
-
end
|
182
|
-
end
|
183
|
-
end
|
184
|
-
|
185
|
-
def run(methods: nil, **opts)
|
186
|
-
run!(methods: methods, **opts)
|
187
|
-
rescue NoExperimentsError => e
|
188
|
-
raise e
|
189
|
-
rescue => e
|
190
|
-
TrailGuide.logger.error e
|
191
|
-
false
|
192
|
-
end
|
193
|
-
|
194
|
-
def render!(prefix: nil, templates: nil, locals: {}, **opts)
|
195
|
-
raise UnsupportedContextError, "The current context (#{context}) does not support rendering. Rendering is only available in controllers and views." unless context.respond_to?(:render, true)
|
196
|
-
choose!(**opts) do |variant, metadata|
|
197
|
-
locals = { variant: variant, metadata: variant.metadata }.merge(locals)
|
198
|
-
locals = { locals: locals } if context_type == :controller
|
199
|
-
|
200
|
-
template = templates[variant.name] if templates
|
201
|
-
prefix ||= (context.try(:view_context) || context).lookup_context.prefixes.first + '/'
|
202
|
-
template ||= "#{prefix.to_s}#{variant.experiment.experiment_name.to_s.underscore}/#{variant.name.to_s.underscore}"
|
203
|
-
|
204
|
-
context.send(:render, template.to_s, **locals)
|
205
|
-
end
|
206
|
-
end
|
207
|
-
|
208
|
-
def render(prefix: nil, templates: nil, locals: {}, **opts)
|
209
|
-
render!(prefix: prefix, templates: templates, locals: locals, **opts)
|
210
|
-
rescue NoExperimentsError => e
|
211
|
-
raise e
|
212
|
-
rescue => e
|
213
|
-
TrailGuide.logger.error e
|
214
|
-
false
|
215
|
-
end
|
216
|
-
|
217
|
-
def convert!(checkpoint=nil, **opts, &block)
|
218
|
-
raise NoExperimentsError, key if experiments.empty?
|
219
|
-
checkpoints = experiments.map do |experiment|
|
220
|
-
ckpt = checkpoint || experiment.goals.find { |g| g == key }
|
221
|
-
if experiment.combined?
|
222
|
-
experiment.combined_experiments.map do |combo|
|
223
|
-
combo.convert!(ckpt, **opts)
|
224
|
-
end
|
225
|
-
else
|
226
|
-
experiment.convert!(ckpt, **opts)
|
227
|
-
end
|
228
|
-
end.flatten
|
229
|
-
|
230
|
-
return false unless checkpoints.any?
|
231
|
-
|
232
|
-
if block_given?
|
233
|
-
yield checkpoints, opts[:metadata]
|
234
|
-
else
|
235
|
-
checkpoints
|
236
|
-
end
|
237
|
-
rescue NoExperimentsError => e
|
238
|
-
unless TrailGuide.configuration.ignore_orphaned_groups?
|
239
|
-
trace = e.backtrace.find { |t| !t.match?(Regexp.new(__FILE__)) }
|
240
|
-
.to_s.split(Rails.root.to_s).last
|
241
|
-
.split(':').first(2).join(':')
|
242
|
-
TrailGuide.catalog.orphaned(key, trace)
|
243
|
-
end
|
244
|
-
false
|
245
|
-
end
|
246
|
-
|
247
|
-
def convert(checkpoint=nil, **opts, &block)
|
248
|
-
convert!(checkpoint, **opts, &block)
|
249
|
-
rescue => e
|
250
|
-
TrailGuide.logger.error e
|
251
|
-
false
|
252
|
-
end
|
253
|
-
|
254
|
-
def experiments
|
255
|
-
@experiments ||= TrailGuide.catalog.select(key).map do |experiment|
|
256
|
-
experiment.new(participant)
|
257
|
-
end
|
258
|
-
end
|
259
|
-
|
260
|
-
def experiment
|
261
|
-
@experiment ||= experiments.first
|
262
|
-
end
|
263
|
-
|
264
|
-
def override_variant
|
265
|
-
return unless context.respond_to?(:params, true)
|
266
|
-
params = context.send(:params)
|
267
|
-
return unless params.key?(TrailGuide.configuration.override_parameter)
|
268
|
-
experiment_params = params[TrailGuide.configuration.override_parameter]
|
269
|
-
return unless experiment_params.key?(experiment.experiment_name.to_s)
|
270
|
-
varname = experiment_params[experiment.experiment_name.to_s]
|
271
|
-
variant = experiment.variants.find { |var| var == varname }
|
272
|
-
variant.try(:name)
|
273
|
-
end
|
274
|
-
|
275
|
-
def exclude_visitor?
|
276
|
-
return false if experiment.configuration.skip_request_filter?
|
277
|
-
context.send(:trailguide_excluded_request?)
|
278
|
-
end
|
279
|
-
end
|
280
68
|
end
|
281
69
|
end
|
@@ -0,0 +1,160 @@
|
|
1
|
+
module TrailGuide
|
2
|
+
module Helper
|
3
|
+
class ExperimentProxy < HelperProxy
|
4
|
+
attr_reader :key
|
5
|
+
|
6
|
+
def initialize(context, key, **opts)
|
7
|
+
super(context, **opts)
|
8
|
+
@key = key.to_s.underscore.to_sym
|
9
|
+
end
|
10
|
+
|
11
|
+
def choose!(**opts, &block)
|
12
|
+
raise NoExperimentsError, key if experiments.empty?
|
13
|
+
raise TooManyExperimentsError, "Selecting a variant requires a single experiment, but `#{key}` matches more than one experiment." if experiments.length > 1
|
14
|
+
raise TooManyExperimentsError, "Selecting a variant requires a single experiment, but `#{key}` refers to a combined experiment." if experiment.combined?
|
15
|
+
opts = {override: override_variant, excluded: exclude_visitor?}.merge(opts)
|
16
|
+
variant = experiment.choose!(**opts)
|
17
|
+
if block_given?
|
18
|
+
yield variant, opts[:metadata]
|
19
|
+
else
|
20
|
+
variant
|
21
|
+
end
|
22
|
+
end
|
23
|
+
alias_method :enroll!, :choose!
|
24
|
+
|
25
|
+
def choose(**opts, &block)
|
26
|
+
choose!(**opts, &block)
|
27
|
+
rescue NoExperimentsError => e
|
28
|
+
raise e
|
29
|
+
rescue => e
|
30
|
+
TrailGuide.logger.error e
|
31
|
+
experiment.control
|
32
|
+
end
|
33
|
+
alias_method :enroll, :choose
|
34
|
+
|
35
|
+
def run!(methods: nil, **opts)
|
36
|
+
choose!(**opts) do |variant, metadata|
|
37
|
+
varmeth = methods[variant.name] if methods
|
38
|
+
varmeth ||= variant.name
|
39
|
+
|
40
|
+
unless context.respond_to?(varmeth, true)
|
41
|
+
if context_type == :controller
|
42
|
+
raise NoVariantMethodError,
|
43
|
+
"Undefined local method `#{varmeth}`. You must define a controller method matching the variant `#{variant.name}` in your experiment `#{key}`. In this case it looks like you need to define #{context.class.name}##{varmeth}(metadata={})"
|
44
|
+
elsif context_type == :template
|
45
|
+
raise NoVariantMethodError,
|
46
|
+
"Undefined local method `#{varmeth}`. You must define a helper method matching the variant `#{variant.name}` in your experiment `#{key}`. In this case it looks like you need to define ApplicationHelper##{varmeth}(metadata={})"
|
47
|
+
else
|
48
|
+
raise NoVariantMethodError,
|
49
|
+
"Undefined local method `#{varmeth}`. You must define a method matching the variant `#{variant.name}` in your experiment `#{key}`. In this case it looks like you need to define #{context.class.name}##{varmeth}(metadata={})"
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
arguments = context.method(varmeth).parameters
|
54
|
+
if arguments.empty?
|
55
|
+
context.send(varmeth)
|
56
|
+
elsif arguments.length > 1 || arguments[0][0] == :rest
|
57
|
+
context.send(varmeth, variant, **variant.metadata)
|
58
|
+
elsif arguments.length == 1
|
59
|
+
context.send(varmeth, **variant.metadata)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def run(methods: nil, **opts)
|
65
|
+
run!(methods: methods, **opts)
|
66
|
+
rescue NoExperimentsError => e
|
67
|
+
raise e
|
68
|
+
rescue => e
|
69
|
+
TrailGuide.logger.error e
|
70
|
+
false
|
71
|
+
end
|
72
|
+
|
73
|
+
def render!(prefix: nil, templates: nil, locals: {}, **opts)
|
74
|
+
raise UnsupportedContextError, "The current context (#{context}) does not support rendering. Rendering is only available in controllers and views." unless context.respond_to?(:render, true)
|
75
|
+
choose!(**opts) do |variant, metadata|
|
76
|
+
locals = { variant: variant, metadata: variant.metadata }.merge(locals)
|
77
|
+
locals = { locals: locals } if context_type == :controller
|
78
|
+
|
79
|
+
template = templates[variant.name] if templates
|
80
|
+
prefix ||= (context.try(:view_context) || context).lookup_context.prefixes.first + '/' rescue ''
|
81
|
+
template ||= "#{prefix.to_s}#{variant.experiment.experiment_name.to_s.underscore}/#{variant.name.to_s.underscore}"
|
82
|
+
|
83
|
+
context.send(:render, template.to_s, **locals)
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
def render(prefix: nil, templates: nil, locals: {}, **opts)
|
88
|
+
render!(prefix: prefix, templates: templates, locals: locals, **opts)
|
89
|
+
rescue NoExperimentsError => e
|
90
|
+
raise e
|
91
|
+
rescue => e
|
92
|
+
TrailGuide.logger.error e
|
93
|
+
false
|
94
|
+
end
|
95
|
+
|
96
|
+
def convert!(checkpoint=nil, **opts, &block)
|
97
|
+
raise NoExperimentsError, key if experiments.empty?
|
98
|
+
checkpoints = experiments.map do |experiment|
|
99
|
+
ckpt = checkpoint || experiment.goals.find { |g| g == key }
|
100
|
+
if experiment.combined?
|
101
|
+
experiment.combined_experiments.map do |combo|
|
102
|
+
combo.convert!(ckpt, **opts)
|
103
|
+
end
|
104
|
+
else
|
105
|
+
experiment.convert!(ckpt, **opts)
|
106
|
+
end
|
107
|
+
end.flatten
|
108
|
+
|
109
|
+
return false unless checkpoints.any?
|
110
|
+
|
111
|
+
if block_given?
|
112
|
+
yield checkpoints, opts[:metadata]
|
113
|
+
else
|
114
|
+
checkpoints
|
115
|
+
end
|
116
|
+
rescue NoExperimentsError => e
|
117
|
+
unless TrailGuide.configuration.ignore_orphaned_groups?
|
118
|
+
trace = e.backtrace.find { |t| !t.match?(Regexp.new(__FILE__)) }
|
119
|
+
.to_s.split(Rails.root.to_s).last
|
120
|
+
.split(':').first(2).join(':')
|
121
|
+
TrailGuide.catalog.orphaned(key, trace)
|
122
|
+
end
|
123
|
+
false
|
124
|
+
end
|
125
|
+
|
126
|
+
def convert(checkpoint=nil, **opts, &block)
|
127
|
+
convert!(checkpoint, **opts, &block)
|
128
|
+
rescue => e
|
129
|
+
TrailGuide.logger.error e
|
130
|
+
false
|
131
|
+
end
|
132
|
+
|
133
|
+
def experiments
|
134
|
+
@experiments ||= TrailGuide.catalog.select(key).map do |experiment|
|
135
|
+
experiment.new(participant)
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
def experiment
|
140
|
+
@experiment ||= experiments.first
|
141
|
+
end
|
142
|
+
|
143
|
+
def override_variant
|
144
|
+
return unless context.respond_to?(:params, true)
|
145
|
+
params = context.send(:params)
|
146
|
+
return unless params.key?(TrailGuide.configuration.override_parameter)
|
147
|
+
experiment_params = params[TrailGuide.configuration.override_parameter]
|
148
|
+
return unless experiment_params.key?(experiment.experiment_name.to_s)
|
149
|
+
varname = experiment_params[experiment.experiment_name.to_s]
|
150
|
+
variant = experiment.variants.find { |var| var == varname }
|
151
|
+
variant.try(:name)
|
152
|
+
end
|
153
|
+
|
154
|
+
def exclude_visitor?
|
155
|
+
return false if experiment.configuration.skip_request_filter?
|
156
|
+
context.send(:trailguide_excluded_request?)
|
157
|
+
end
|
158
|
+
end
|
159
|
+
end
|
160
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
module TrailGuide
|
2
|
+
module Helper
|
3
|
+
class HelperProxy
|
4
|
+
attr_reader :context
|
5
|
+
|
6
|
+
def initialize(context, participant: nil)
|
7
|
+
@context = context
|
8
|
+
@participant = participant
|
9
|
+
end
|
10
|
+
|
11
|
+
def new(key)
|
12
|
+
ExperimentProxy.new(context, key, participant: participant)
|
13
|
+
end
|
14
|
+
|
15
|
+
def choose!(key, **opts, &block)
|
16
|
+
new(key).choose!(**opts, &block)
|
17
|
+
end
|
18
|
+
alias_method :enroll!, :choose!
|
19
|
+
|
20
|
+
def choose(key, **opts, &block)
|
21
|
+
new(key).choose(**opts, &block)
|
22
|
+
end
|
23
|
+
alias_method :enroll, :choose
|
24
|
+
|
25
|
+
def run!(key, **opts)
|
26
|
+
new(key).run!(**opts)
|
27
|
+
end
|
28
|
+
|
29
|
+
def run(key, **opts)
|
30
|
+
new(key).run(**opts)
|
31
|
+
end
|
32
|
+
|
33
|
+
def render!(key, **opts)
|
34
|
+
new(key).render!(**opts)
|
35
|
+
end
|
36
|
+
|
37
|
+
def render(key, **opts)
|
38
|
+
new(key).render(**opts)
|
39
|
+
end
|
40
|
+
|
41
|
+
def convert!(key, checkpoint=nil, **opts, &block)
|
42
|
+
new(key).convert!(checkpoint, **opts, &block)
|
43
|
+
end
|
44
|
+
|
45
|
+
def convert(key, checkpoint=nil, **opts, &block)
|
46
|
+
new(key).convert(checkpoint, **opts, &block)
|
47
|
+
end
|
48
|
+
|
49
|
+
def participant
|
50
|
+
@participant ||= context.send(:trailguide_participant)
|
51
|
+
end
|
52
|
+
|
53
|
+
def context_type
|
54
|
+
if context.is_a?(ActionView::Context)
|
55
|
+
:template
|
56
|
+
elsif context.is_a?(ActionController::Base)
|
57
|
+
:controller
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|