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.
@@ -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