absurdity 0.2.5

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore ADDED
@@ -0,0 +1,6 @@
1
+ *.gem
2
+ .bundle
3
+ Gemfile.lock
4
+ pkg/*
5
+ *.sw?
6
+ dump.rdb
data/.rvmrc ADDED
@@ -0,0 +1 @@
1
+ rvm ruby-1.9.2@absurdity
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source "http://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in absurdity.gemspec
4
+ gemspec
data/README.rdoc ADDED
@@ -0,0 +1,117 @@
1
+ =Absurdity
2
+
3
+ A rails gem for absurdly simple a/b testing
4
+
5
+ http://github.com/xing/absurdity
6
+
7
+ ==Prerequisites
8
+
9
+ Redis (tested with 1.2.6, 2.2.2)
10
+ Rails (tested with 3.0.10, 3.1.0)
11
+
12
+ ==Installation
13
+
14
+ gem "absurdity"
15
+
16
+ ==Usage
17
+
18
+ ===Instantiate a Redis client
19
+
20
+ Put this in an initializer, for expample config/initializers/redis_absurdity.rb:
21
+
22
+ Absurdity.redis = Redis.new(:host => "localhost", :port => 6379)
23
+
24
+ Note: More often you will use a redis.conf to set up your redis client, the important part is to assign your
25
+ redis client instance to Absurdity.redis .
26
+ Don't forget to start your Redis server.
27
+
28
+ ===Define Experiments
29
+
30
+ mkdir "absurdity" in your rails app root directory
31
+ touch "absurdity/experiments.yml"
32
+
33
+ define your experiments in this file:
34
+ :experiments:
35
+ :wild_side:
36
+ :metrics:
37
+ - :fun
38
+ - :hangover
39
+ :variants:
40
+ - :with_music
41
+ - :without_music
42
+ :walk:
43
+ :metrics:
44
+ - :sun
45
+ - :rain
46
+ :variants:
47
+ - :slow
48
+ - :fast
49
+ :completed:
50
+ :fast
51
+
52
+ :wild_side will be the name of your experiment
53
+ [:fun, :hangover] will be the metrics you wish to track
54
+ [:with_music, :without_music] will be the variants you wish to vary
55
+
56
+ the :walk has been marked as completed.
57
+ You should identify which variant to show here (:fast).
58
+ Now everyone will only see the :fast variant.
59
+ And, no one will produce any metric tracking.
60
+
61
+ Note: You can't use the shortform for declaring an array of symbols in ruby 1.9.2 and Psych.
62
+ So instead of using :metrics: [:fun, :hangover] use the longer form shown above.
63
+
64
+ ===Usage in a controller
65
+
66
+ def create
67
+ Absurdity.track! :fun, :wild_side, current_user.id
68
+ end
69
+
70
+ ===Usage in view/helpers
71
+
72
+ def my_helper
73
+ variant = Absurdity.variant :wild_side, current_user.id
74
+ if variant == :with_music
75
+ Play.music
76
+ else
77
+ Play.nothing
78
+ end
79
+ end
80
+
81
+ ===View Metrics
82
+
83
+ /absurdities => linked list of experiments
84
+ /absurdities/experiment_name => show experiment
85
+
86
+ ==Authors
87
+
88
+ {Tim Payton}[http://github.com/dizzy42]
89
+ {Ömür Özkir}[http://github.com/oem]
90
+
91
+ Please find out more about our work in our
92
+ {tech blog}[http://blog.xing.com/category/english/tech-blog].
93
+
94
+
95
+ ==License
96
+
97
+ The MIT License
98
+
99
+ Copyright (c) 2011 {XING AG}[http://www.xing.com/]
100
+
101
+ Permission is hereby granted, free of charge, to any person obtaining a copy
102
+ of this software and associated documentation files (the "Software"), to deal
103
+ in the Software without restriction, including without limitation the rights
104
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
105
+ copies of the Software, and to permit persons to whom the Software is
106
+ furnished to do so, subject to the following conditions:
107
+
108
+ The above copyright notice and this permission notice shall be included in
109
+ all copies or substantial portions of the Software.
110
+
111
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
112
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
113
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
114
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
115
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
116
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
117
+ THE SOFTWARE.
data/Rakefile ADDED
@@ -0,0 +1,15 @@
1
+ require 'bundler'
2
+ Bundler::GemHelper.install_tasks
3
+
4
+ require 'rake/testtask'
5
+
6
+ Rake::TestTask.new do |t|
7
+ t.libs << 'lib'
8
+ t.libs << 'test'
9
+ t.pattern = 'test/**/*_test.rb'
10
+ t.verbose = false
11
+ end
12
+
13
+ desc "Run tests"
14
+ task :default => :test
15
+
data/absurdity.gemspec ADDED
@@ -0,0 +1,28 @@
1
+ # -*- encoding: utf-8 -*-
2
+ $:.push File.expand_path("../lib", __FILE__)
3
+ require "absurdity/version"
4
+
5
+ Gem::Specification.new do |s|
6
+ s.name = "absurdity"
7
+ s.version = Absurdity::VERSION
8
+ s.platform = Gem::Platform::RUBY
9
+ s.authors = ["Tim Payton", "Ömür Özkir"]
10
+ s.email = "opensource@xing.com"
11
+ s.homepage = "http://xing.github.com/absurdity/"
12
+ s.summary = %q{Absurdly simple a/b testing}
13
+ s.description = %q{See summary}
14
+
15
+ s.rubyforge_project = "absurdity"
16
+
17
+ s.add_dependency("redis", ">= 0")
18
+ s.add_dependency("rake", ">= 0.8.7")
19
+
20
+ s.add_development_dependency("mock_redis", ">= 0")
21
+ s.add_development_dependency("mocha", ">= 0")
22
+ s.add_development_dependency("minitest", ">= 0")
23
+
24
+ s.files = `git ls-files`.split("\n")
25
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
26
+ s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
27
+ s.require_paths = ["lib"]
28
+ end
@@ -0,0 +1,18 @@
1
+ class AbsurditiesController < ApplicationController
2
+ helper :absurdity
3
+ layout "absurdities"
4
+
5
+ def index
6
+ @reports = Absurdity::Experiment.reports
7
+ end
8
+
9
+ def show
10
+ @report = Absurdity::Experiment.find(params[:id].to_sym).report
11
+ end
12
+
13
+ def create
14
+ Absurdity.track!(params[:metric], params[:experiment], params[:identity_id])
15
+ render nothing: true
16
+ end
17
+
18
+ end
@@ -0,0 +1,31 @@
1
+ module AbsurdityHelper
2
+
3
+ def metric_info(metrics, metric, count)
4
+ content_tag(:span, "#{metric.to_s.humanize}: ", class: "metric")
5
+ .concat "#{count} "
6
+ # str += metric_ratios(metrics, metric, count)
7
+ end
8
+
9
+ def completed_text(completed)
10
+ if completed == :completed
11
+ content_tag(:span, "Completed")
12
+ elsif completed
13
+ content_tag(:span, "Completed: #{completed}")
14
+ else
15
+ ""
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def metric_ratios(metrics, metric, count)
22
+ ratios = metrics.map do |other_metric, other_count|
23
+ if metric != other_metric && count != 0 && other_count != 0
24
+ str = "#{metric.to_s.humanize}/#{other_metric.to_s.humanize} :"
25
+ str += " #{number_to_percentage((count.to_f/other_count.to_f) * 100, precision: 3)}"
26
+ str
27
+ end
28
+ end.compact
29
+ ratios.present? ? "(" + ratios.join(",") + ")" : ""
30
+ end
31
+ end
@@ -0,0 +1,12 @@
1
+ <header>
2
+ <h1>Experiments</h1>
3
+ </header>
4
+
5
+ <ul class="reports">
6
+ <% @reports.each do |report| %>
7
+ <li>
8
+ <%= link_to report[:name].to_s.humanize, absurdity_path(report[:name]) %>
9
+ <%= completed_text(report[:completed]) %>
10
+ </li>
11
+ <% end %>
12
+ </ul>
@@ -0,0 +1,22 @@
1
+ <header>
2
+ <h1>Experiment</h1>
3
+
4
+ <h2>
5
+ <%= @report[:name].to_s.humanize %>
6
+ </h2>
7
+
8
+ <h3>
9
+ <%= completed_text(@report[:completed]) %>
10
+ </h3>
11
+ </header>
12
+
13
+ <ul>
14
+ <% @report[:data].each do |variant, metrics| %>
15
+ <li class="variant">
16
+ <h3><%= variant.to_s.humanize %></h3>
17
+ <% metrics.each do |metric, count| %>
18
+ <p><%= metric_info(metrics, metric, count) %></p>
19
+ <% end %>
20
+ </li>
21
+ <% end %>
22
+ </ul>
@@ -0,0 +1,17 @@
1
+ <!DOCTYPE html>
2
+ <html lang="<%= I18n.locale %>">
3
+ <head>
4
+ <title>Absurdity - A/B Testing</title>
5
+ <meta charset="utf-8">
6
+ <meta name="viewport" content="width=device-width, minimum-scale=1, maximum-scale=1, user-scalable=0">
7
+ <link rel="apple-touch-icon" href="/images/iphone_favicon.png">
8
+ <link rel="prev" href="/">
9
+
10
+ <link rel="stylesheet" href="/stylesheets/absurdity.css" media="screen">
11
+ </head>
12
+
13
+ <body>
14
+ <%= yield %>
15
+ </body>
16
+
17
+ </html>
data/config/routes.rb ADDED
@@ -0,0 +1,7 @@
1
+ Rails.application.routes.draw do
2
+
3
+ resources :absurdities,
4
+ :format => :html,
5
+ :only => [:index, :show, :create]
6
+
7
+ end
data/lib/absurdity.rb ADDED
@@ -0,0 +1,26 @@
1
+ require 'json'
2
+ require 'absurdity/railtie.rb' if defined?(Rails)
3
+ require 'absurdity/engine' if defined?(Rails)
4
+
5
+ module Absurdity
6
+ class MissingIdentityIDError < RuntimeError; end
7
+
8
+ autoload :Config, "absurdity/config"
9
+ autoload :Experiment, "absurdity/experiment"
10
+ autoload :Metric, "absurdity/metric"
11
+ autoload :Variant, "absurdity/variant"
12
+ autoload :Datastore, "absurdity/datastore"
13
+
14
+ def self.redis=(redis)
15
+ Config.instance.redis = redis
16
+ end
17
+
18
+ def self.track!(metric_slug, experiment_slug, identity_id=nil)
19
+ Experiment.find(experiment_slug).track!(metric_slug, identity_id)
20
+ end
21
+
22
+ def self.variant(experiment_slug, identity_id)
23
+ Experiment.find(experiment_slug).variant_for(identity_id)
24
+ end
25
+
26
+ end
@@ -0,0 +1,24 @@
1
+ require 'singleton'
2
+ require 'logger'
3
+
4
+ module Absurdity
5
+ class Config
6
+ include Singleton
7
+
8
+ def redis
9
+ @redis
10
+ end
11
+
12
+ def redis=(redis)
13
+ @redis = redis
14
+ end
15
+
16
+ def logger
17
+ @logger ||= ::Logger.new(STDOUT)
18
+ end
19
+
20
+ def logger=(logger)
21
+ @logger = logger
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,172 @@
1
+ module Absurdity
2
+ class Datastore
3
+ PARSE_AS_JSON = [
4
+ :metrics_list,
5
+ :variants_list,
6
+ :experiments_list
7
+ ]
8
+
9
+ def self.save(object)
10
+ klass = object.class
11
+ if klass == Absurdity::Experiment
12
+ save_experiment(object)
13
+ elsif klass == Absurdity::Metric
14
+ save_metric(object)
15
+ elsif klass == Absurdity::Variant
16
+ save_variant(object)
17
+ end
18
+ end
19
+
20
+ def self.all(klass)
21
+ if klass == Absurdity::Experiment
22
+ all_experiments
23
+ elsif klass == Absurdity::Metric
24
+ all_metrics
25
+ elsif klass == Absurdity::Variant
26
+ all_variants
27
+ end
28
+ end
29
+
30
+ def self.find(klass, options)
31
+ if klass == Absurdity::Experiment
32
+ find_experiment(options[:slug])
33
+ elsif klass == Absurdity::Metric
34
+ find_metric(options[:slug], options[:experiment_slug], options[:variant_slug])
35
+ elsif klass == Absurdity::Variant
36
+ find_variant(options[:identity_id], options[:experiment_slug])
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def self.redis
43
+ Config.instance.redis
44
+ end
45
+
46
+
47
+ # FINDERS
48
+
49
+ def self.find_experiment(experiment_slug)
50
+ return nil unless slug = experiments_list.find { |e| e == experiment_slug }
51
+ experiment = Experiment.new(slug)
52
+ experiment.attributes[:metrics_list] = get(:metrics_list, experiment: experiment)
53
+ experiment.attributes[:variants_list] = get(:variants_list, experiment: experiment)
54
+ experiment.attributes[:completed] = get(:completed, experiment: experiment)
55
+ experiment
56
+ end
57
+
58
+ def self.find_metric(metric_slug, experiment_slug, variant_slug)
59
+ experiment = find_experiment(experiment_slug)
60
+ return nil unless experiment && experiment.metrics_list.find { |sl| sl == metric_slug }
61
+ metric = Metric.new(metric_slug, experiment_slug, variant_slug)
62
+ metric.instance_variable_set(:@count, metric_count(metric))
63
+ metric
64
+ end
65
+
66
+ def self.find_variant(identity_id, experiment_slug)
67
+ experiment = find_experiment(experiment_slug)
68
+ return nil unless slug = get(variant_key(identity_id), experiment: experiment)
69
+ Variant.new(slug, experiment_slug, identity_id)
70
+ end
71
+
72
+ def self.all_experiments
73
+ experiments_list.map { |exp| find_experiment(exp) }
74
+ end
75
+
76
+
77
+ # SAVERS
78
+
79
+ def self.save_metric(metric)
80
+ set(metric_key(metric), metric.count.to_i, experiment: find_experiment(metric.experiment_slug))
81
+ end
82
+
83
+ def self.save_experiment(experiment)
84
+ unless find_experiment(experiment.slug)
85
+ add_to_experiments_list(experiment.slug)
86
+ create_variants(experiment)
87
+ create_metrics(experiment)
88
+ end
89
+ mark_completed(experiment) if experiment.completed
90
+ end
91
+
92
+ def self.save_variant(variant)
93
+ set(variant_key(variant.identity_id), variant.slug, experiment: find_experiment(variant.experiment_slug))
94
+ end
95
+
96
+ def self.get(key, options={})
97
+ if experiment = options[:experiment]
98
+ store_key = "experiments:#{experiment.slug}:#{key}"
99
+ else
100
+ store_key = "experiments:#{key}"
101
+ end
102
+ string = redis.get(store_key)
103
+ if string.to_i.to_s == string
104
+ string.to_i
105
+ elsif PARSE_AS_JSON.include?(key)
106
+ string && JSON.parse(string).map { |v| v.to_sym }
107
+ else
108
+ string && string.to_sym
109
+ end
110
+ end
111
+
112
+ def self.set(key, value, options={})
113
+ if experiment = options[:experiment]
114
+ store_key = "experiments:#{experiment.slug}:#{key}"
115
+ else
116
+ store_key = "experiments:#{key}"
117
+ end
118
+ redis.set(store_key, value)
119
+ end
120
+
121
+ def self.metric_count(metric)
122
+ experiment = find_experiment(metric.experiment_slug)
123
+ get(metric_key(metric), experiment: experiment).to_i
124
+ end
125
+
126
+ def self.metric_key(metric)
127
+ key = metric.variant_slug ? "#{metric.variant_slug}" : ""
128
+ key += ":#{metric.slug}:count"
129
+ key
130
+ end
131
+
132
+ def self.variant_key(identity_id)
133
+ "identity_id:#{identity_id}:variant"
134
+ end
135
+
136
+ def self.experiments_list
137
+ get(:experiments_list) || []
138
+ end
139
+
140
+ def self.add_to_experiments_list(slug)
141
+ set(:experiments_list, (experiments_list << slug).to_json)
142
+ end
143
+
144
+ def self.create_metrics(experiment)
145
+ set(:metrics_list, experiment.metrics_list.to_json, experiment: experiment)
146
+ experiment.metrics_list.each do |metric_slug|
147
+ if experiment.variants?
148
+ experiment.variants_list.each do |variant_slug|
149
+ Metric.create(metric_slug, experiment.slug, variant_slug)
150
+ end
151
+ else
152
+ Metric.create(metric_slug, experiment.slug)
153
+ end
154
+ end
155
+ end
156
+
157
+ def self.create_variants(experiment)
158
+ if !experiment.variants_list.nil?
159
+ set(:variants_list, experiment.variants_list.to_json, experiment: experiment)
160
+ end
161
+ end
162
+
163
+ def self.mark_completed(experiment)
164
+ set(:completed, experiment.completed, experiment: experiment)
165
+ end
166
+
167
+ def self.logger
168
+ Config.instance.logger
169
+ end
170
+
171
+ end
172
+ end
@@ -0,0 +1,5 @@
1
+ module Absurdity
2
+ class Redis < Datastore
3
+
4
+ end
5
+ end
@@ -0,0 +1,9 @@
1
+ module Absurdity
2
+ class Engine < Rails::Engine
3
+
4
+ initializer "static assets" do |app|
5
+ app.middleware.insert_before ::Rack::Lock, ::ActionDispatch::Static, "#{root}/public"
6
+ end
7
+
8
+ end
9
+ end
@@ -0,0 +1,135 @@
1
+ module Absurdity
2
+ class Experiment
3
+ class NotFoundError < RuntimeError; end
4
+ class FoundError < RuntimeError; end
5
+
6
+ def self.create(slug, metrics_list, variants_list=nil)
7
+ raise FoundError if Datastore.find(self, slug: slug)
8
+ experiment = new(slug, metrics_list: metrics_list, variants_list: variants_list)
9
+ experiment.save
10
+ experiment
11
+ end
12
+
13
+ def self.find(slug)
14
+ raise NotFoundError unless experiment = Datastore.find(self, slug: slug)
15
+ experiment
16
+ end
17
+
18
+ def self.reports
19
+ all.map { |exp| exp.report }
20
+ end
21
+
22
+ def self.all
23
+ Datastore.all_experiments
24
+ end
25
+
26
+ attr_reader :slug, :attributes
27
+ def initialize(slug, attributes = {})
28
+ @slug = slug
29
+ @attributes = attributes
30
+ end
31
+
32
+ def report
33
+ report = { name: slug, completed: completed, data: {} }
34
+ if variants?
35
+ variants_list.each do |variant|
36
+ report[:data][variant] = {}
37
+ metrics_list.each do |metric_slug|
38
+ report[:data][variant][metric_slug] = metric(metric_slug, variant).count
39
+ end
40
+ end
41
+ else
42
+ metrics_list.each do |metric_slug|
43
+ report[:data][metric_slug] = metric(metric_slug).count
44
+ end
45
+ end
46
+ report
47
+ end
48
+
49
+ def save
50
+ Datastore.save_experiment(self)
51
+ end
52
+
53
+ def track!(metric_slug, identity_id=nil)
54
+ raise Absurdity::MissingIdentityIDError if variants? && identity_id.nil?
55
+ variant = variants? ? variant_for(identity_id) : nil
56
+ metric(metric_slug, variant).track! unless completed
57
+ end
58
+
59
+ def complete(variant_slug)
60
+ @attributes[:completed] = variant_slug
61
+ save
62
+ end
63
+
64
+ def completed
65
+ @attributes[:completed]
66
+ end
67
+
68
+ def count(metric_slug)
69
+ if variants?
70
+ count = {}
71
+ variants_list.each do |variant|
72
+ count[variant] = metric(metric_slug, variant).count
73
+ end
74
+ else
75
+ count = metric(metric_slug).count
76
+ end
77
+ count
78
+ end
79
+
80
+ def metric(metric_slug, variant=nil)
81
+ Metric.find(metric_slug, slug, variant)
82
+ end
83
+
84
+ def ==(other_experiment)
85
+ slug == other_experiment.slug &&
86
+ metrics_list == other_experiment.metrics_list &&
87
+ variants_list == other_experiment.variants_list
88
+ end
89
+
90
+ def metrics
91
+ return @metrics unless @metrics.nil?
92
+ @metrics = []
93
+ metrics_list.each do |metric_slug|
94
+ if variants?
95
+ variants_list.each { |variant| @metrics << metric(metric_slug, variant) }
96
+ else
97
+ @metrics << metric(metric_slug)
98
+ end
99
+ end
100
+ @metrics
101
+ end
102
+
103
+ def variant_for(identity_id)
104
+ if completed
105
+ variant = Variant.new(completed, slug, identity_id)
106
+ else
107
+ variant = Variant.find(identity_id, slug)
108
+ if variant.nil?
109
+ variant = Variant.new(random_variant, slug, identity_id)
110
+ variant.save
111
+ end
112
+ end
113
+ variant.slug
114
+ end
115
+
116
+ def metrics_list
117
+ attributes[:metrics_list]
118
+ end
119
+
120
+ def variants_list
121
+ attributes[:variants_list]
122
+ end
123
+
124
+ def variants?
125
+ variants_list
126
+ end
127
+
128
+ private
129
+
130
+ def random_variant
131
+ variants_list.sort_by { rand }[0]
132
+ end
133
+
134
+ end
135
+ end
@@ -0,0 +1,46 @@
1
+ module Absurdity
2
+ class Metric
3
+ class NotFoundError < RuntimeError; end
4
+
5
+ def self.create(slug, experiment_slug, variant_slug=nil)
6
+ metric = new(slug, experiment_slug, variant_slug)
7
+ metric.save
8
+ metric
9
+ end
10
+
11
+ def self.find(slug, experiment_slug, variant_slug=nil)
12
+ raise NotFoundError unless metric = Datastore.find(self,
13
+ slug: slug,
14
+ experiment_slug: experiment_slug,
15
+ variant_slug: variant_slug)
16
+ metric
17
+ end
18
+
19
+ attr_reader :slug, :experiment_slug, :variant_slug
20
+ def initialize(slug, experiment_slug, variant_slug=nil)
21
+ @slug = slug
22
+ @experiment_slug = experiment_slug
23
+ @variant_slug = variant_slug
24
+ end
25
+
26
+ def save
27
+ Datastore.save(self)
28
+ end
29
+
30
+ def track!
31
+ @count += 1
32
+ save
33
+ end
34
+
35
+ def count
36
+ @count ||= 0
37
+ end
38
+
39
+ def ==(other_metric)
40
+ slug == other_metric.slug &&
41
+ experiment_slug == other_metric.experiment_slug &&
42
+ variant_slug == other_metric.variant_slug
43
+ end
44
+
45
+ end
46
+ end
@@ -0,0 +1,32 @@
1
+ module Absurdity
2
+ class Railtie < ::Rails::Railtie
3
+ config.after_initialize do
4
+ Config.instance.logger = ::Rails.logger
5
+ load_experiments
6
+ end
7
+
8
+ def self.load_experiments
9
+ experiments_to_create = YAML.load_file("absurdity/experiments.yml")[:experiments]
10
+ experiments_to_create.each do |experiment_slug, values|
11
+ metrics_list = values[:metrics]
12
+ variants_list = values[:variants]
13
+ experiment = new_experiment(experiment_slug, metrics_list, variants_list)
14
+ complete(experiment_slug, values[:completed]) if values[:completed] && !experiment.completed
15
+ end
16
+ end
17
+
18
+ def self.new_experiment(experiment_slug, metrics_list, variants_list=nil)
19
+ begin
20
+ experiment = Experiment.find(experiment_slug)
21
+ rescue Experiment::NotFoundError
22
+ Experiment.create(experiment_slug, metrics_list, variants_list)
23
+ end
24
+ end
25
+
26
+ def self.complete(experiment_slug, variant_slug)
27
+ experiment = Experiment.find(experiment_slug)
28
+ experiment.complete(variant_slug)
29
+ end
30
+
31
+ end
32
+ end
@@ -0,0 +1,20 @@
1
+ module Absurdity
2
+ class Variant
3
+
4
+ def self.find(identity_id, experiment_slug)
5
+ Datastore.find(self, experiment_slug: experiment_slug, identity_id: identity_id)
6
+ end
7
+
8
+ attr_reader :slug, :experiment_slug, :identity_id
9
+ def initialize(slug, experiment_slug, identity_id)
10
+ @slug = slug
11
+ @experiment_slug = experiment_slug
12
+ @identity_id = identity_id
13
+ end
14
+
15
+ def save
16
+ Datastore.save(self)
17
+ end
18
+
19
+ end
20
+ end
@@ -0,0 +1,3 @@
1
+ module Absurdity
2
+ VERSION = "0.2.5"
3
+ end
@@ -0,0 +1,96 @@
1
+ body {
2
+ background-color: #fff;
3
+ font: 12px Helvetica, Arial, sans-serif;
4
+ margin: 0 auto;
5
+ padding: 12px 0;
6
+ width: 1000px;
7
+ }
8
+
9
+ h1, h2, h3 {
10
+ color: #bbb;
11
+ display: inline;
12
+ font-size: 2em;
13
+ font-weight: normal;
14
+ margin: 0;
15
+ padding: 0;
16
+ text-transform: uppercase;
17
+ white-space: nowrap;
18
+ overflow: hidden;
19
+ text-overflow: ellipsis;
20
+ }
21
+
22
+ h1 {
23
+ color: #333;
24
+ font-weight: bold;
25
+ padding-right: 12px;
26
+ }
27
+
28
+ h2 {
29
+ font-size: 2em;
30
+ font-weight: normal;
31
+ }
32
+
33
+ h3 {
34
+ font-size: 1.8em;
35
+ display: block;
36
+ }
37
+
38
+ ul {
39
+ list-style: none;
40
+ padding: 0;
41
+ }
42
+
43
+ .reports li {
44
+ color: #aaa;
45
+ background-color: #333;
46
+ border-radius: 10px;
47
+ -moz-box-shadow: 0 5px 5px #999;
48
+ -webkit-box-shadow: 0 5px 5px #999;
49
+ box-shadow: 0 5px 5px #999;
50
+ display: inline-block;
51
+ font-weight: bold;
52
+ padding: 10px 10px;
53
+ margin: 10px 10px 10px 0;
54
+ position: relative;
55
+ height: 80px;
56
+ width: 170px;
57
+ }
58
+
59
+ .reports li span {
60
+ font-weight: normal;
61
+ position: absolute;
62
+ bottom: 10px;
63
+ left: 10px;
64
+ width: 160px;
65
+ overflow: hidden;
66
+ text-overflow: ellipsis;
67
+ }
68
+
69
+ .reports li a, .reports li a:visited {
70
+ color: #f8f8f8;
71
+ display: inline-block;
72
+ text-decoration: none;
73
+ position: absolute;
74
+ top: 10px;
75
+ height: 80px;
76
+ width: 170px;
77
+ }
78
+
79
+ li.variant {
80
+ border-left: 3px solid #eee;
81
+ color: #333;
82
+ display: inline-block;
83
+ margin-top: 12px;
84
+ padding-left: 12px;
85
+ width: 25%;
86
+ }
87
+
88
+ li.variant:first-child {
89
+ border-left: none;
90
+ padding: 0 12px 0 0;
91
+ }
92
+
93
+ .metric {
94
+ font-weight: bold;
95
+ }
96
+
@@ -0,0 +1,96 @@
1
+ require 'test_helper'
2
+
3
+ class AbsurdityTest < MiniTest::Unit::TestCase
4
+
5
+ def test_redis_setter_and_getter
6
+ a_redis = MockRedis.new
7
+ Absurdity.redis = a_redis
8
+ assert_equal a_redis, Absurdity.redis
9
+ end
10
+
11
+ def test_track_missing_experiment
12
+ Absurdity.redis = MockRedis.new
13
+
14
+ assert_raises Absurdity::Experiment::NotFoundError do
15
+ Absurdity.track! :clicked, :shared_contacts_link
16
+ end
17
+ end
18
+
19
+ def test_track_missing_metric
20
+ Absurdity.redis = MockRedis.new
21
+ Absurdity::Experiment.create(:shared_contacts_link,
22
+ [:clicked])
23
+
24
+ assert_raises Absurdity::Metric::NotFoundError do
25
+ Absurdity.track! :seen, :shared_contacts_link
26
+ end
27
+ end
28
+
29
+ def test_track_missing_identity_id
30
+ Absurdity.redis = MockRedis.new
31
+ Absurdity::Experiment.create(:shared_contacts_link,
32
+ [:clicked],
33
+ [:with_photos, :without_photos])
34
+
35
+ assert_raises Absurdity::MissingIdentityIDError do
36
+ Absurdity.track! :seen, :shared_contacts_link
37
+ end
38
+ end
39
+
40
+ def test_track_experiment_metric_without_variants
41
+ Absurdity.redis = MockRedis.new
42
+ Absurdity::Experiment.create(:shared_contacts_link,
43
+ [:clicked])
44
+
45
+ Absurdity.track! :clicked, :shared_contacts_link
46
+ assert_equal 1, Absurdity::Experiment.find(:shared_contacts_link).count(:clicked)
47
+ end
48
+
49
+ def test_track_experiment_metric_with_variants
50
+ Absurdity.redis = MockRedis.new
51
+ Absurdity::Experiment.any_instance.expects(:random_variant).returns(:with_photos)
52
+
53
+ Absurdity::Experiment.create(:shared_contacts_link,
54
+ [:clicked],
55
+ [:with_photos, :without_photos])
56
+
57
+ Absurdity.track! :clicked, :shared_contacts_link, 1
58
+ count = Absurdity::Experiment.find(:shared_contacts_link).count(:clicked)
59
+
60
+ assert_equal 1, count[:with_photos]
61
+ assert_equal 0, count[:without_photos]
62
+ end
63
+
64
+ def test_track_experiment_with_multiple_metrics_with_variants
65
+ Absurdity.redis = MockRedis.new
66
+ Absurdity::Experiment.any_instance.expects(:random_variant).returns(:with_photos)
67
+
68
+ Absurdity::Experiment.create(:shared_contacts_link,
69
+ [:clicked, :seen],
70
+ [:with_photos, :without_photos])
71
+
72
+ Absurdity.track! :clicked, :shared_contacts_link, 1
73
+ Absurdity.track! :seen, :shared_contacts_link, 1
74
+ count = Absurdity::Experiment.find(:shared_contacts_link).count(:clicked)
75
+
76
+ assert_equal 1, count[:with_photos]
77
+ assert_equal 0, count[:without_photos]
78
+
79
+ count = Absurdity::Experiment.find(:shared_contacts_link).count(:seen)
80
+ assert_equal 1, count[:with_photos]
81
+ assert_equal 0, count[:without_photos]
82
+ end
83
+
84
+ def test_variant
85
+ Absurdity.redis = MockRedis.new
86
+ Absurdity::Experiment.any_instance.expects(:random_variant).returns(:with_photos)
87
+
88
+ Absurdity::Experiment.create(:shared_contacts_link,
89
+ [:clicked, :seen],
90
+ [:with_photos, :without_photos])
91
+
92
+ assert_equal :with_photos, Absurdity.variant(:shared_contacts_link, 1)
93
+ end
94
+
95
+ end
96
+
@@ -0,0 +1,13 @@
1
+ require 'test_helper'
2
+
3
+ class AbsurdityTest < MiniTest::Unit::TestCase
4
+
5
+ def test_redis_setter_and_getter
6
+ a_redis = MockRedis.new
7
+ Absurdity::Config.instance.redis = a_redis
8
+
9
+ assert_equal a_redis, Absurdity::Config.instance.redis
10
+ end
11
+
12
+ end
13
+
@@ -0,0 +1,36 @@
1
+ require 'test_helper'
2
+
3
+ class ExperimentTest < MiniTest::Unit::TestCase
4
+
5
+ def setup
6
+ @experiment_slug = :shared_contacts_link
7
+ @metrics_list = [:clicked, :seen]
8
+ @variants_list = [:with_photos, :without_photos]
9
+ Absurdity.redis = MockRedis.new
10
+ end
11
+
12
+ def test_find_and_test_create
13
+ experiment = Absurdity::Experiment.create(@experiment_slug,
14
+ @metrics_list,
15
+ @variants_list)
16
+
17
+ assert_equal experiment, Absurdity::Experiment.find(@experiment_slug)
18
+ end
19
+
20
+ def test_initialize
21
+ # crappy test
22
+ experiment = Absurdity::Experiment.new(@experiment_slug,
23
+ metrics_list: @metrics_list,
24
+ variants_list: @variants_list)
25
+ end
26
+
27
+ def test_save
28
+
29
+ end
30
+
31
+ def test_metric
32
+
33
+ end
34
+
35
+ end
36
+
@@ -0,0 +1,83 @@
1
+ require 'test_helper'
2
+
3
+ class MetricTest < MiniTest::Unit::TestCase
4
+
5
+ def setup
6
+ Absurdity.redis = MockRedis.new
7
+ end
8
+
9
+ def test_find_and_create
10
+ experiment_slug = :shared_contacts_link
11
+ metric_slug = :clicked
12
+ experiment = stub(slug: experiment_slug, metrics_list: [metric_slug])
13
+ Absurdity::Datastore.stubs(:find_experiment).returns(experiment)
14
+
15
+ metric = Absurdity::Metric.create(metric_slug, experiment_slug)
16
+
17
+ assert_equal metric, Absurdity::Metric.find(metric_slug, experiment_slug)
18
+ end
19
+
20
+ def test_find_not_found
21
+ assert_raises Absurdity::Metric::NotFoundError do
22
+ Absurdity::Metric.find(:blah, :boo)
23
+ end
24
+ end
25
+
26
+ def test_save_first_time
27
+ experiment_slug = :shared_contacts_link
28
+ metric_slug = :clicked
29
+ experiment = stub(slug: experiment_slug, metrics_list: [metric_slug])
30
+ Absurdity::Datastore.stubs(:find_experiment).returns(experiment)
31
+
32
+ metric = Absurdity::Metric.new(metric_slug, experiment_slug)
33
+
34
+ metric.save
35
+ assert_equal metric, Absurdity::Metric.find(metric_slug, experiment_slug)
36
+ end
37
+
38
+ def test_save_after_first_time
39
+ experiment_slug = :shared_contacts_link
40
+ metric_slug = :clicked
41
+ metric = Absurdity::Metric.create(metric_slug, experiment_slug)
42
+
43
+ assert_equal 0, metric.count
44
+ metric.track!
45
+ assert_equal 1, metric.count
46
+ metric.save
47
+ assert_equal 1, metric.count
48
+ end
49
+
50
+ def test_count
51
+ experiment_slug = :shared_contacts_link
52
+ metric_slug = :clicked
53
+ metric = Absurdity::Metric.create(metric_slug, experiment_slug)
54
+
55
+ assert_equal 0, metric.count
56
+ metric.track!
57
+ assert_equal 1, metric.count
58
+ end
59
+
60
+ def test_track_metric
61
+ experiment_slug = :shared_contacts_link
62
+ metric_slug = :clicked
63
+
64
+ metric = Absurdity::Metric.new(metric_slug, experiment_slug)
65
+
66
+ assert_equal 0, metric.count
67
+ metric.track!
68
+ assert_equal 1, metric.count
69
+ end
70
+
71
+ def test_track_variant_metric
72
+ experiment_slug = :shared_contacts_link
73
+ metric_slug = :clicked
74
+ variant_slug = :with_photos
75
+ metric = Absurdity::Metric.new(metric_slug, experiment_slug, variant_slug)
76
+
77
+ assert_equal 0, metric.count
78
+ metric.track!
79
+ assert_equal 1, metric.count
80
+ end
81
+
82
+ end
83
+
@@ -0,0 +1,15 @@
1
+ require 'minitest/autorun'
2
+ require 'mock_redis'
3
+ require 'absurdity'
4
+ require 'redis'
5
+ require 'mocha'
6
+
7
+ class MiniTest::Unit::TestCase
8
+
9
+ def teardown
10
+ config = Absurdity::Config.instance
11
+ config.redis && config.redis.flushdb
12
+ config.instance_variable_set(:@redis, nil)
13
+ end
14
+
15
+ end
metadata ADDED
@@ -0,0 +1,140 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: absurdity
3
+ version: !ruby/object:Gem::Version
4
+ prerelease:
5
+ version: 0.2.5
6
+ platform: ruby
7
+ authors:
8
+ - Tim Payton
9
+ - "\xC3\x96m\xC3\xBCr \xC3\x96zkir"
10
+ autorequire:
11
+ bindir: bin
12
+ cert_chain: []
13
+
14
+ date: 2011-10-21 00:00:00 Z
15
+ dependencies:
16
+ - !ruby/object:Gem::Dependency
17
+ name: redis
18
+ prerelease: false
19
+ requirement: &id001 !ruby/object:Gem::Requirement
20
+ none: false
21
+ requirements:
22
+ - - ">="
23
+ - !ruby/object:Gem::Version
24
+ version: "0"
25
+ type: :runtime
26
+ version_requirements: *id001
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ prerelease: false
30
+ requirement: &id002 !ruby/object:Gem::Requirement
31
+ none: false
32
+ requirements:
33
+ - - ">="
34
+ - !ruby/object:Gem::Version
35
+ version: 0.8.7
36
+ type: :runtime
37
+ version_requirements: *id002
38
+ - !ruby/object:Gem::Dependency
39
+ name: mock_redis
40
+ prerelease: false
41
+ requirement: &id003 !ruby/object:Gem::Requirement
42
+ none: false
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: "0"
47
+ type: :development
48
+ version_requirements: *id003
49
+ - !ruby/object:Gem::Dependency
50
+ name: mocha
51
+ prerelease: false
52
+ requirement: &id004 !ruby/object:Gem::Requirement
53
+ none: false
54
+ requirements:
55
+ - - ">="
56
+ - !ruby/object:Gem::Version
57
+ version: "0"
58
+ type: :development
59
+ version_requirements: *id004
60
+ - !ruby/object:Gem::Dependency
61
+ name: minitest
62
+ prerelease: false
63
+ requirement: &id005 !ruby/object:Gem::Requirement
64
+ none: false
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: "0"
69
+ type: :development
70
+ version_requirements: *id005
71
+ description: See summary
72
+ email: opensource@xing.com
73
+ executables: []
74
+
75
+ extensions: []
76
+
77
+ extra_rdoc_files: []
78
+
79
+ files:
80
+ - .gitignore
81
+ - .rvmrc
82
+ - Gemfile
83
+ - README.rdoc
84
+ - Rakefile
85
+ - absurdity.gemspec
86
+ - app/controllers/absurdities_controller.rb
87
+ - app/helpers/absurdity_helper.rb
88
+ - app/views/absurdities/index.html.erb
89
+ - app/views/absurdities/show.html.erb
90
+ - app/views/layouts/absurdities.html.erb
91
+ - config/routes.rb
92
+ - lib/absurdity.rb
93
+ - lib/absurdity/config.rb
94
+ - lib/absurdity/datastore.rb
95
+ - lib/absurdity/datastore/redis.rb
96
+ - lib/absurdity/engine.rb
97
+ - lib/absurdity/experiment.rb
98
+ - lib/absurdity/metric.rb
99
+ - lib/absurdity/railtie.rb
100
+ - lib/absurdity/variant.rb
101
+ - lib/absurdity/version.rb
102
+ - public/stylesheets/absurdity.css
103
+ - test/absurdity_test.rb
104
+ - test/config_test.rb
105
+ - test/experiment_test.rb
106
+ - test/metric_test.rb
107
+ - test/test_helper.rb
108
+ homepage: http://xing.github.com/absurdity/
109
+ licenses: []
110
+
111
+ post_install_message:
112
+ rdoc_options: []
113
+
114
+ require_paths:
115
+ - lib
116
+ required_ruby_version: !ruby/object:Gem::Requirement
117
+ none: false
118
+ requirements:
119
+ - - ">="
120
+ - !ruby/object:Gem::Version
121
+ version: "0"
122
+ required_rubygems_version: !ruby/object:Gem::Requirement
123
+ none: false
124
+ requirements:
125
+ - - ">="
126
+ - !ruby/object:Gem::Version
127
+ version: "0"
128
+ requirements: []
129
+
130
+ rubyforge_project: absurdity
131
+ rubygems_version: 1.8.10
132
+ signing_key:
133
+ specification_version: 3
134
+ summary: Absurdly simple a/b testing
135
+ test_files:
136
+ - test/absurdity_test.rb
137
+ - test/config_test.rb
138
+ - test/experiment_test.rb
139
+ - test/metric_test.rb
140
+ - test/test_helper.rb