forked from assaf/vanity
-
Notifications
You must be signed in to change notification settings - Fork 0
/
playground.rb
401 lines (354 loc) · 13.1 KB
/
playground.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
require "uri"
module Vanity
# Playground catalogs all your experiments, holds the Vanity configuration.
#
# @example
# Vanity.playground.logger = my_logger
# puts Vanity.playground.map(&:name)
class Playground
DEFAULTS = { :collecting => true, :load_path=>"experiments" }
DEFAULT_ADD_PARTICIPANT_PATH = '/vanity/add_participant'
# Created new Playground. Unless you need to, use the global
# Vanity.playground.
#
# First argument is connection specification (see #redis=), last argument is
# a set of options, both are optional. Supported options are:
# - connection -- Connection specification
# - namespace -- Namespace to use
# - load_path -- Path to load experiments/metrics from
# - logger -- Logger to use
def initialize(*args)
options = Hash === args.last ? args.pop : {}
# In the case of Rails, use the Rails logger and collect only for
# production environment by default.
defaults = options[:rails] ? DEFAULTS.merge(:collecting => ::Rails.env.production?, :logger => ::Rails.logger) : DEFAULTS
if config_file_exists?
env = ENV["RACK_ENV"] || ENV["RAILS_ENV"] || "development"
config = load_config_file[env]
if Hash === config
config = config.inject({}) { |h,kv| h[kv.first.to_sym] = kv.last ; h }
else
config = { :connection=>config }
end
else
config = {}
end
@options = defaults.merge(config).merge(options)
if @options[:host] == 'redis' && @options.values_at(:host, :port, :db).any?
warn "Deprecated: please specify Redis connection as URL (\"redis://host:port/db\")"
establish_connection :adapter=>"redis", :host=>@options[:host], :port=>@options[:port], :database=>@options[:db] || @options[:database]
elsif @options[:redis]
@adapter = RedisAdapter.new(:redis=>@options[:redis])
else
connection_spec = args.shift || @options[:connection]
if connection_spec
connection_spec = "redis://" + connection_spec unless connection_spec[/^\w+:/]
establish_connection connection_spec
end
end
warn "Deprecated: namespace option no longer supported directly" if @options[:namespace]
@load_path = @options[:load_path] || DEFAULTS[:load_path]
unless @logger = @options[:logger]
@logger = Logger.new(STDOUT)
@logger.level = Logger::ERROR
end
@loading = []
@use_js = false
self.add_participant_path = DEFAULT_ADD_PARTICIPANT_PATH
@collecting = !!@options[:collecting]
end
# Deprecated. Use redis.server instead.
attr_accessor :host, :port, :db, :password, :namespace
# Path to load experiment files from.
attr_accessor :load_path
# Logger.
attr_accessor :logger
# Path to the add_participant action, necessary if you have called use_js!
attr_accessor :add_participant_path
# Defines a new experiment. Generally, do not call this directly,
# use one of the definition methods (ab_test, measure, etc).
#
# @see Vanity::Experiment
def define(name, type, options = {}, &block)
warn "Deprecated: if you need this functionality let's make a better API"
id = name.to_s.downcase.gsub(/\W/, "_").to_sym
raise "Experiment #{id} already defined once" if experiments[id]
klass = Experiment.const_get(type.to_s.gsub(/\/(.?)/) { "::#{$1.upcase}" }.gsub(/(?:^|_)(.)/) { $1.upcase })
experiment = klass.new(self, id, name, options)
experiment.instance_eval &block
experiment.save
experiments[id] = experiment
end
# Returns the experiment. You may not have guessed, but this method raises
# an exception if it cannot load the experiment's definition.
#
# @see Vanity::Experiment
def experiment(name)
id = name.to_s.downcase.gsub(/\W/, "_").to_sym
warn "Deprecated: pleae call experiment method with experiment identifier (a Ruby symbol)" unless id == name
experiments[id.to_sym] or raise NameError, "No experiment #{id}"
end
# -- Robot Detection --
# Call to indicate that participants should be added via js
# This helps keep robots from participating in the ab test
# and skewing results.
#
# If you use this, there are two more steps:
# - Set Vanity.playground.add_participant_path = '/path/to/vanity/action',
# this should point to the add_participant path that is added with
# Vanity::Rails::Dashboard, make sure that this action is available
# to all users
# - Add <%= vanity_js %> to any page that needs uses an ab_test. vanity_js
# needs to be included after your call to ab_test so that it knows which
# version of the experiment the participant is a member of. The helper
# will render nothing if the there are no ab_tests running on the current
# page, so adding vanity_js to the bottom of your layouts is a good
# option. Keep in mind that if you call use_js! and don't include
# vanity_js in your view no participants will be recorded.
def use_js!
@use_js = true
end
def using_js?
@use_js
end
# Returns hash of experiments (key is experiment id).
#
# @see Vanity::Experiment
def experiments
unless @experiments
@experiments = {}
@logger.info "Vanity: loading experiments from #{load_path}"
Dir[File.join(load_path, "*.rb")].each do |file|
Experiment::Base.load self, @loading, file
end
end
@experiments
end
# Reloads all metrics and experiments. Rails calls this for each request in
# development mode.
def reload!
@experiments = nil
@metrics = nil
load!
end
# Loads all metrics and experiments. Rails calls this during
# initialization.
def load!
experiments
metrics
end
# Returns a metric (raises NameError if no metric with that identifier).
#
# @see Vanity::Metric
# @since 1.1.0
def metric(id)
metrics[id.to_sym] or raise NameError, "No metric #{id}"
end
# True if collection data (metrics and experiments). You only want to
# collect data in production environment, everywhere else run with
# collection off.
#
# @since 1.4.0
def collecting?
@collecting
end
# Turns data collection on and off.
#
# @since 1.4.0
def collecting=(enabled)
@collecting = !!enabled
end
# Returns hash of metrics (key is metric id).
#
# @see Vanity::Metric
# @since 1.1.0
def metrics
unless @metrics
@metrics = {}
@logger.info "Vanity: loading metrics from #{load_path}/metrics"
Dir[File.join(load_path, "metrics/*.rb")].each do |file|
Metric.load self, @loading, file
end
if config_file_exists? && remote = load_config_file["metrics"]
remote.each do |id, url|
fail "Metric #{id} already defined in playground" if metrics[id.to_sym]
metric = Metric.new(self, id)
metric.remote url
metrics[id.to_sym] = metric
end
end
end
@metrics
end
# Tracks an action associated with a metric.
#
# @example
# Vanity.playground.track! :uploaded_video
#
# @since 1.1.0
def track!(id, count = 1)
metric(id).track! count
end
# Choose a value for any experiment tagged with the specified event;
# filter can be used to specify a white-list for experiments.
def assign_on(event, filter=nil)
experiments.each do |id, obj|
if !filter || filter.include?(id)
if obj.assign_on?(event)
obj.choose
end
end
end
end
# -- Connection management --
# This is the preferred way to programmatically create a new connection (or
# switch to a new connection). If no connection was established, the
# playground will create a new one by calling this method with no arguments.
#
# With no argument, uses the connection specified in config/vanity.yml file
# for the current environment (RACK_ENV, RAILS_ENV or development). If there
# is no config/vanity.yml file, picks the configuration from
# config/redis.yml, or defaults to Redis on localhost, port 6379.
#
# If the argument is a symbol, uses the connection specified in
# config/vanity.yml for that environment. For example:
# Vanity.playground.establish_connection :production
#
# If the argument is a string, it is processed as a URL. For example:
# Vanity.playground.establish_connection "redis://redis.local/5"
#
# Otherwise, the argument is a hash and specifies the adapter name and any
# additional options understood by that adapter (as with config/vanity.yml).
# For example:
# Vanity.playground.establish_connection :adapter=>:redis,
# :host=>"redis.local"
#
# @since 1.4.0
def establish_connection(spec = nil)
disconnect! if @adapter
case spec
when nil
if config_file_exists?
env = ENV["RACK_ENV"] || ENV["RAILS_ENV"] || "development"
spec = load_config_file[env]
fail "No configuration for #{env}" unless spec
establish_connection spec
elsif config_file_exists?("redis.yml")
env = ENV["RACK_ENV"] || ENV["RAILS_ENV"] || "development"
redis = load_config_file("redis.yml")[env]
fail "No configuration for #{env}" unless redis
establish_connection "redis://" + redis
else
establish_connection :adapter=>"redis"
end
when Symbol
spec = load_config_file[spec.to_s]
establish_connection spec
when String
uri = URI.parse(spec)
params = CGI.parse(uri.query) if uri.query
establish_connection :adapter=>uri.scheme, :username=>uri.user, :password=>uri.password,
:host=>uri.host, :port=>uri.port, :path=>uri.path, :params=>params
else
spec = spec.inject({}) { |hash,(k,v)| hash[k.to_sym] = v ; hash }
@adapter = Adapters.establish_connection(spec)
end
end
def config_file_root
(defined?(::Rails) ? ::Rails.root : Pathname.new(".")) + "config"
end
def config_file_exists?(basename = "vanity.yml")
File.exists?(config_file_root + basename)
end
def load_config_file(basename = "vanity.yml")
YAML.load(ERB.new(File.read(config_file_root + basename)).result)
end
# Returns the current connection. Establishes new connection is necessary.
#
# @since 1.4.0
def connection
@adapter || establish_connection
end
# Returns true if connection is open.
#
# @since 1.4.0
def connected?
@adapter && @adapter.active?
end
# Closes the current connection.
#
# @since 1.4.0
def disconnect!
@adapter.disconnect! if @adapter
end
# Closes the current connection and establishes a new one.
#
# @since 1.3.0
def reconnect!
establish_connection
end
# Deprecated. Use Vanity.playground.collecting = true/false instead. Under
# Rails, collecting is true in production environment, false in all other
# environments, which is exactly what you want.
def test!
warn "Deprecated: use collecting = false instead"
self.collecting = false
end
# Deprecated. Use establish_connection or configuration file instead.
def redis=(spec_or_connection)
warn "Deprecated: use establish_connection method instead"
case spec_or_connection
when String
establish_connection "redis://" + spec_or_connection
when ::Redis
@connection = Adapters::RedisAdapter.new(spec_or_connection)
when :mock
establish_connection :adapter=>:mock
else
raise "I don't know what to do with #{spec_or_connection.inspect}"
end
end
def redis
warn "Deprecated: use connection method instead"
connection
end
end
# In the case of Rails, use the Rails logger and collect only for
# production environment by default.
@playground = Playground.new(:rails=>defined?(::Rails))
class << self
# The playground instance.
#
# @see Vanity::Playground
attr_accessor :playground
# Returns the Vanity context. For example, when using Rails this would be
# the current controller, which can be used to get/set the vanity identity.
def context
Thread.current[:vanity_context]
end
# Sets the Vanity context. For example, when using Rails this would be
# set by the set_vanity_context before filter (via Vanity::Rails#use_vanity).
def context=(context)
Thread.current[:vanity_context] = context
end
# Path to template.
def template(name)
path = File.join(File.dirname(__FILE__), "templates/#{name}")
path << ".erb" unless name["."]
path
end
end
end
class Object
# Use this method to access an experiment by name.
#
# @example
# puts experiment(:text_size).alternatives
#
# @see Vanity::Playground#experiment
# @deprecated
def experiment(name)
warn "Deprecated. Please call Vanity.playground.experiment directly."
Vanity.playground.experiment(name)
end
end