vanity 1.0.0 → 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (57) hide show
  1. data/CHANGELOG +35 -0
  2. data/README.rdoc +33 -6
  3. data/lib/vanity.rb +13 -7
  4. data/lib/vanity/backport.rb +43 -0
  5. data/lib/vanity/commands/report.rb +13 -3
  6. data/lib/vanity/experiment/ab_test.rb +98 -66
  7. data/lib/vanity/experiment/base.rb +51 -5
  8. data/lib/vanity/metric.rb +213 -0
  9. data/lib/vanity/mock_redis.rb +76 -0
  10. data/lib/vanity/playground.rb +78 -61
  11. data/lib/vanity/rails/dashboard.rb +11 -2
  12. data/lib/vanity/rails/helpers.rb +3 -3
  13. data/lib/vanity/templates/_ab_test.erb +3 -4
  14. data/lib/vanity/templates/_experiment.erb +4 -4
  15. data/lib/vanity/templates/_experiments.erb +2 -2
  16. data/lib/vanity/templates/_metric.erb +9 -0
  17. data/lib/vanity/templates/_metrics.erb +13 -0
  18. data/lib/vanity/templates/_report.erb +14 -3
  19. data/lib/vanity/templates/flot.min.js +1 -0
  20. data/lib/vanity/templates/jquery.min.js +19 -0
  21. data/lib/vanity/templates/vanity.css +16 -4
  22. data/lib/vanity/templates/vanity.js +96 -0
  23. data/test/ab_test_test.rb +159 -96
  24. data/test/experiment_test.rb +99 -18
  25. data/test/experiments/age_and_zipcode.rb +1 -0
  26. data/test/experiments/metrics/cheers.rb +3 -0
  27. data/test/experiments/metrics/signups.rb +2 -0
  28. data/test/experiments/metrics/yawns.rb +3 -0
  29. data/test/experiments/null_abc.rb +1 -0
  30. data/test/metric_test.rb +287 -0
  31. data/test/playground_test.rb +1 -80
  32. data/test/rails_test.rb +9 -6
  33. data/test/test_helper.rb +37 -6
  34. data/vanity.gemspec +1 -1
  35. data/vendor/{redis-0.1 → redis-rb}/LICENSE +0 -0
  36. data/vendor/{redis-0.1 → redis-rb}/README.markdown +0 -0
  37. data/vendor/{redis-0.1 → redis-rb}/Rakefile +0 -0
  38. data/vendor/redis-rb/bench.rb +44 -0
  39. data/vendor/redis-rb/benchmarking/suite.rb +24 -0
  40. data/vendor/redis-rb/benchmarking/worker.rb +71 -0
  41. data/vendor/redis-rb/bin/distredis +33 -0
  42. data/vendor/redis-rb/examples/basic.rb +16 -0
  43. data/vendor/redis-rb/examples/incr-decr.rb +18 -0
  44. data/vendor/redis-rb/examples/list.rb +26 -0
  45. data/vendor/redis-rb/examples/sets.rb +36 -0
  46. data/vendor/{redis-0.1 → redis-rb}/lib/dist_redis.rb +0 -0
  47. data/vendor/{redis-0.1 → redis-rb}/lib/hash_ring.rb +0 -0
  48. data/vendor/{redis-0.1 → redis-rb}/lib/pipeline.rb +0 -2
  49. data/vendor/{redis-0.1 → redis-rb}/lib/redis.rb +25 -7
  50. data/vendor/{redis-0.1 → redis-rb}/lib/redis/raketasks.rb +0 -0
  51. data/vendor/redis-rb/profile.rb +22 -0
  52. data/vendor/redis-rb/redis-rb.gemspec +30 -0
  53. data/vendor/{redis-0.1 → redis-rb}/spec/redis_spec.rb +113 -0
  54. data/vendor/{redis-0.1 → redis-rb}/spec/spec_helper.rb +0 -0
  55. data/vendor/redis-rb/speed.rb +16 -0
  56. data/vendor/{redis-0.1 → redis-rb}/tasks/redis.tasks.rb +5 -1
  57. metadata +37 -14
@@ -1,6 +1,29 @@
1
1
  module Vanity
2
2
  module Experiment
3
3
 
4
+ # These methods are available from experiment definitions (files located in
5
+ # the experiments directory, automatically loaded by Vanity). Use these
6
+ # methods to define you experiments, for example:
7
+ # ab_test "New Banner" do
8
+ # alternatives :red, :green, :blue
9
+ # metrics :signup
10
+ # end
11
+ module Definition
12
+
13
+ # Defines a new experiment, given the experiment's name, type and
14
+ # definition block.
15
+ def define(name, type, options = nil, &block)
16
+ options ||= {}
17
+ @playground.define(name, type, options, &block)
18
+ end
19
+
20
+ def binding(playground)
21
+ @playground = playground
22
+ Kernel.binding
23
+ end
24
+
25
+ end
26
+
4
27
  # Base class that all experiment types are derived from.
5
28
  class Base
6
29
 
@@ -12,6 +35,27 @@ module Vanity
12
35
  name.split("::").last.gsub(/([a-z])([A-Z])/) { "#{$1}_#{$2}" }.gsub(/([A-Z])([A-Z][a-z])/) { "#{$1}_#{$2}" }.downcase
13
36
  end
14
37
 
38
+ # Playground uses this to load experiment definitions.
39
+ def load(playground, stack, path, id)
40
+ fn = File.join(path, "#{id}.rb")
41
+ fail "Circular dependency detected: #{stack.join('=>')}=>#{fn}" if stack.include?(fn)
42
+ source = File.read(fn)
43
+ stack.push fn
44
+ context = Object.new
45
+ context.instance_eval do
46
+ extend Definition
47
+ experiment = eval(source, context.binding(playground), fn)
48
+ fail NameError.new("Expected #{fn} to define experiment #{id}", id) unless experiment.id == id
49
+ experiment
50
+ end
51
+ rescue
52
+ error = NameError.exception($!.message, id)
53
+ error.set_backtrace $!.backtrace
54
+ raise error
55
+ ensure
56
+ stack.pop
57
+ end
58
+
15
59
  end
16
60
 
17
61
  def initialize(playground, id, name, options, &block)
@@ -19,7 +63,7 @@ module Vanity
19
63
  @id, @name = id.to_sym, name
20
64
  @options = options || {}
21
65
  @namespace = "#{@playground.namespace}:#{@id}"
22
- @identify_block = ->(context){ context.vanity_identity }
66
+ @identify_block = lambda { |context| context.vanity_identity }
23
67
  end
24
68
 
25
69
  # Human readable experiment name (first argument you pass when creating a
@@ -110,18 +154,19 @@ module Vanity
110
154
  # Force experiment to complete.
111
155
  def complete!
112
156
  redis.setnx key(:completed_at), Time.now.to_i
113
- # TODO: logging
157
+ @completed_at = redis[key(:completed_at)]
158
+ @playground.logger.info "vanity: completed experiment #{id}"
114
159
  end
115
160
 
116
161
  # Time stamp when experiment was completed.
117
162
  def completed_at
118
- time = redis[key(:completed_at)]
119
- time && Time.at(time.to_i)
163
+ @completed_at ||= redis[key(:completed_at)]
164
+ @completed_at && Time.at(@completed_at.to_i)
120
165
  end
121
166
 
122
167
  # Returns true if experiment active, false if completed.
123
168
  def active?
124
- redis[key(:completed_at)].nil?
169
+ !redis.exists(key(:completed_at))
125
170
  end
126
171
 
127
172
  # -- Store/validate --
@@ -130,6 +175,7 @@ module Vanity
130
175
  def destroy
131
176
  redis.del key(:created_at)
132
177
  redis.del key(:completed_at)
178
+ @created_at = @completed_at = nil
133
179
  end
134
180
 
135
181
  # Called by Playground to save the experiment definition.
@@ -0,0 +1,213 @@
1
+ module Vanity
2
+
3
+ # A metric is an object that implements two methods: +name+ and +values+. It
4
+ # can also respond to addition methods (+track!+, +bounds+, etc), these are
5
+ # optional.
6
+ #
7
+ # This class implements a basic metric that tracks data and stores it in
8
+ # Redis. You can use this as the basis for your metric, or as reference for
9
+ # the methods your metric must and can implement.
10
+ #
11
+ # @since 1.1.0
12
+ class Metric
13
+
14
+ # These methods are available when defining a metric in a file loaded
15
+ # from the +experiments/metrics+ directory.
16
+ #
17
+ # For example:
18
+ # $ cat experiments/metrics/yawn_sec
19
+ # metric "Yawns/sec" do
20
+ # description "Most boring metric ever"
21
+ # end
22
+ module Definition
23
+
24
+ # Defines a new metric, using the class Vanity::Metric.
25
+ def metric(name, &block)
26
+ metric = Metric.new(@playground, name.to_s, name.to_s.downcase.gsub(/\W/, "_"))
27
+ metric.instance_eval &block
28
+ metric
29
+ end
30
+
31
+ def binding(playground)
32
+ @playground = playground
33
+ Kernel.binding
34
+ end
35
+
36
+ end
37
+
38
+ # Startup metrics for pirates. AARRR stands for:
39
+ # * Acquisition
40
+ # * Activation
41
+ # * Retention
42
+ # * Referral
43
+ # * Revenue
44
+ # Read more: http://500hats.typepad.com/500blogs/2007/09/startup-metrics.html
45
+
46
+ class << self
47
+
48
+ # Helper method to return description for a metric.
49
+ #
50
+ # A metric object may have a +description+ method that returns a detailed
51
+ # description. It may also have no description, or no +description+
52
+ # method, in which case return +nil+.
53
+ #
54
+ # @example
55
+ # puts Vanity::Metric.description(metric)
56
+ def description(metric)
57
+ metric.description if metric.respond_to?(:description)
58
+ end
59
+
60
+ # Helper method to return bounds for a metric.
61
+ #
62
+ # A metric object may have a +bounds+ method that returns lower and upper
63
+ # bounds. It may also have no bounds, or no +bounds+ # method, in which
64
+ # case we return +[nil, nil]+.
65
+ #
66
+ # @example
67
+ # upper = Vanity::Metric.bounds(metric).last
68
+ def bounds(metric)
69
+ metric.respond_to?(:bounds) && metric.bounds || [nil, nil]
70
+ end
71
+
72
+ # Returns data set for a given date range. The data set is an array of
73
+ # date, value pairs.
74
+ #
75
+ # First argument is the metric. Second argument is the start date, or
76
+ # number of days to go back in history, defaults to 90 days. Third
77
+ # argument is end date, defaults to today.
78
+ #
79
+ # @example These are all equivalent:
80
+ # Vanity::Metric.data(my_metric)
81
+ # Vanity::Metric.data(my_metric, 90)
82
+ # Vanity::Metric.data(my_metric, Date.today - 90)
83
+ # Vanity::Metric.data(my_metric, Date.today - 90, Date.today)
84
+ def data(metric, *args)
85
+ first = args.shift || 90
86
+ to = args.shift || Date.today
87
+ from = first.respond_to?(:to_date) ? first.to_date : to - first
88
+ (from..to).zip(metric.values(from, to))
89
+ end
90
+
91
+ # Playground uses this to load metric definitions.
92
+ def load(playground, stack, path, id)
93
+ fn = File.join(path, "#{id}.rb")
94
+ fail "Circular dependency detected: #{stack.join('=>')}=>#{fn}" if stack.include?(fn)
95
+ source = File.read(fn)
96
+ stack.push fn
97
+ context = Object.new
98
+ context.instance_eval do
99
+ extend Definition
100
+ metric = eval(source, context.binding(playground), fn)
101
+ fail NameError.new("Expected #{fn} to define metric #{id}", id) unless metric.name.downcase.gsub(/\W+/, '_').to_sym == id
102
+ metric
103
+ end
104
+ rescue
105
+ error = NameError.exception($!.message, id)
106
+ error.set_backtrace $!.backtrace
107
+ raise error
108
+ ensure
109
+ stack.pop
110
+ end
111
+
112
+ end
113
+
114
+
115
+ # Takes playground (need this to access Redis), friendly name and optional
116
+ # id (can infer from name).
117
+ def initialize(playground, name, id = nil)
118
+ id ||= name.to_s.downcase.gsub(/\W+/, '_')
119
+ @playground, @name, @id = playground, name.to_s, id.to_sym
120
+ @hooks = []
121
+ redis.setnx key(:created_at), Time.now.to_i
122
+ @created_at = Time.at(redis[key(:created_at)].to_i)
123
+ end
124
+
125
+
126
+ # -- Tracking --
127
+
128
+ # Called to track an action associated with this metric.
129
+ def track!(count = 1)
130
+ timestamp = Time.now
131
+ if count > 0
132
+ redis.incrby key(timestamp.to_date, "count"), count
133
+ @playground.logger.info "vanity: #{@id} with count #{count}"
134
+ @hooks.each do |hook|
135
+ hook.call @id, timestamp, count
136
+ end
137
+ end
138
+ end
139
+
140
+ # Metric definitions use this to introduce tracking hook. The hook is
141
+ # called with metric identifier, timestamp, count and possibly additional
142
+ # arguments.
143
+ #
144
+ # For example:
145
+ # hook do |metric_id, timestamp, count|
146
+ # syslog.info metric_id
147
+ # end
148
+ def hook(&block)
149
+ @hooks << block
150
+ end
151
+
152
+ # This method returns the acceptable bounds of a metric as an array with
153
+ # two values: low and high. Use nil for unbounded.
154
+ #
155
+ # Alerts are created when metric values exceed their bounds. For example,
156
+ # a metric of user registration can use historical data to calculate
157
+ # expected range of new registration for the next day. If actual metric
158
+ # falls below the expected range, it could indicate registration process is
159
+ # broken. Going above higher bound could trigger opening a Champagne
160
+ # bottle.
161
+ #
162
+ # The default implementation returns +nil+.
163
+ def bounds
164
+ end
165
+
166
+
167
+ # -- Reporting --
168
+
169
+ # Human readable metric name. All metrics must implement this method.
170
+ def name
171
+ @name
172
+ end
173
+
174
+ # Time stamp when metric was created.
175
+ attr_reader :created_at
176
+
177
+ # Human readable description. Use two newlines to break paragraphs.
178
+ attr_accessor :description
179
+
180
+ # Sets or returns description. For example
181
+ # metric "Yawns/sec" do
182
+ # description "Most boring metric ever"
183
+ # end
184
+ #
185
+ # puts "Just defined: " + metric(:boring).description
186
+ def description(text = nil)
187
+ @description = text if text
188
+ @description
189
+ end
190
+
191
+ # Given two arguments, a start date and an end date (inclusive), returns an
192
+ # array of measurements. All metrics must implement this method.
193
+ def values(from, to)
194
+ redis.mget((from.to_date..to.to_date).map { |date| key(date, "count") }).map(&:to_i)
195
+ end
196
+
197
+
198
+ # -- Storage --
199
+
200
+ def destroy!
201
+ redis.del redis.keys(key("*"))
202
+ end
203
+
204
+ def redis
205
+ @playground.redis
206
+ end
207
+
208
+ def key(*args)
209
+ "metrics:#{@id}:#{args.join(':')}"
210
+ end
211
+
212
+ end
213
+ end
@@ -0,0 +1,76 @@
1
+ module Vanity
2
+ # The Redis you should never use in production.
3
+ class MockRedis
4
+ @@hash = {}
5
+
6
+ def initialize(options = {})
7
+ end
8
+
9
+ def [](key)
10
+ @@hash[key]
11
+ end
12
+
13
+ def []=(key, value)
14
+ @@hash[key] = value.to_s
15
+ end
16
+
17
+ def del(*keys)
18
+ keys.flatten.each do |key|
19
+ @@hash.delete key
20
+ end
21
+ end
22
+
23
+ def setnx(key, value)
24
+ @@hash[key] = value.to_s unless @@hash.has_key?(key)
25
+ end
26
+
27
+ def incr(key)
28
+ @@hash[key] = (@@hash[key].to_i + 1).to_s
29
+ end
30
+
31
+ def incrby(key, value)
32
+ @@hash[key] = (@@hash[key].to_i + value).to_s
33
+ end
34
+
35
+ def mget(keys)
36
+ @@hash.values_at(*keys)
37
+ end
38
+
39
+ def exists(key)
40
+ @@hash.has_key?(key)
41
+ end
42
+
43
+ def keys(pattern)
44
+ regexp = Regexp.new(pattern.split("*").map { |r| Regexp.escape(r) }.join(".*"))
45
+ @@hash.keys.select { |key| key =~ regexp }
46
+ end
47
+
48
+ def flushdb
49
+ @@hash.clear
50
+ end
51
+
52
+ def sismember(key, value)
53
+ case set = @@hash[key]
54
+ when nil ; false
55
+ when Set ; set.member?(value)
56
+ else fail "Not a set"
57
+ end
58
+ end
59
+
60
+ def sadd(key, value)
61
+ case set = @@hash[key]
62
+ when nil ; @@hash[key] = Set.new([value])
63
+ when Set ; set.add value
64
+ else fail "Not a set"
65
+ end
66
+ end
67
+
68
+ def scard(key)
69
+ case set = @@hash[key]
70
+ when nil ; 0
71
+ when Set ; set.size
72
+ else fail "Not a set"
73
+ end
74
+ end
75
+ end
76
+ end
@@ -1,35 +1,23 @@
1
1
  module Vanity
2
2
 
3
- # These methods are available from experiment definitions (files located in
4
- # the experiments directory, automatically loaded by Vanity). Use these
5
- # methods to define you experiments, for example:
6
- # ab_test "New Banner" do
7
- # alternatives :red, :green, :blue
8
- # end
9
- module Definition
10
-
11
- protected
12
- # Defines a new experiment, given the experiment's name, type and
13
- # definition block.
14
- def define(name, type, options = nil, &block)
15
- options ||= {}
16
- Vanity.playground.define(name, type, options, &block)
17
- end
18
-
19
- end
20
-
21
3
  # Playground catalogs all your experiments, holds the Vanity configuration.
22
- # For example:
4
+ #
5
+ # @example
23
6
  # Vanity.playground.logger = my_logger
24
7
  # puts Vanity.playground.map(&:name)
25
8
  class Playground
26
9
 
10
+ DEFAULTS = { :host=>"127.0.0.1", :port=>6379, :db=>0, :load_path=>"experiments" }
11
+
27
12
  # Created new Playground. Unless you need to, use the global Vanity.playground.
28
- def initialize
29
- @experiments = {}
30
- @host, @port, @db = "127.0.0.1", 6379, 0
13
+ def initialize(options = {})
14
+ @host, @port, @db, @load_path = DEFAULTS.merge(options).values_at(:host, :port, :db, :load_path)
31
15
  @namespace = "vanity:#{Vanity::Version::MAJOR}"
32
- @load_path = "experiments"
16
+ @logger = options[:logger] || Logger.new(STDOUT)
17
+ @logger.level = Logger::ERROR
18
+ @redis = options[:redis]
19
+ @experiments = {}
20
+ @loading = []
33
21
  end
34
22
 
35
23
  # Redis host name. Default is 127.0.0.1
@@ -56,7 +44,7 @@ module Vanity
56
44
  # Defines a new experiment. Generally, do not call this directly,
57
45
  # use one of the definition methods (ab_test, measure, etc).
58
46
  def define(name, type, options = {}, &block)
59
- id = name.to_s.downcase.gsub(/\W/, "_")
47
+ id = name.to_s.downcase.gsub(/\W/, "_").to_sym
60
48
  raise "Experiment #{id} already defined once" if @experiments[id]
61
49
  klass = Experiment.const_get(type.to_s.gsub(/\/(.?)/) { "::#{$1.upcase}" }.gsub(/(?:^|_)(.)/) { $1.upcase })
62
50
  experiment = klass.new(self, id, name, options)
@@ -65,41 +53,19 @@ module Vanity
65
53
  @experiments[id] = experiment
66
54
  end
67
55
 
68
- # Returns the named experiment. You may not have guessed, but this method
69
- # raises an exception if it cannot load the experiment's definition.
70
- #
71
- # Experiment names are always mapped by downcasing them and replacing
72
- # non-word characters with underscores, so "Green call to action" becomes
73
- # "green_call_to_action". You can also use a symbol if you feel like it.
56
+ # Returns the experiment. You may not have guessed, but this method raises
57
+ # an exception if it cannot load the experiment's definition.
74
58
  def experiment(name)
75
- id = name.to_s.downcase.gsub(/\W/, "_")
76
- unless @experiments.has_key?(id)
77
- @loading ||= []
78
- fail "Circular dependency detected: #{@loading.join('=>')}=>#{id}" if @loading.include?(id)
79
- begin
80
- @loading.push id
81
- source = File.read(File.expand_path("#{id}.rb", load_path))
82
- context = Object.new
83
- context.instance_eval do
84
- extend Definition
85
- eval source
86
- end
87
- rescue
88
- error = LoadError.exception($!.message)
89
- error.set_backtrace $!.backtrace
90
- raise error
91
- ensure
92
- @loading.pop
93
- end
94
- end
95
- @experiments[id] or fail LoadError, "Expected experiments/#{id}.rb to define experiment #{name}"
59
+ id = name.to_s.downcase.gsub(/\W/, "_").to_sym
60
+ warn "Deprecated: pleae call experiment method with experiment identifier (a Ruby symbol)" unless id == name
61
+ @experiments[id] ||= Experiment::Base.load(self, @loading, File.expand_path(load_path), id)
96
62
  end
97
63
 
98
64
  # Returns list of all loaded experiments.
99
65
  def experiments
100
66
  Dir[File.join(load_path, "*.rb")].each do |file|
101
67
  id = File.basename(file).gsub(/.rb$/, "")
102
- experiment id
68
+ experiment id.to_sym
103
69
  end
104
70
  @experiments.values
105
71
  end
@@ -107,25 +73,72 @@ module Vanity
107
73
  # Reloads all experiments.
108
74
  def reload!
109
75
  @experiments.clear
76
+ @metrics = nil
110
77
  end
111
78
 
112
79
  # Use this instance to access the Redis database.
113
80
  def redis
114
- redis = Redis.new(host: self.host, port: self.port, db: self.db,
115
- password: self.password, logger: self.logger)
116
- class << self ; self ; end.send(:define_method, :redis) { redis }
117
- redis
81
+ @redis ||= Redis.new(:host=>self.host, :port=>self.port, :db=>self.db,
82
+ :password=>self.password, :logger=>self.logger)
83
+ class << self ; self ; end.send(:define_method, :redis) { @redis }
84
+ @redis
85
+ end
86
+
87
+ # Switches playground to use MockRedis instead of a live server.
88
+ # Particularly useful for testing, e.g. if you can't access Redis on your CI
89
+ # server. This method has no affect after playground accesses live Redis
90
+ # server.
91
+ #
92
+ # @example Put this in config/environments/test.rb
93
+ # config.after_initialize { Vanity.playground.mock! }
94
+ def mock!
95
+ @redis ||= MockRedis.new
118
96
  end
119
97
 
98
+ # Returns a metric (creating one if doesn't already exist).
99
+ #
100
+ # @since 1.1.0
101
+ def metric(id)
102
+ id = id.to_sym
103
+ metrics[id] ||= Metric.load(self, @loading, File.expand_path("metrics", load_path), id)
104
+ end
105
+
106
+ # Returns hash of metrics (key is metric id).
107
+ #
108
+ # @since 1.1.0
109
+ def metrics
110
+ unless @metrics
111
+ @metrics = {}
112
+ Dir[File.join(load_path, "metrics/*.rb")].each do |file|
113
+ begin
114
+ id = File.basename(file).gsub(/.rb$/, "")
115
+ metric id
116
+ rescue NameError
117
+ @logger.error "Could not load metric #{$!.name}: #{$!}"
118
+ end
119
+ end
120
+ end
121
+ @metrics
122
+ end
123
+
124
+ # Tracks an action associated with a metric.
125
+ #
126
+ # @example
127
+ # Vanity.playground.track! :uploaded_video
128
+ #
129
+ # @since 1.1.0
130
+ def track!(id, count = 1)
131
+ metric(id).track! count
132
+ end
120
133
  end
121
134
 
122
135
  @playground = Playground.new
123
136
  class << self
124
137
 
125
- # Returns the playground instance.
126
- def playground
127
- @playground
128
- end
138
+ # The playground instance.
139
+ #
140
+ # @see Vanity::Playground
141
+ attr_accessor :playground
129
142
 
130
143
  # Returns the Vanity context. For example, when using Rails this would be
131
144
  # the current controller, which can be used to get/set the vanity identity.
@@ -152,8 +165,12 @@ end
152
165
 
153
166
  class Object
154
167
 
155
- # Use this method to access an experiment by name. For example:
168
+ # Use this method to access an experiment by name.
169
+ #
170
+ # @example
156
171
  # puts experiment(:text_size).alternatives
172
+ #
173
+ # @see Vanity::Playground#experiment
157
174
  def experiment(name)
158
175
  Vanity.playground.experiment(name)
159
176
  end