trailguide 0.1.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 +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
|