experimental 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,15 @@
1
+ ---
2
+ !binary "U0hBMQ==":
3
+ metadata.gz: !binary |-
4
+ Zjc0ZjhjZGU1OTRjYzA0YjY2MjM2NDVhOTQ2NjY1MGJjNjFiZWUyYg==
5
+ data.tar.gz: !binary |-
6
+ N2Q1ZDM5OTk4NWYyMzNmYjY5OWNlOWVjNzFlMTUxYzZjMjRlZTUyNg==
7
+ !binary "U0hBNTEy":
8
+ metadata.gz: !binary |-
9
+ ZGVmMGFjYWNkZWExMGJmNWRiNmVmMjRiZWQzMTUyNGFhZGRmNThiODI2MTg3
10
+ NTQzMjI4NzBmZGJkNmFlNDI5YTM2MThmMmJkY2JiZWVkODBhZTcyZmM0OWVm
11
+ NzQzNGUwOGMwMGVjZjhiM2QzM2NhNmQxYTAyMDJhNTI3NzIyNjE=
12
+ data.tar.gz: !binary |-
13
+ N2Q3ZjM1OWRjN2NiMzA4ZWExYWU5ODY0ZDcxMjA5NTgzOWI4OWE2OWIzNmJk
14
+ MjM1Njk1NTk4Mjc1NWRmNzM4N2YwM2M1YjViZmRlODFlMDIxOWRkM2I1NmU3
15
+ MWNiODNhMmEzOGZhMDBkM2JlMDY1MjRkN2Q5MTNjYWEyZDQ1ZmU=
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2013 This Life, Inc.
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.markdown ADDED
@@ -0,0 +1,337 @@
1
+ # Experimental
2
+ Experimental is an Split testing framework for Rails.
3
+ It was written with a few goals in mind:
4
+ * Split the users in a non-predictable pattern (i.e. half of the users won't always
5
+ be in all experiments)
6
+ * Keep experiments and their start and end dates in the database
7
+ * Have a clear developer workflow, so that tests in the code are
8
+ started in the database when the code goes out and tests that should
9
+ be removed make the site explode
10
+ * Allow admins to end experiments and set a winner
11
+ * Cache the experiments
12
+
13
+ ## Installation
14
+
15
+ ```rails g experimental```
16
+
17
+ ### Routes
18
+
19
+ ````
20
+ resources :experiments, :only => [:index, :new, :create] do
21
+ collection do
22
+ get :inactive
23
+ post :set_winner
24
+ end
25
+ end
26
+ ````
27
+
28
+ ````
29
+ namespace :singles_admin do
30
+ resources :experiments, :only => [:index, :new, :create] do
31
+ collection do
32
+ get :inactive
33
+ post :set_winner
34
+ end
35
+ end
36
+ end
37
+ ````
38
+
39
+ ### Admin Frontend
40
+
41
+ #### Create your own admin controller:
42
+ ````
43
+ class Admin::ExperimentsController < ApplicationController
44
+ include Experimental::ControllerActions
45
+
46
+ alias_method :index, :experiments_index
47
+ alias_method :new, :experiments_new
48
+ alias_method :set_winner, :experiments_set_winner
49
+
50
+ def create
51
+ if experiments_create
52
+ redirect_to admin_experiments_path
53
+ else
54
+ render :new
55
+ end
56
+ end
57
+
58
+ def base_resource_name
59
+ "singles_admin_experiment"
60
+ end
61
+
62
+ end
63
+ ````
64
+
65
+ #### Using ActiveAdmin:
66
+
67
+ ``` rails g active_admin:resource Experiment```
68
+
69
+ ````
70
+ require 'experimental/controller_actions'
71
+
72
+ ActiveAdmin.register Experimental::Experiment, as: "Experiment" do
73
+ actions :index, :new, :create
74
+ filter :name
75
+
76
+ controller do
77
+ class_eval do
78
+ include Experimental::ControllerActions
79
+ end
80
+
81
+ def base_resource_name
82
+ "admin_experiment"
83
+ end
84
+
85
+ def create
86
+ if experiments_create
87
+ redirect_to admin_experiments_path
88
+ else
89
+ render :new
90
+ end
91
+ end
92
+
93
+ def new
94
+ experiments_new
95
+ end
96
+ end
97
+
98
+ # collection_actions force active_admin to create a route
99
+ collection_action :set_winner, method: :post do
100
+ experiments_set_winner
101
+ end
102
+
103
+ # can do this instead of the ended_or_removed scope below
104
+ # you will need to add a link to inactive_admins_experiments_path
105
+ # in your view
106
+ collection_action :inactive do
107
+ experiments_inactive
108
+ render template: 'admin/experiments/index'
109
+ end
110
+
111
+ scope :in_progress, :default => true do |experiments|
112
+ experiments.in_progress
113
+ end
114
+
115
+ scope :ended_or_removed do |experiments|
116
+ @include_inactive = true
117
+ experiments.ended_or_removed
118
+ end
119
+
120
+ index do
121
+ render template: 'admin/experiments/index'
122
+ end
123
+
124
+ form :partial => 'new'
125
+ end
126
+
127
+ ````
128
+
129
+ #### Views
130
+
131
+ create an index and new view in appropriate view folder, i.e.
132
+
133
+ ``` app/views/admin/experiments/index.html.erb ```
134
+ ````
135
+ <%= render partial: 'experimental/links' %>
136
+ <%= render partial: 'experimental/index' %>
137
+ ````
138
+
139
+ ``` app/views/admin/experiments/new.html.erb ```
140
+ ````
141
+ <%= render partial: 'experimental/links' %>
142
+ <%= render partial: 'experimental/new' %>
143
+ ````
144
+
145
+ *Note: ActiveAdmin users will not need to include the links
146
+ partials*
147
+
148
+ ### Subject
149
+
150
+ For the class you'd like to be the subject of experiments, include the
151
+ Experimental::Subject module in a model with an id and timestamps
152
+ ````
153
+ class User < ActiveRecord::Base
154
+ include Experimental::Subject
155
+ ...
156
+ ````
157
+
158
+
159
+ ## Usage
160
+
161
+ ### Create an experiment
162
+
163
+ In `config/experiments.yml`, add the name, num_buckets, and notes of the
164
+ experiment under in_code:
165
+ ````
166
+ in_code:
167
+ -
168
+ name: :price_experiment
169
+ num_buckets: 2
170
+ notes: |
171
+ 0: $22
172
+ 1: $19.99
173
+
174
+ ````
175
+ Then run `rake experiments:sync`
176
+
177
+ ### Using the experiment
178
+
179
+ To see if a user is in the experiment population AND in a bucket:
180
+ ````
181
+ #checks if the user is in the my_experiment population
182
+ # and if they are in bucket 0
183
+ user.in_bucket?(:my_experiment, 0)
184
+ ````
185
+
186
+ To see if a user is in the experiment population **ONLY**
187
+ ````
188
+ user.in_experiment?(:my_experiment)
189
+ ````
190
+
191
+ ### Ending an experiment
192
+
193
+ You can end an experiment by setting the end_date. In the admin
194
+ interface, there is a dropdown to set the end date. When ending an
195
+ experiment *you must set a winning bucket*
196
+
197
+ *Ending an experiment means that all users will be given the winning
198
+ bucket*
199
+
200
+ ### Removing an experiment
201
+
202
+ A removed experiment is an experiment that is not referenced
203
+ anywhere in code. In fact, the framework will throw an exception
204
+ if you reference an experiment that is not in code.
205
+
206
+ Moving an experiment from the in_code to the removed section of
207
+ `config/experiments.yml` and running `rake experiments:sync` will
208
+ remove the experiment and expire the cache.
209
+
210
+ ````
211
+ removed:
212
+ -
213
+ name: :price_experiment
214
+ ````
215
+ Then run `rake experiments:sync`
216
+
217
+
218
+
219
+ ## Testing
220
+
221
+ ### Setup
222
+ in ```spec_helper.rb``` (after inclusion of ActiveSupport)
223
+
224
+ ````
225
+ require 'experimental/rspec_helpers'
226
+ ````
227
+
228
+ *You may want to force experiments off for all tests by default*
229
+ ````
230
+ config.before(:each) do
231
+ User.any_instance.stub(:in_experiment?).and_return(false)
232
+ end
233
+ ````
234
+
235
+ ### Testing experiments
236
+
237
+ Include the Rspec helpers in your spec class or spec_helper
238
+ ````
239
+ include Experimental::RspecHelpers
240
+ ````
241
+
242
+ Shared contexts are available for in_experiment? and in_bucket?
243
+ ````
244
+ include_context "in experiment"
245
+ include_context "not in experiment"
246
+
247
+ include_context "in experiment bucket 0"
248
+ include_context "in experiment bucket 1"
249
+ ```
250
+
251
+ Helper methods are also available:
252
+
253
+ **is_in_experiment**
254
+ ````
255
+ # first param is true for in experiment, false for not in
256
+ experiment
257
+ # second param is the experiment name
258
+ # third param is the subject object
259
+ is_in_experiment(true, :my_experiment, my_subject)
260
+
261
+ # if user and experiment_name are defined, you can do
262
+ let(:experiment_name) { :my_experiment }
263
+ let(:user) { User.new }
264
+ is_in_experiment # true if in experiment
265
+ is_in_experiment(false) # true if NOT in experiment
266
+ ````
267
+
268
+ **is_not_in_experiment**
269
+ ````
270
+ # first param is name of experiment
271
+ # second param is subject object
272
+ is_not_in_experiment(:my_experiment, my_subject)
273
+
274
+ # if user and experiment_name are defined, you can do
275
+ let(:experiment_name) { :my_experiment }
276
+ let(:user) { User.new }
277
+ is_not_in_experiment
278
+ ````
279
+
280
+ **has_experiment_bucket**
281
+ ````
282
+ has_experiment_bucket(1, :my_experiment, my_subject)
283
+
284
+ # if user and experiment_name are defined, you can do
285
+ let(:experiment_name) { :my_experiment }
286
+ let(:user) { User.new }
287
+ has_experiment_bucket(1)
288
+
289
+ ````
290
+
291
+
292
+
293
+ ## Developer Workflow
294
+
295
+ Experiments *can* be defined in config/experiments.yml
296
+ Running the rake task `rake experiments:sync` will load those
297
+ experiments under 'in_code' into the database and set removed_at
298
+ timestamp for those under 'removed'
299
+
300
+ You will likely want to automate the running of `rake
301
+ experiments:sync` by adding to your deploy file.
302
+
303
+ ### Capistrano
304
+ In `config/deploy.rb`:
305
+
306
+ Create a namespace to run the task:
307
+ ````
308
+ namespace :database do
309
+ desc "Sync experiments"
310
+ task :sync_from_app, roles: :db, only: { primary: true } do
311
+ run "cd #{current_path} && RAILS_ENV=#{rails_env} bundle exec rake experiments:sync"
312
+ end
313
+ end
314
+ ````
315
+
316
+ Include that in the deploy:default task:
317
+ ````
318
+ namespace :deploy do
319
+ ...
320
+ task :default do
321
+ begin
322
+ update_code
323
+ migrate
324
+ database.sync_from_app
325
+ restart
326
+ ...
327
+ ````
328
+
329
+ ### Admin created experiments
330
+
331
+ The purpose of Admin created experiments are for experiments
332
+ that will flow through to another system, such as an email provider.
333
+ They likely start with a known string and are dynamically sent in
334
+ code.
335
+ Otherwise, Admin created experiments will do nothing as their is no
336
+ code attached to them.
337
+
data/Rakefile ADDED
@@ -0,0 +1,19 @@
1
+ begin
2
+ require 'bundler/setup'
3
+ rescue LoadError
4
+ puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
5
+ end
6
+
7
+ Bundler::GemHelper.install_tasks
8
+
9
+ require 'rake/testtask'
10
+
11
+ Rake::TestTask.new(:test) do |t|
12
+ t.libs << 'lib'
13
+ t.libs << 'spec'
14
+ t.pattern = 'spec/**/*_spec.rb'
15
+ t.verbose = false
16
+ end
17
+
18
+
19
+ task :default => :test
@@ -0,0 +1,69 @@
1
+ module Experimental
2
+ class Cache
3
+ cattr_accessor :interval
4
+ cattr_reader :last_check
5
+ cattr_reader :last_update
6
+ cattr_reader :experiments
7
+ cattr_accessor :cache_race_condition_ttl
8
+ @@cache_race_condition_ttl = 10
9
+ cattr_accessor :cache_key
10
+
11
+ class << self
12
+ def get(name)
13
+ unless within_interval?
14
+ if need_update?(last_cached_update)
15
+ @@experiments = experiments_to_hash(Experiment.in_code)
16
+ @@last_update = Time.now
17
+ end
18
+ end
19
+ experiments[name.to_sym]
20
+ end
21
+
22
+ def [](name)
23
+ get(name)
24
+ end
25
+
26
+ def interval
27
+ @@interval ||= 5.minutes
28
+ end
29
+
30
+ def last_check
31
+ @@last_check ||=
32
+ Time.now - interval - 1.minute
33
+ end
34
+
35
+ def last_update
36
+ @@last_update ||= last_cached_update
37
+ end
38
+
39
+ def cache_key
40
+ @@cache_key ||= "experiments_last_update"
41
+ end
42
+
43
+ def within_interval?
44
+ (Time.now - last_check) < interval
45
+ end
46
+
47
+ def need_update?(last_cached_update_time)
48
+ experiments.nil? || last_update < last_cached_update_time
49
+ end
50
+
51
+ def experiments_to_hash(experiments)
52
+ HashWithIndifferentAccess.new.tap do |h|
53
+ experiments.each { |e| h[e.name.to_sym] = e }
54
+ end
55
+ end
56
+
57
+ def last_cached_update
58
+ # setting a default to 1 minute ago will force all servers to update
59
+ Rails.cache.fetch(cache_key, :race_condition_ttl => cache_race_condition_ttl ) do
60
+ Experiment.last_updated_at || 1.minute.ago
61
+ end
62
+ end
63
+
64
+ def expire_last_updated
65
+ Rails.cache.delete(cache_key)
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,114 @@
1
+ module Experimental
2
+ class Experiment < ActiveRecord::Base
3
+ extend Population::Filter
4
+
5
+ cattr_accessor :use_cache
6
+ @@use_cache = true
7
+
8
+ attr_accessible :name, :num_buckets, :notes
9
+
10
+ validates_presence_of :name, :num_buckets
11
+ validates_numericality_of :num_buckets, :greater_than_or_equal_to => 2
12
+ validates_numericality_of :winning_bucket,
13
+ :greater_than_or_equal_to => 0,
14
+ :less_than => :num_buckets,
15
+ :if => :ended?
16
+
17
+ after_create :expire_cache
18
+
19
+ def self.in_code
20
+ where(:removed_at => nil)
21
+ end
22
+
23
+ def self.in_progress
24
+ where('removed_at is null and end_date is null').
25
+ order('start_date desc').
26
+ order(:name)
27
+ end
28
+
29
+ def self.ended_or_removed
30
+ where('removed_at is not null or end_date is not null').
31
+ order(:removed_at).
32
+ order('end_date desc')
33
+ end
34
+
35
+ def self.last_updated_at
36
+ maximum(:updated_at)
37
+ end
38
+
39
+ def self.[](experiment_name)
40
+ if use_cache
41
+ Cache.get(experiment_name)
42
+ else
43
+ find_by_name(experiment_name.to_s)
44
+ end
45
+ end
46
+
47
+ def self.expire_cache
48
+ Cache.expire_last_updated if use_cache
49
+ end
50
+
51
+ def expire_cache
52
+ Experiment.expire_cache
53
+ end
54
+
55
+ def bucket(subject)
56
+ (ended? || removed?) ? winning_bucket : bucket_number(subject)
57
+ end
58
+
59
+ def in?(subject)
60
+ return false if removed?
61
+ population_filter.in?(subject, self)
62
+ end
63
+
64
+ def end(winning_num)
65
+ self.winning_bucket = winning_num
66
+ self.end_date = Time.now
67
+ result = save
68
+
69
+ Experiment.expire_cache if result
70
+
71
+ result
72
+ end
73
+
74
+ def remove
75
+ result = false
76
+
77
+ unless removed?
78
+ result = update_attributes(
79
+ { removed_at: Time.now }, without_protection: true
80
+ )
81
+ expire_cache if result
82
+ end
83
+
84
+ result
85
+ end
86
+
87
+ def removed?
88
+ !removed_at.nil?
89
+ end
90
+
91
+ def ended?
92
+ !end_date.nil? && Time.now > end_date
93
+ end
94
+
95
+ def active?
96
+ !removed? && !ended?
97
+ end
98
+
99
+ def to_sql_formula(subject_table = "users")
100
+ "CONV(SUBSTR(SHA1(CONCAT(\"#{name}\",#{subject_table}.id)),1,8),16,10) % #{num_buckets}"
101
+ end
102
+
103
+ def bucket_number(subject)
104
+ top_8 = Digest::SHA1.hexdigest("#{name}#{subject.id}")[0..7]
105
+ top_8.to_i(16) % num_buckets
106
+ end
107
+
108
+ private
109
+
110
+ def population_filter
111
+ @population_filter ||= self.class.find_population(population)
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,64 @@
1
+ module Experimental
2
+ class Loader
3
+ cattr_accessor :file_path
4
+ @@file_path = 'config/experiments.yml'
5
+
6
+ @@whitelisted_attributes = [:name, :num_buckets, :notes, :population]
7
+
8
+ class << self
9
+ def sync(verbose = false)
10
+ puts "Loading experiments ..." if verbose
11
+ experiments = load_experiments
12
+
13
+ #new/active
14
+ experiments.in_code.each do |exp|
15
+ name = exp.with_indifferent_access[:name]
16
+ puts "\tUpdating #{name} ..." if verbose
17
+
18
+ exp = whitelisted_attrs(exp)
19
+ e = Experimental::Experiment.find_by_name(name) || Experimental::Experiment.new
20
+
21
+ puts "\t\tcreating ..." if verbose && e.id.nil?
22
+ puts "\t\tupdating ..." if verbose && !e.id.nil?
23
+
24
+ exp.merge!({ start_date: Time.now }) if e.start_date.nil?
25
+ e.update_attributes!(exp, without_protection: true)
26
+ end unless experiments.in_code.nil?
27
+
28
+ #removed
29
+ experiments.removed.each do |exp|
30
+ name = exp.with_indifferent_access[:name]
31
+ puts "\tRemoving #{name} ..." if verbose
32
+
33
+ exp = whitelisted_attrs(exp)
34
+ e = Experimental::Experiment.find_by_name(name)
35
+
36
+ if e && e.removed_at.nil?
37
+ puts "\t\t#{name} exists, removing ..." if verbose
38
+ result = e.remove
39
+ puts "\t\t\t#{result}" if verbose
40
+ else
41
+ puts "\t\t#{name} doesn't exist!" if verbose
42
+ end
43
+ end unless experiments.removed.nil?
44
+
45
+ puts "Expiring cache ..." if verbose
46
+ Experimental::Experiment.expire_cache
47
+
48
+ puts "Done syncing experiments!" if verbose
49
+ end
50
+
51
+ private
52
+
53
+ def load_experiments
54
+ OpenStruct.new YAML.load_file(File.join(Rails.root, file_path))
55
+ end
56
+
57
+ def whitelisted_attrs(exp)
58
+ exp.
59
+ select { |k, v| @@whitelisted_attributes.include?(k.to_sym) }.
60
+ with_indifferent_access
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,12 @@
1
+ # This class will filter the population of an experiment
2
+ # Override in? to filter the population
3
+ # Called by Experiment.in?
4
+ module Experimental
5
+ module Population
6
+ class Default
7
+ def self.in?(obj, experiment)
8
+ true
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,33 @@
1
+ module Experimental
2
+ module Population
3
+ module Filter
4
+ def self.extended(base)
5
+ base.reset_population_filters
6
+ end
7
+
8
+ def find_population(name)
9
+ if name.blank?
10
+ Experimental::Population::Default
11
+ else
12
+ filter_classes[name.to_s]
13
+ end
14
+ end
15
+
16
+ def register_population_filter(name, filter_class)
17
+ filter_classes[name.to_s] = filter_class
18
+ end
19
+
20
+ def reset_population_filters
21
+ filter_classes.clear
22
+ register_population_filter(:new_users, NewUsers)
23
+ register_population_filter(:default, Default)
24
+ end
25
+
26
+ private
27
+
28
+ def filter_classes
29
+ @filter_classes ||= {}
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,9 @@
1
+ module Experimental
2
+ module Population
3
+ class NewUsers
4
+ def self.in?(subject, experiment)
5
+ subject.created_at >= experiment.start_date
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,19 @@
1
+ module Experimental
2
+ module Subject
3
+ def in_experiment?(name)
4
+ Experiment[name].try { |e| e.in?(self) }
5
+ end
6
+
7
+ def not_in_experiment?(name)
8
+ !in_experiment?(name)
9
+ end
10
+
11
+ def experiment_bucket(name)
12
+ Experiment[name].try { |e| e.bucket(self) }
13
+ end
14
+
15
+ def in_bucket?(name, bucket)
16
+ in_experiment?(name) && experiment_bucket(name) == bucket
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,16 @@
1
+ <tr>
2
+ <td><%= experiment.name.humanize %></td>
3
+ <td><%= "#{experiment.num_buckets} (0-#{experiment.num_buckets-1})" %></td>
4
+ <td><%= experiment.population || "default" %></td>
5
+ <td><%= experiment.notes.try(:gsub, "\n", '<br>').try(:html_safe) %></td>
6
+ <td><%= experiment.start_date.try(:in_time_zone).try(:strftime, "%D %r %Z") %></td>
7
+ <% if @include_inactive %>
8
+ <td><%= experiment.winning_bucket %></td>
9
+ <td><%= experiment.end_date.try(:in_time_zone).try(:strftime, "%D %r %Z") %></td>
10
+ <td><%= experiment.removed_at.try(:in_time_zone).try(:strftime, "%D %r %Z") %></td>
11
+ <% else %>
12
+ <td>
13
+ <%= hidden_field_tag "exp-#{experiment.id}", experiment.id %>
14
+ <%= select_tag "exp-#{experiment.id}-buckets", options_for_select((0..experiment.num_buckets-1).to_a.insert(0, ['-- select --', -1])), class: 'set_winner' %>
15
+ </td>
16
+ <% end %>
@@ -0,0 +1,50 @@
1
+ <h1><%= @h1 %></h1>
2
+
3
+ <table>
4
+ <thead>
5
+ <tr>
6
+ <th scope="col"> Name </th>
7
+ <th scope="col"> Buckets </th>
8
+ <th scope="col"> Population </th>
9
+ <th scope="col"> Notes </th>
10
+ <th scope="col"> Start </th>
11
+ <th scope="col"> Winner </th>
12
+ <% if @include_inactive %>
13
+ <th scope="col"> End </th>
14
+ <th scope="col"> Removed </th>
15
+ <% end %>
16
+ </tr>
17
+ </thead>
18
+ <tbody>
19
+ <%= render partial: 'experimental/experiment', collection: @experiments %>
20
+ </tbody>
21
+ </table>
22
+
23
+ <script type="text/javascript">
24
+ $(function(){
25
+ $(".set_winner").change(function(){
26
+ var bucket_id = $(this).val();
27
+ var msg = "Are you sure you want to end this experiment and set the winning bucket to " + bucket_id;
28
+
29
+ if( bucket_id > -1 && confirm(msg) ){
30
+ var exp_id = $(this).siblings().val();
31
+
32
+ $.ajax({
33
+ type: "POST",
34
+ data: {id:exp_id, bucket_id:bucket_id},
35
+ url: '<%= @experimental_path_names.set_winner %>',
36
+ success: function(resp){
37
+ window.location.reload();
38
+ },
39
+ error: function(){
40
+ alert("WE BLEW UP!");
41
+ $(this).val(-1);
42
+ }
43
+ });
44
+ }
45
+ else{
46
+ $(this).val(-1);
47
+ }
48
+ });
49
+ });
50
+ </script>
@@ -0,0 +1,7 @@
1
+ <div class="experiment_links">
2
+ <%= link_to "In-progress", @experimental_path_names.index %>
3
+ |
4
+ <%= link_to "Ended or Removed", @experimental_path_names.inactive %>
5
+ |
6
+ <%= link_to "Create New Experiment", @experimental_path_names.new %>
7
+ </div>
@@ -0,0 +1,37 @@
1
+ <h1> New Experiment</h1>
2
+ <br />
3
+ <%= form_for(@experiment, url: @experimental_path_names.index) do |f| %>
4
+ <% if @experiment.errors.any? %>
5
+ <div id="error_explanation">
6
+ <h2>
7
+ <%= pluralize(@experiment.errors.count, "error") %>
8
+ probited this experiment from being saved:
9
+ </h2>
10
+ <ul>
11
+ <% @experiment.errors.full_messages.each do |msg| %>
12
+ <li><%= msg %></li>
13
+ <% end %>
14
+ </ul>
15
+ </div>
16
+ <% end %>
17
+
18
+ <p class="field">
19
+ <%= f.label :name %>
20
+ <br />
21
+ <%= f.text_field :name %>
22
+ </p>
23
+ <p class="field">
24
+ <%= f.label :num_buckets %>
25
+ <br />
26
+ <%= f.text_field :num_buckets %>
27
+ </p>
28
+ <p class="field">
29
+ <%= f.label :notes %>
30
+ <br />
31
+ <%= f.text_area :notes, cols: "30", rows: "5" %>
32
+ </p>
33
+
34
+ <p class="actions" >
35
+ <%= f.submit %>
36
+ </p>
37
+ <% end %>
data/config/routes.rb ADDED
@@ -0,0 +1,2 @@
1
+ Rails.application.routes.draw do
2
+ end
@@ -0,0 +1,89 @@
1
+ require 'ostruct'
2
+
3
+ module Experimental
4
+ module ControllerActions
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ class_eval do
9
+ attr_writer :base_resource_name
10
+
11
+ respond_to :json, :only => [:set_winner]
12
+
13
+ before_filter :set_experimental_path_names
14
+ end
15
+ end
16
+
17
+ def base_resource_name
18
+ @base_resource_name ||= "experiment"
19
+ end
20
+
21
+ def set_experimental_path_names
22
+ @experimental_path_names = OpenStruct.new
23
+ plural_path = "#{base_resource_name.pluralize}_path"
24
+
25
+ @experimental_path_names.index = self.send(plural_path.to_sym)
26
+ if self.respond_to?("inactive_#{plural_path}".to_sym)
27
+ @experimental_path_names.inactive = self.send("inactive_#{plural_path}".to_sym)
28
+ end
29
+ @experimental_path_names.new = self.send("new_#{base_resource_name}_path".to_sym)
30
+ @experimental_path_names.set_winner = self.send("set_winner_#{plural_path}".to_sym)
31
+ end
32
+
33
+ def experimental_path_names
34
+ set_experimental_path_names if @experimental_path_names.nil?
35
+ @experimental_path_names
36
+ end
37
+
38
+ def experiments_index
39
+ @h1 = "In-progress Experiments"
40
+ @include_inactive = false
41
+ @experiments = Experiment.in_progress
42
+ end
43
+
44
+ def experiments_new
45
+ @experiment = Experiment.new
46
+ end
47
+
48
+ def experiments_create
49
+ @experiment = Experiment.new(params[:experimental_experiment])
50
+ @experiment.start_date = Time.now
51
+
52
+ if @experiment.save
53
+ flash[:notice] = "Experiment was successfully created."
54
+ return true
55
+ else
56
+ flash.now[:error] = "There was an error!"
57
+ return false
58
+ end
59
+ end
60
+
61
+ def experiments_inactive
62
+ @h1 = "Ended or Removed Experiments"
63
+ @include_inactive = true
64
+ @experiments = Experiment.ended_or_removed
65
+ end
66
+
67
+ def create
68
+ if experiments_create
69
+ redirect_to experimental_path_names.index
70
+ else
71
+ render :new
72
+ end
73
+ end
74
+
75
+ def inactive
76
+ experiments_inactive
77
+ render :index
78
+ end
79
+
80
+ def experiments_set_winner
81
+ exp = Experiment.find params[:id]
82
+ if exp.end(params[:bucket_id])
83
+ render json: nil, status: :ok
84
+ else
85
+ render json: nil, status: :error
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,4 @@
1
+ module Experimental
2
+ class Engine < ::Rails::Engine
3
+ end
4
+ end
@@ -0,0 +1,56 @@
1
+ module Experimental
2
+ module RspecHelpers
3
+ extend ::ActiveSupport::Concern
4
+
5
+ def is_in_experiment(val = true, name = nil, obj = nil)
6
+ obj ||= user
7
+ name ||= experiment_name
8
+
9
+ obj.should_receive(:in_experiment?).any_number_of_times.
10
+ with(name).and_return(val)
11
+ end
12
+
13
+ def is_not_in_experiment(name = nil, obj = nil)
14
+ obj ||= user
15
+ name ||= experiment_name
16
+
17
+ is_in_experiment(false, name, obj)
18
+ end
19
+
20
+ def has_experiment_bucket(bucket, name = nil, obj = nil)
21
+ obj ||= user
22
+ name ||= experiment_name
23
+
24
+ obj.should_receive(:experiment_bucket).any_number_of_times.
25
+ with(name).and_return(bucket)
26
+ end
27
+ end
28
+
29
+ module ClassMethods
30
+ shared_context "in experiment" do
31
+ before do
32
+ is_in_experiment
33
+ end
34
+ end
35
+
36
+ shared_context "not in experiment" do
37
+ before do
38
+ is_not_in_experiment
39
+ end
40
+ end
41
+
42
+ shared_context "in experiment bucket 1" do
43
+ include_context "in experiment"
44
+ before do
45
+ has_experiment_bucket(1)
46
+ end
47
+ end
48
+
49
+ shared_context "in experiment bucket 0" do
50
+ include_context "in experiment"
51
+ before do
52
+ has_experiment_bucket(0)
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,11 @@
1
+ module Experimental
2
+ VERSION = [0, 1, 0]
3
+
4
+ class << VERSION
5
+ include Comparable
6
+
7
+ def to_s
8
+ join('.')
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ require 'rails'
2
+ require 'experimental/engine'
3
+
4
+ module Experimental
5
+ autoload :VERSION, 'experimental/version'
6
+ autoload :ControllerActions, 'experimental/controller_actions'
7
+
8
+ def self.register_population_filter(name, filter_class)
9
+ Experiment.register_population_filter(name, filter_class)
10
+ end
11
+ end
@@ -0,0 +1,29 @@
1
+ class ExperimentalGenerator < Rails::Generators::Base
2
+ include Rails::Generators::Migration
3
+ source_root File.expand_path('../templates', __FILE__)
4
+
5
+ desc "copy experiments yaml file"
6
+ def copy_experiments_yaml
7
+ copy_file "experiments.yml", "config/experiments.yml"
8
+ end
9
+
10
+ desc "copy initializer"
11
+ def copy_experiments_initialize
12
+ copy_file "experimental.rb", "config/initializers/experimental.rb"
13
+ end
14
+
15
+
16
+ desc "add migrations"
17
+ def self.next_migration_number(path)
18
+ if ActiveRecord::Base.timestamped_migrations
19
+ Time.now.utc.strftime("%Y%m%d%H%M%S").to_i
20
+ else
21
+ "%.3d" % (current_migration_number(dirname) + 1)
22
+ end
23
+ end
24
+
25
+ def copy_migrations
26
+ migration_template 'create_experiments_table.rb',
27
+ 'db/migrate/create_experiments_table.rb'
28
+ end
29
+ end
@@ -0,0 +1,24 @@
1
+ class CreateExperimentsTable < ActiveRecord::Migration
2
+ def up
3
+ create_table :experiments do |t|
4
+ t.string :name, null: false, limit: 128
5
+ t.datetime :start_date
6
+ t.datetime :end_date
7
+ t.integer :num_buckets, null: false, default: 1
8
+ t.integer :winning_bucket
9
+ t.text :notes, limit: 256
10
+ t.string :population
11
+ t.datetime :removed_at
12
+ t.timestamps
13
+ end
14
+
15
+ add_index :experiments, :name, :unique => true
16
+ add_index :experiments, :start_date
17
+ add_index :experiments, :population
18
+ add_index :experiments, :updated_at
19
+ end
20
+
21
+ def down
22
+ drop_table :experiments
23
+ end
24
+ end
@@ -0,0 +1,6 @@
1
+ #Load the population classes
2
+ #Rails lazy loads and since they are never explicitly referenced, they don't get loaded
3
+ #Deleting this file causes failures when running individual specs
4
+ Dir[File.join(Rails.root, 'app/models/experimental/population/*.rb')].each do |f|
5
+ require f
6
+ end
@@ -0,0 +1,33 @@
1
+ # run rake experiments:sync to get these into the database
2
+ # sets start_date to Time.now for experiments in_code
3
+ # sets removed_at to Time.now for experiments in removed
4
+
5
+ # these are the experiments with the path still in code
6
+ # note: these could be ended via the Admin
7
+ # Format:
8
+ # -
9
+ # name: #name of the experiment as symbol
10
+ # num_buckets: #number of possible buckets (standard a/b test would be 2)
11
+ # population: #optional
12
+ # #possible values:
13
+ # :default or nil = all users
14
+ # :new_users = users created after start date
15
+ # notes: #optional. free form notes to see in Admin
16
+ # # preface this with | in order to have multi-line text
17
+ # Example
18
+ # -
19
+ # name: :test_experiment
20
+ # num_buckets: 2
21
+ # notes: |
22
+ # 0: sees experiment
23
+ # 1: does not see experiment
24
+ in_code:
25
+
26
+ # copy an experiment to this section when all code paths have been removed
27
+ # removed at date will be set in rake task
28
+ # only in_code experiments are loaded, but just in case ...
29
+ # experiment code throws an exception if you reference a removed experiment
30
+ # Example:
31
+ # -
32
+ # name: :removed_experiment
33
+ removed:
@@ -0,0 +1,6 @@
1
+ namespace :experiments do
2
+ desc "sync experiments from config/experiment.yml into the database"
3
+ task :sync => :environment do
4
+ Experimental::Experiment::Loader.sync(true)
5
+ end
6
+ end
metadata ADDED
@@ -0,0 +1,170 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: experimental
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - HowAboutWe.com
8
+ - Rebecca Miller-Webster
9
+ - Bryan Woods
10
+ autorequire:
11
+ bindir: bin
12
+ cert_chain: []
13
+ date: 2013-05-17 00:00:00.000000000 Z
14
+ dependencies:
15
+ - !ruby/object:Gem::Dependency
16
+ name: rails
17
+ requirement: !ruby/object:Gem::Requirement
18
+ requirements:
19
+ - - ~>
20
+ - !ruby/object:Gem::Version
21
+ version: 3.2.6
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ~>
27
+ - !ruby/object:Gem::Version
28
+ version: 3.2.6
29
+ - !ruby/object:Gem::Dependency
30
+ name: jquery-rails
31
+ requirement: !ruby/object:Gem::Requirement
32
+ requirements:
33
+ - - ! '>='
34
+ - !ruby/object:Gem::Version
35
+ version: '0'
36
+ type: :runtime
37
+ prerelease: false
38
+ version_requirements: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - ! '>='
41
+ - !ruby/object:Gem::Version
42
+ version: '0'
43
+ - !ruby/object:Gem::Dependency
44
+ name: sqlite3
45
+ requirement: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ! '>='
48
+ - !ruby/object:Gem::Version
49
+ version: '0'
50
+ type: :development
51
+ prerelease: false
52
+ version_requirements: !ruby/object:Gem::Requirement
53
+ requirements:
54
+ - - ! '>='
55
+ - !ruby/object:Gem::Version
56
+ version: '0'
57
+ - !ruby/object:Gem::Dependency
58
+ name: rspec-rails
59
+ requirement: !ruby/object:Gem::Requirement
60
+ requirements:
61
+ - - ! '>='
62
+ - !ruby/object:Gem::Version
63
+ version: '0'
64
+ type: :development
65
+ prerelease: false
66
+ version_requirements: !ruby/object:Gem::Requirement
67
+ requirements:
68
+ - - ! '>='
69
+ - !ruby/object:Gem::Version
70
+ version: '0'
71
+ - !ruby/object:Gem::Dependency
72
+ name: activeadmin
73
+ requirement: !ruby/object:Gem::Requirement
74
+ requirements:
75
+ - - ! '>='
76
+ - !ruby/object:Gem::Version
77
+ version: '0'
78
+ type: :development
79
+ prerelease: false
80
+ version_requirements: !ruby/object:Gem::Requirement
81
+ requirements:
82
+ - - ! '>='
83
+ - !ruby/object:Gem::Version
84
+ version: '0'
85
+ - !ruby/object:Gem::Dependency
86
+ name: sass-rails
87
+ requirement: !ruby/object:Gem::Requirement
88
+ requirements:
89
+ - - ! '>='
90
+ - !ruby/object:Gem::Version
91
+ version: '0'
92
+ type: :development
93
+ prerelease: false
94
+ version_requirements: !ruby/object:Gem::Requirement
95
+ requirements:
96
+ - - ! '>='
97
+ - !ruby/object:Gem::Version
98
+ version: '0'
99
+ - !ruby/object:Gem::Dependency
100
+ name: coffee-rails
101
+ requirement: !ruby/object:Gem::Requirement
102
+ requirements:
103
+ - - ! '>='
104
+ - !ruby/object:Gem::Version
105
+ version: '0'
106
+ type: :development
107
+ prerelease: false
108
+ version_requirements: !ruby/object:Gem::Requirement
109
+ requirements:
110
+ - - ! '>='
111
+ - !ruby/object:Gem::Version
112
+ version: '0'
113
+ description: AB Test framework for Rails
114
+ email:
115
+ - dev@howaboutwe.com
116
+ executables: []
117
+ extensions: []
118
+ extra_rdoc_files: []
119
+ files:
120
+ - app/models/experimental/cache.rb
121
+ - app/models/experimental/experiment.rb
122
+ - app/models/experimental/loader.rb
123
+ - app/models/experimental/population/default.rb
124
+ - app/models/experimental/population/filter.rb
125
+ - app/models/experimental/population/new_users.rb
126
+ - app/models/experimental/subject.rb
127
+ - app/views/experimental/_experiment.html.erb
128
+ - app/views/experimental/_index.html.erb
129
+ - app/views/experimental/_links.html.erb
130
+ - app/views/experimental/_new.html.erb
131
+ - config/routes.rb
132
+ - lib/experimental/controller_actions.rb
133
+ - lib/experimental/engine.rb
134
+ - lib/experimental/rspec_helpers.rb
135
+ - lib/experimental/version.rb
136
+ - lib/experimental.rb
137
+ - lib/generators/experimental/experimental_generator.rb
138
+ - lib/generators/experimental/templates/create_experiments_table.rb
139
+ - lib/generators/experimental/templates/experimental.rb
140
+ - lib/generators/experimental/templates/experiments.yml
141
+ - lib/tasks/experiment.rake
142
+ - MIT-LICENSE
143
+ - Rakefile
144
+ - README.markdown
145
+ homepage: http://wwww.github.com/howaboutwe/experimental
146
+ licenses:
147
+ - MIT
148
+ metadata: {}
149
+ post_install_message:
150
+ rdoc_options: []
151
+ require_paths:
152
+ - lib
153
+ required_ruby_version: !ruby/object:Gem::Requirement
154
+ requirements:
155
+ - - ! '>='
156
+ - !ruby/object:Gem::Version
157
+ version: '0'
158
+ required_rubygems_version: !ruby/object:Gem::Requirement
159
+ requirements:
160
+ - - ! '>='
161
+ - !ruby/object:Gem::Version
162
+ version: '0'
163
+ requirements: []
164
+ rubyforge_project:
165
+ rubygems_version: 2.0.3
166
+ signing_key:
167
+ specification_version: 4
168
+ summary: Adds support for database-backed AB tests in Rails apps
169
+ test_files: []
170
+ has_rdoc: