trailguide 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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