trailguide 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +28 -0
- data/config/routes.rb +2 -0
- data/lib/trail_guide/adapters/participants/anonymous.rb +70 -0
- data/lib/trail_guide/adapters/participants/cookie.rb +99 -0
- data/lib/trail_guide/adapters/participants/multi.rb +41 -0
- data/lib/trail_guide/adapters/participants/redis.rb +83 -0
- data/lib/trail_guide/adapters/participants/session.rb +73 -0
- data/lib/trail_guide/adapters/participants/unity.rb +107 -0
- data/lib/trail_guide/adapters/participants.rb +12 -0
- data/lib/trail_guide/adapters.rb +6 -0
- data/lib/trail_guide/algorithms/bandit.rb +48 -0
- data/lib/trail_guide/algorithms/distributed.rb +26 -0
- data/lib/trail_guide/algorithms/random.rb +19 -0
- data/lib/trail_guide/algorithms/weighted.rb +25 -0
- data/lib/trail_guide/algorithms.rb +24 -0
- data/lib/trail_guide/catalog.rb +71 -0
- data/lib/trail_guide/engine.rb +65 -0
- data/lib/trail_guide/experiment.rb +327 -0
- data/lib/trail_guide/helper.rb +185 -0
- data/lib/trail_guide/participant.rb +110 -0
- data/lib/trail_guide/unity.rb +87 -0
- data/lib/trail_guide/variant.rb +103 -0
- data/lib/trail_guide/version.rb +14 -0
- data/lib/trailguide.rb +57 -0
- metadata +153 -0
@@ -0,0 +1,24 @@
|
|
1
|
+
require "trail_guide/algorithms/weighted"
|
2
|
+
require "trail_guide/algorithms/distributed"
|
3
|
+
require "trail_guide/algorithms/bandit"
|
4
|
+
require "trail_guide/algorithms/random"
|
5
|
+
|
6
|
+
module TrailGuide
|
7
|
+
module Algorithms
|
8
|
+
def self.algorithm(algo)
|
9
|
+
case algo
|
10
|
+
when :weighted
|
11
|
+
algo = TrailGuide::Algorithms::Weighted
|
12
|
+
when :bandit
|
13
|
+
algo = TrailGuide::Algorithms::Bandit
|
14
|
+
when :distributed
|
15
|
+
algo = TrailGuide::Algorithms::Distributed
|
16
|
+
when :random
|
17
|
+
algo = TrailGuide::Algorithms::Random
|
18
|
+
else
|
19
|
+
algo = algo.constantize if algo.is_a?(String)
|
20
|
+
end
|
21
|
+
algo
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
module TrailGuide
|
2
|
+
class Catalog
|
3
|
+
include Enumerable
|
4
|
+
|
5
|
+
class << self
|
6
|
+
def catalog
|
7
|
+
@catalog ||= new
|
8
|
+
end
|
9
|
+
|
10
|
+
def register(klass)
|
11
|
+
catalog.register(klass)
|
12
|
+
end
|
13
|
+
|
14
|
+
def find(name)
|
15
|
+
catalog.find(name)
|
16
|
+
end
|
17
|
+
|
18
|
+
def select(name)
|
19
|
+
catalog.select(name)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
attr_reader :experiments
|
24
|
+
|
25
|
+
def initialize(experiments=[])
|
26
|
+
@experiments = experiments
|
27
|
+
end
|
28
|
+
|
29
|
+
def each(&block)
|
30
|
+
experiments.each(&block)
|
31
|
+
end
|
32
|
+
|
33
|
+
def find(name)
|
34
|
+
if name.is_a?(Class)
|
35
|
+
experiments.find { |exp| exp == name }
|
36
|
+
else
|
37
|
+
experiments.find do |exp|
|
38
|
+
exp.experiment_name == name.to_s.underscore.to_sym ||
|
39
|
+
exp.metric == name.to_s.underscore.to_sym ||
|
40
|
+
exp.name == name.to_s.classify
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def select(name)
|
46
|
+
if name.is_a?(Class)
|
47
|
+
experiments.select { |exp| exp == name }
|
48
|
+
else
|
49
|
+
experiments.select do |exp|
|
50
|
+
exp.experiment_name == name.to_s.underscore.to_sym ||
|
51
|
+
exp.metric == name.to_s.underscore.to_sym ||
|
52
|
+
exp.name == name.to_s.classify
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
def register(klass)
|
58
|
+
experiments << klass unless experiments.any? { |exp| exp == klass }
|
59
|
+
klass
|
60
|
+
end
|
61
|
+
|
62
|
+
def method_missing(meth, *args, &block)
|
63
|
+
return experiments.send(meth, *args, &block) if experiments.respond_to?(meth, true)
|
64
|
+
super
|
65
|
+
end
|
66
|
+
|
67
|
+
def respond_to_missing?(meth, include_private=false)
|
68
|
+
experiments.respond_to?(meth, include_private)
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
module TrailGuide
|
2
|
+
class Engine < ::Rails::Engine
|
3
|
+
isolate_namespace TrailGuide
|
4
|
+
|
5
|
+
config.generators do |g|
|
6
|
+
g.test_framework = :rspec
|
7
|
+
end
|
8
|
+
|
9
|
+
initializer "trailguide" do |app|
|
10
|
+
TrailGuide::Engine.load_experiments
|
11
|
+
ActionController::Base.send :include, TrailGuide::Helper
|
12
|
+
ActionController::Base.helper TrailGuide::Helper
|
13
|
+
end
|
14
|
+
|
15
|
+
def self.load_experiments
|
16
|
+
# Load experiments from YAML configs if any exists
|
17
|
+
load_yaml_experiments(Rails.root.join("config/experiments.yml"))
|
18
|
+
Dir[Rails.root.join("config/experiments/**/*.yml")].each { |f| load_yaml_experiments(f) }
|
19
|
+
|
20
|
+
# Load experiments from ruby configs if any exist
|
21
|
+
DSL.instance_eval(File.read(Rails.root.join("config/experiments.rb"))) if File.exists?(Rails.root.join("config/experiments.rb"))
|
22
|
+
Dir[Rails.root.join("config/experiments/**/*.rb")].each { |f| DSL.instance_eval(File.read(f)) }
|
23
|
+
|
24
|
+
# Load any experiment classes defined in the app
|
25
|
+
Dir[Rails.root.join("app/experiments/**/*.rb")].each { |f| load f }
|
26
|
+
end
|
27
|
+
|
28
|
+
def self.load_yaml_experiments(file)
|
29
|
+
experiments = (YAML.load_file(file) || {} rescue {})
|
30
|
+
.symbolize_keys.map { |k,v| [k, v.symbolize_keys] }.to_h
|
31
|
+
|
32
|
+
experiments.each do |name, options|
|
33
|
+
expvars = options[:variants].map do |var|
|
34
|
+
if var.is_a?(Array)
|
35
|
+
[var[0], var[1].symbolize_keys]
|
36
|
+
else
|
37
|
+
[var]
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
DSL.experiment(name) do
|
42
|
+
expvars.each do |expvar|
|
43
|
+
variant *expvar
|
44
|
+
end
|
45
|
+
control options[:control] if options[:control]
|
46
|
+
algorithm options[:algorithm] if options[:algorithm]
|
47
|
+
goals options[:goals] if options[:goals]
|
48
|
+
metric options[:metric] if options[:metric]
|
49
|
+
resettable options[:resettable] if options.key?(:resettable)
|
50
|
+
allow_multiple_conversions options[:allow_multiple_conversions] if options.key?(:allow_multiple_conversions)
|
51
|
+
allow_multiple_goals options[:allow_multiple_goals] if options.key?(:allow_multiple_goals)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
class DSL
|
57
|
+
def self.experiment(name, &block)
|
58
|
+
Class.new(TrailGuide::Experiment) do
|
59
|
+
experiment_name name
|
60
|
+
instance_eval &block
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
@@ -0,0 +1,327 @@
|
|
1
|
+
module TrailGuide
|
2
|
+
class Experiment
|
3
|
+
class << self
|
4
|
+
def inherited(child)
|
5
|
+
# TODO allow inheriting algo, variants, goals, metrics, etc.
|
6
|
+
TrailGuide::Catalog.register(child)
|
7
|
+
end
|
8
|
+
|
9
|
+
# TODO could probably move all this configuration stuff at the class level
|
10
|
+
# into a canfig object instead...?
|
11
|
+
def experiment_name(name=nil)
|
12
|
+
@experiment_name = name.to_s.underscore.to_sym unless name.nil?
|
13
|
+
@experiment_name || self.name.try(:underscore).try(:to_sym)
|
14
|
+
end
|
15
|
+
|
16
|
+
def config_algorithm
|
17
|
+
config_algo = TrailGuide.configuration.algorithm
|
18
|
+
case config_algo
|
19
|
+
when :weighted
|
20
|
+
config_algo = TrailGuide::Algorithms::Weighted
|
21
|
+
when :bandit
|
22
|
+
config_algo = TrailGuide::Algorithms::Bandit
|
23
|
+
when :distributed
|
24
|
+
config_algo = TrailGuide::Algorithms::Distributed
|
25
|
+
when :random
|
26
|
+
config_algo = TrailGuide::Algorithms::Random
|
27
|
+
else
|
28
|
+
config_algo = config_algo.constantize if config_algo.is_a?(String)
|
29
|
+
end
|
30
|
+
config_algo
|
31
|
+
end
|
32
|
+
|
33
|
+
def algorithm(algo=nil)
|
34
|
+
@algorithm = TrailGuide::Algorithms.algorithm(algo) unless algo.nil?
|
35
|
+
@algorithm ||= TrailGuide::Algorithms.algorithm(TrailGuide.configuration.algorithm)
|
36
|
+
end
|
37
|
+
|
38
|
+
def resettable(reset)
|
39
|
+
@resettable = reset
|
40
|
+
end
|
41
|
+
|
42
|
+
def resettable?
|
43
|
+
if @resettable.nil?
|
44
|
+
!TrailGuide.configuration.reset_manually
|
45
|
+
else
|
46
|
+
!!@resettable
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def variant(name, metadata: {}, weight: 1, control: false)
|
51
|
+
raise ArgumentError, "The variant #{name} already exists in experiment #{experiment_name}" if variants.any? { |var| var == name }
|
52
|
+
control = true if variants.empty?
|
53
|
+
variant = Variant.new(self, name, metadata: metadata, weight: weight, control: control)
|
54
|
+
variants << variant
|
55
|
+
variant
|
56
|
+
end
|
57
|
+
|
58
|
+
def variants(include_control=true)
|
59
|
+
@variants ||= []
|
60
|
+
if include_control
|
61
|
+
@variants
|
62
|
+
else
|
63
|
+
@variants.select { |var| !var.control? }
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
def control(name=nil)
|
68
|
+
return variants.find { |var| var.control? } || variants.first if name.nil?
|
69
|
+
|
70
|
+
variants.each(&:variant!)
|
71
|
+
var_idx = variants.index { |var| var == name }
|
72
|
+
|
73
|
+
if var_idx.nil?
|
74
|
+
variant = Variant.new(self, name, control: true)
|
75
|
+
else
|
76
|
+
variant = variants.slice!(var_idx, 1)[0]
|
77
|
+
variant.control!
|
78
|
+
end
|
79
|
+
|
80
|
+
variants.unshift(variant)
|
81
|
+
return variant
|
82
|
+
end
|
83
|
+
|
84
|
+
def funnel(name)
|
85
|
+
funnels << name.to_s.underscore.to_sym
|
86
|
+
end
|
87
|
+
alias_method :goal, :funnel
|
88
|
+
|
89
|
+
def funnels(arr=nil)
|
90
|
+
@funnels = arr unless arr.nil?
|
91
|
+
@funnels ||= []
|
92
|
+
end
|
93
|
+
alias_method :goals, :funnels
|
94
|
+
|
95
|
+
def metric(key=nil)
|
96
|
+
@metric = key.to_s.underscore.to_sym unless key.nil?
|
97
|
+
@metric ||= experiment_name
|
98
|
+
end
|
99
|
+
|
100
|
+
def allow_multiple_conversions(allow)
|
101
|
+
@allow_multiple_conversions = allow
|
102
|
+
end
|
103
|
+
|
104
|
+
def allow_multiple_conversions?
|
105
|
+
!!@allow_multiple_conversions
|
106
|
+
end
|
107
|
+
|
108
|
+
def allow_multiple_goals(allow)
|
109
|
+
@allow_multiple_goals = allow
|
110
|
+
end
|
111
|
+
|
112
|
+
def allow_multiple_goals?
|
113
|
+
!!@allow_multiple_goals
|
114
|
+
end
|
115
|
+
|
116
|
+
def callbacks
|
117
|
+
@callbacks ||= begin
|
118
|
+
callbacks = {
|
119
|
+
on_choose: [TrailGuide.configuration.on_experiment_choose].compact,
|
120
|
+
on_use: [TrailGuide.configuration.on_experiment_use].compact,
|
121
|
+
on_convert: [TrailGuide.configuration.on_experiment_convert].compact,
|
122
|
+
on_start: [TrailGuide.configuration.on_experiment_start].compact,
|
123
|
+
on_stop: [TrailGuide.configuration.on_experiment_stop].compact,
|
124
|
+
on_reset: [TrailGuide.configuration.on_experiment_reset].compact,
|
125
|
+
on_delete: [TrailGuide.configuration.on_experiment_delete].compact,
|
126
|
+
}
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
def on_choose(meth=nil, &block)
|
131
|
+
callbacks[:on_choose] << (meth || block)
|
132
|
+
end
|
133
|
+
|
134
|
+
def on_use(meth=nil, &block)
|
135
|
+
callbacks[:on_use] << (meth || block)
|
136
|
+
end
|
137
|
+
|
138
|
+
def on_convert(meth=nil, &block)
|
139
|
+
callbacks[:on_convert] << (meth || block)
|
140
|
+
end
|
141
|
+
|
142
|
+
def on_start(meth=nil, &block)
|
143
|
+
callbacks[:on_start] << (meth || block)
|
144
|
+
end
|
145
|
+
|
146
|
+
def on_stop(meth=nil, &block)
|
147
|
+
callbacks[:on_stop] << (meth || block)
|
148
|
+
end
|
149
|
+
|
150
|
+
def on_reset(meth=nil, &block)
|
151
|
+
callbacks[:on_reset] << (meth || block)
|
152
|
+
end
|
153
|
+
|
154
|
+
def on_delete(meth=nil, &block)
|
155
|
+
callbacks[:on_delete] << (meth || block)
|
156
|
+
end
|
157
|
+
|
158
|
+
def run_callbacks(hook, *args)
|
159
|
+
return unless callbacks[hook]
|
160
|
+
args.unshift(self)
|
161
|
+
callbacks[hook].each do |callback|
|
162
|
+
if callback.respond_to?(:call)
|
163
|
+
callback.call(*args)
|
164
|
+
else
|
165
|
+
send(callback, *args)
|
166
|
+
end
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
def start!
|
171
|
+
return false if started?
|
172
|
+
save! unless persisted?
|
173
|
+
started = TrailGuide.redis.hset(storage_key, 'started_at', Time.now.to_i)
|
174
|
+
run_callbacks(:on_start)
|
175
|
+
started
|
176
|
+
end
|
177
|
+
|
178
|
+
def stop!
|
179
|
+
return false unless started?
|
180
|
+
stopped = TrailGuide.redis.hdel(storage_key, 'started_at')
|
181
|
+
run_callbacks(:on_stop)
|
182
|
+
stopped
|
183
|
+
end
|
184
|
+
|
185
|
+
def started_at
|
186
|
+
started = TrailGuide.redis.hget(storage_key, 'started_at')
|
187
|
+
return Time.at(started.to_i) if started
|
188
|
+
end
|
189
|
+
|
190
|
+
def started?
|
191
|
+
!!started_at
|
192
|
+
end
|
193
|
+
|
194
|
+
def declare_winner!(variant)
|
195
|
+
variant = variant.name if variant.is_a?(Variant)
|
196
|
+
TrailGuide.redis.hset(storage_key, 'winner', variant.to_s.underscore)
|
197
|
+
end
|
198
|
+
|
199
|
+
def winner
|
200
|
+
winner = TrailGuide.redis.hget(storage_key, 'winner')
|
201
|
+
return variants.find { |var| var == winner } if winner
|
202
|
+
end
|
203
|
+
|
204
|
+
def winner?
|
205
|
+
!!winner
|
206
|
+
end
|
207
|
+
|
208
|
+
def persisted?
|
209
|
+
TrailGuide.redis.exists(storage_key)
|
210
|
+
end
|
211
|
+
|
212
|
+
def save!
|
213
|
+
variants.each(&:save!)
|
214
|
+
TrailGuide.redis.hsetnx(storage_key, 'name', experiment_name)
|
215
|
+
end
|
216
|
+
|
217
|
+
def delete!
|
218
|
+
variants.each(&:delete!)
|
219
|
+
deleted = TrailGuide.redis.del(storage_key)
|
220
|
+
run_callbacks(:on_delete)
|
221
|
+
deleted
|
222
|
+
end
|
223
|
+
|
224
|
+
def reset!
|
225
|
+
reset = (delete! && save!)
|
226
|
+
run_callbacks(:on_reset)
|
227
|
+
reset
|
228
|
+
end
|
229
|
+
|
230
|
+
def as_json(opts={})
|
231
|
+
# TODO fill in the rest of the values i've added
|
232
|
+
{
|
233
|
+
experiment_name: experiment_name,
|
234
|
+
algorithm: algorithm,
|
235
|
+
variants: variants.as_json
|
236
|
+
}
|
237
|
+
end
|
238
|
+
|
239
|
+
def storage_key
|
240
|
+
experiment_name
|
241
|
+
end
|
242
|
+
end
|
243
|
+
|
244
|
+
attr_reader :participant
|
245
|
+
delegate :experiment_name, :variants, :control, :funnels, :storage_key,
|
246
|
+
:started?, :started_at, :start!, :resettable?, :winner?, :winner,
|
247
|
+
:allow_multiple_conversions?, :allow_multiple_goals?, :callbacks,
|
248
|
+
to: :class
|
249
|
+
|
250
|
+
def initialize(participant)
|
251
|
+
@participant = participant
|
252
|
+
end
|
253
|
+
|
254
|
+
def algorithm
|
255
|
+
@algorithm ||= self.class.algorithm.new(self)
|
256
|
+
end
|
257
|
+
|
258
|
+
def choose!(metadata: nil, **opts)
|
259
|
+
return control if TrailGuide.configuration.disabled
|
260
|
+
|
261
|
+
variant = choose_variant!(metadata: metadata, **opts)
|
262
|
+
run_callbacks(:on_use, variant, metadata)
|
263
|
+
variant
|
264
|
+
end
|
265
|
+
|
266
|
+
def choose_variant!(override: nil, excluded: false, metadata: nil)
|
267
|
+
return control if TrailGuide.configuration.disabled
|
268
|
+
if override.present?
|
269
|
+
variant = variants.find { |var| var == override }
|
270
|
+
return variant unless TrailGuide.configuration.store_override && started?
|
271
|
+
else
|
272
|
+
return winner if winner?
|
273
|
+
return control if excluded
|
274
|
+
return control if !started? && TrailGuide.configuration.start_manually
|
275
|
+
start! unless started?
|
276
|
+
return variants.find { |var| var == participant[storage_key] } if participating?
|
277
|
+
return control unless TrailGuide.configuration.allow_multiple_experiments == true || !participant.participating_in_active_experiments?(TrailGuide.configuration.allow_multiple_experiments == false)
|
278
|
+
|
279
|
+
variant = algorithm.choose!(metadata: metadata)
|
280
|
+
end
|
281
|
+
|
282
|
+
participant.participating!(variant)
|
283
|
+
variant.increment_participation!
|
284
|
+
run_callbacks(:on_choose, variant, metadata)
|
285
|
+
variant
|
286
|
+
end
|
287
|
+
|
288
|
+
def convert!(checkpoint=nil, metadata: nil)
|
289
|
+
return false unless participating?
|
290
|
+
raise ArgumentError, "You must provide a valid goal checkpoint for #{experiment_name}" unless checkpoint.present? || funnels.empty?
|
291
|
+
raise ArgumentError, "Unknown goal checkpoint: #{checkpoint}" unless checkpoint.nil? || funnels.any? { |funnel| funnel == checkpoint.to_s.underscore.to_sym }
|
292
|
+
# TODO eventually allow progressing through funnel checkpoints towards goals
|
293
|
+
if converted?(checkpoint)
|
294
|
+
return false unless allow_multiple_conversions?
|
295
|
+
elsif converted?
|
296
|
+
return false unless allow_multiple_goals?
|
297
|
+
end
|
298
|
+
|
299
|
+
variant = variants.find { |var| var == participant[storage_key] }
|
300
|
+
# TODO eventually only reset if we're at the final goal in a funnel
|
301
|
+
participant.converted!(variant, checkpoint, reset: resettable?)
|
302
|
+
variant.increment_conversion!(checkpoint)
|
303
|
+
run_callbacks(:on_convert, variant, checkpoint, metadata)
|
304
|
+
variant
|
305
|
+
end
|
306
|
+
|
307
|
+
def participating?
|
308
|
+
participant.participating?(self)
|
309
|
+
end
|
310
|
+
|
311
|
+
def converted?(checkpoint=nil)
|
312
|
+
participant.converted?(self, checkpoint)
|
313
|
+
end
|
314
|
+
|
315
|
+
def run_callbacks(hook, *args)
|
316
|
+
return unless callbacks[hook]
|
317
|
+
args.unshift(self)
|
318
|
+
callbacks[hook].each do |callback|
|
319
|
+
if callback.respond_to?(:call)
|
320
|
+
callback.call(*args)
|
321
|
+
else
|
322
|
+
send(callback, *args)
|
323
|
+
end
|
324
|
+
end
|
325
|
+
end
|
326
|
+
end
|
327
|
+
end
|
@@ -0,0 +1,185 @@
|
|
1
|
+
module TrailGuide
|
2
|
+
module Helper
|
3
|
+
def trailguide(metric=nil, **opts, &block)
|
4
|
+
proxy = HelperProxy.new(self)
|
5
|
+
return proxy if metric.nil?
|
6
|
+
proxy.choose!(metric, **opts, &block)
|
7
|
+
end
|
8
|
+
|
9
|
+
class HelperProxy
|
10
|
+
attr_reader :context
|
11
|
+
|
12
|
+
def initialize(context, participant: nil)
|
13
|
+
@context = context
|
14
|
+
@participant = participant
|
15
|
+
end
|
16
|
+
|
17
|
+
def new(metric)
|
18
|
+
MetricProxy.new(context, metric, participant: participant)
|
19
|
+
end
|
20
|
+
|
21
|
+
def choose!(metric, **opts, &block)
|
22
|
+
new(metric).choose!(**opts, &block)
|
23
|
+
end
|
24
|
+
|
25
|
+
def run!(metric, **opts)
|
26
|
+
new(metric).run!(**opts)
|
27
|
+
end
|
28
|
+
|
29
|
+
def render!(metric, **opts)
|
30
|
+
new(metric).render!(**opts)
|
31
|
+
end
|
32
|
+
|
33
|
+
def convert!(metric, checkpoint=nil, **opts, &block)
|
34
|
+
new(metric).convert!(checkpoint, **opts, &block)
|
35
|
+
end
|
36
|
+
|
37
|
+
def participant
|
38
|
+
@participant ||= TrailGuide::Participant.new(context)
|
39
|
+
end
|
40
|
+
|
41
|
+
def context_type
|
42
|
+
if context.is_a?(ActionView::Context)
|
43
|
+
:template
|
44
|
+
elsif context.is_a?(ActionController::Base)
|
45
|
+
:controller
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
class MetricProxy < HelperProxy
|
51
|
+
attr_reader :metric
|
52
|
+
|
53
|
+
def initialize(context, metric, **opts)
|
54
|
+
super(context, **opts)
|
55
|
+
@metric = metric
|
56
|
+
end
|
57
|
+
|
58
|
+
def choose!(**opts, &block)
|
59
|
+
raise ArgumentError, "Please provide a single experiment" unless experiments.length == 1
|
60
|
+
opts = {override: override_variant, excluded: exclude_visitor?}.merge(opts)
|
61
|
+
variant = experiment.choose!(**opts)
|
62
|
+
if block_given?
|
63
|
+
yield variant, opts[:metadata]
|
64
|
+
else
|
65
|
+
variant
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
def run!(methods: nil, **opts)
|
70
|
+
raise ArgumentError, "Please provide a single experiment" unless experiments.length == 1
|
71
|
+
choose!(**opts) do |variant, metadata|
|
72
|
+
varmeth = methods[variant.name] if methods
|
73
|
+
varmeth ||= variant.name
|
74
|
+
|
75
|
+
unless context.respond_to?(varmeth, true)
|
76
|
+
if context_type == :controller
|
77
|
+
raise NoMethodError,
|
78
|
+
"You must define a controller method that matches variant `#{variant.name}` in your experiment `#{metric}`. In this case it looks like you need to define #{context.class.name}##{varmeth}(metadata={})"
|
79
|
+
elsif context_type == :template
|
80
|
+
raise NoMethodError,
|
81
|
+
"You must define a helper method that matches variant `#{variant.name}` in your experiment `#{metric}`. In this case it looks like you need to define ApplicationHelper##{varmeth}(metadata={})"
|
82
|
+
else
|
83
|
+
raise NoMethodError,
|
84
|
+
"You must define a method that matches variant `#{variant.name}` in your experiment `#{metric}`. In this case it looks like you need to define #{context.class.name}##{varmeth}(metadata={})"
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
arguments = context.method(varmeth).parameters
|
89
|
+
if arguments.empty?
|
90
|
+
context.send(varmeth)
|
91
|
+
elsif arguments.length > 1 || arguments[0][0] == :rest
|
92
|
+
context.send(varmeth, variant, **variant.metadata)
|
93
|
+
elsif arguments.length == 1
|
94
|
+
context.send(varmeth, **variant.metadata)
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
def render!(prefix: nil, templates: nil, **opts)
|
100
|
+
raise NoMethodError, "The current context does not support rendering. Rendering is only available for controllers and views." unless context.respond_to?(:render, true)
|
101
|
+
raise ArgumentError, "Please provide a single experiment" unless experiments.length == 1
|
102
|
+
choose!(**opts) do |variant, metadata|
|
103
|
+
locals = { variant: variant, metadata: variant.metadata }
|
104
|
+
locals = { locals: locals } if context_type == :controller
|
105
|
+
|
106
|
+
template = templates[variant.name] if templates
|
107
|
+
prefix ||= (context.try(:view_context) || context).lookup_context.prefixes.first + '/'
|
108
|
+
template ||= "#{prefix.to_s}#{variant.experiment.experiment_name.to_s.underscore}/#{variant.name.to_s.underscore}"
|
109
|
+
|
110
|
+
context.send(:render, template.to_s, **locals)
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
def convert!(checkpoint=nil, **opts, &block)
|
115
|
+
checkpoints = experiments.map { |experiment| experiment.convert!(checkpoint, **opts) }
|
116
|
+
return false unless checkpoints.any?
|
117
|
+
if block_given?
|
118
|
+
yield checkpoints, opts[:metadata]
|
119
|
+
else
|
120
|
+
checkpoints
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
def experiments
|
125
|
+
@experiments ||= TrailGuide::Catalog.select(metric).map do |experiment|
|
126
|
+
experiment.new(participant)
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
def experiment
|
131
|
+
@experiment ||= experiments.first
|
132
|
+
end
|
133
|
+
|
134
|
+
def override_variant
|
135
|
+
return unless context.respond_to?(:params, true)
|
136
|
+
params = context.send(:params)
|
137
|
+
return unless params.key?(TrailGuide.configuration.override_parameter)
|
138
|
+
experiment_params = params[TrailGuide.configuration.override_parameter]
|
139
|
+
return unless experiment_params.key?(experiment.experiment_name.to_s)
|
140
|
+
varname = experiment_params[experiment.experiment_name.to_s]
|
141
|
+
variant = experiment.variants.find { |var| var == varname }
|
142
|
+
variant.try(:name)
|
143
|
+
end
|
144
|
+
|
145
|
+
def exclude_visitor?
|
146
|
+
instance_exec(context, &TrailGuide.configuration.request_filter)
|
147
|
+
end
|
148
|
+
|
149
|
+
def is_preview?
|
150
|
+
return false unless context.respond_to?(:request, true)
|
151
|
+
headers = context.send(:request).try(:headers)
|
152
|
+
headers && headers['x-purpose'] == 'preview'
|
153
|
+
end
|
154
|
+
|
155
|
+
def is_filtered_user_agent?
|
156
|
+
return false if TrailGuide.configuration.filtered_user_agents.empty?
|
157
|
+
return false unless context.respond_to?(:request, true)
|
158
|
+
request = context.send(:request)
|
159
|
+
return false unless request && request.user_agent
|
160
|
+
|
161
|
+
TrailGuide.configuration.filtered_user_agents do |ua|
|
162
|
+
return true if ua.class == String && request.user_agent == ua
|
163
|
+
return true if ua.class == Regexp && request.user_agent =~ ua
|
164
|
+
end
|
165
|
+
|
166
|
+
return false
|
167
|
+
end
|
168
|
+
|
169
|
+
def is_filtered_ip_address?
|
170
|
+
return false if TrailGuide.configuration.filtered_ip_addresses.empty?
|
171
|
+
return false unless context.respond_to?(:request, true)
|
172
|
+
request = context.send(:request)
|
173
|
+
return false unless request && request.ip
|
174
|
+
|
175
|
+
TrailGuide.configuration.filtered_ip_addresses.each do |ip|
|
176
|
+
return true if ip.class == String && request.ip == ip
|
177
|
+
return true if ip.class == Regexp && request.ip =~ ip
|
178
|
+
return true if ip.class == Range && ip.first.class == IPAddr && ip.include?(IPAddr.new(request.ip))
|
179
|
+
end
|
180
|
+
|
181
|
+
return false
|
182
|
+
end
|
183
|
+
end
|
184
|
+
end
|
185
|
+
end
|