experimental 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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: