ablab 0.1.1 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 1f0cb7c5be15bedc01af115b00e2204722b62c16
4
- data.tar.gz: d709ec056777764497723f456096350f85ca7260
3
+ metadata.gz: 82b09b75abb045ba604cc9940801370381565a4f
4
+ data.tar.gz: 7687acf2d7bf62c7027eea04b411f143b6651093
5
5
  SHA512:
6
- metadata.gz: 61bd3520e7d2801faec29ca4835d5a6e0ba89d9417b4326c098acf57f68614149780f9957f1fa8de2c500697f6c353c44fdbff4baec6ab300b8b3969ac0284f0
7
- data.tar.gz: 19284473fcd4e90840acae8786207de33869f93fb2155b0ba75ac94b53b897a484f6b9f31340b8e6e23489e04b5a5f482e181b39d2c3e49805bf9db0a82a607d
6
+ metadata.gz: dd0c52bec7918a4f6c783311647fc59e6f92b217c0807474036e97a173679e7d29e2d6b419c3de87c0c61b0809dcb0260500e64200dfc330d23573ec20778ca0
7
+ data.tar.gz: 0ca01138cc6a81182efc7cd0bf862141fbd68baba8e516b10400315f9fe0088fcd54ef3a4d75570d99e1b10d2f3ee132600da5c06caf81d629a921450ba3f557
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2016 Luca Ongaro
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/Rakefile CHANGED
@@ -1,6 +1,24 @@
1
- require "bundler/gem_tasks"
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
+ require 'rdoc/task'
8
+
9
+ RDoc::Task.new(:rdoc) do |rdoc|
10
+ rdoc.rdoc_dir = 'rdoc'
11
+ rdoc.title = 'Ablab'
12
+ rdoc.options << '--line-numbers'
13
+ rdoc.rdoc_files.include('README.rdoc')
14
+ rdoc.rdoc_files.include('lib/**/*.rb')
15
+ end
16
+
17
+ load 'rails/tasks/statistics.rake'
18
+
2
19
  require "rspec/core/rake_task"
3
20
 
4
- RSpec::Core::RakeTask.new(:spec)
21
+ Bundler::GemHelper.install_tasks
5
22
 
6
- task :default => :spec
23
+ RSpec::Core::RakeTask.new(:spec)
24
+ task default: :spec
@@ -0,0 +1,13 @@
1
+ // This is a manifest file that'll be compiled into application.js, which will include all the files
2
+ // listed below.
3
+ //
4
+ // Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts,
5
+ // or any plugin's vendor/assets/javascripts directory can be referenced here using a relative path.
6
+ //
7
+ // It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the
8
+ // compiled file.
9
+ //
10
+ // Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details
11
+ // about supported directives.
12
+ //
13
+ //= require_tree .
@@ -0,0 +1,19 @@
1
+ var Ablab = (function(scripts) {
2
+ var currentScript = scripts[scripts.length - 1];
3
+ return {
4
+ debug: false,
5
+ baseURL: '/ablab',
6
+ trackView: function(experiment) {
7
+ this._track(experiment, 'view');
8
+ },
9
+ trackSuccess: function(experiment) {
10
+ this._track(experiment, 'success');
11
+ },
12
+ _track: function(experiment, evt) {
13
+ var trackerPath = (this.baseURL) + '/track.js';
14
+ var script = document.createElement('script');
15
+ script.setAttribute('src', trackerPath + '?experiment=' + experiment + '&event=' + evt);
16
+ currentScript.parentNode.appendChild(script);
17
+ }
18
+ }
19
+ })(document.getElementsByTagName("script"));
@@ -0,0 +1,99 @@
1
+ /*
2
+ Place all the styles related to the matching controller here.
3
+ They will automatically be included in application.css.
4
+ */
5
+
6
+ * {
7
+ box-sizing: border-box;
8
+ }
9
+
10
+ body {
11
+ font-family: sans-serif;
12
+ background: #f5f5f5;
13
+ color: #555555;
14
+ font-size: 15px;
15
+ padding: 0;
16
+ margin: 0;
17
+ }
18
+
19
+ body > * {
20
+ padding: 1em 1.618em;
21
+ }
22
+
23
+ h1, h2, h3 {
24
+ margin: 0 0 0.618em 0;
25
+ }
26
+
27
+ header {
28
+ background: #777777;
29
+ border-bottom: 2px solid #555555;
30
+ }
31
+
32
+ header h1 {
33
+ color: #c4c4c4;
34
+ text-shadow: 0 -1px 0 #555555;
35
+ margin: 0;
36
+ font-size: 24px;
37
+ }
38
+
39
+ p {
40
+ margin: 0 0 0.618em 0;
41
+ }
42
+
43
+ .experiment {
44
+ padding-top: 1.618em;
45
+ padding-bottom: 2.5em;
46
+ border-bottom: 1px solid #ececec;
47
+ box-shadow: 0 1px 0 #fcfcfc;
48
+ }
49
+
50
+ .experiment:last-child {
51
+ border: none;
52
+ box-shadow: none;
53
+ }
54
+
55
+ table {
56
+ border-collapse: collapse;
57
+ background: #fff4ed;
58
+ }
59
+
60
+ table tr:nth-child(odd) {
61
+ background: #ffe9dc;
62
+ }
63
+
64
+ table tr.control {
65
+ background: #dddddd;
66
+ }
67
+
68
+ table tr.control td {
69
+ border: 1px solid #aaaaaa;
70
+ color: #555555;
71
+ }
72
+
73
+ table tr.winner {
74
+ font-weight: bold;
75
+ background: #C9F9B5;
76
+ }
77
+
78
+ table tr.winner td {
79
+ border: 1px solid #70DC42;
80
+ color: #2a6212;
81
+ }
82
+
83
+ table td, table th {
84
+ color: #652600;
85
+ padding: 0.618em 1em;
86
+ border: 1px solid #ffa26b;
87
+ }
88
+
89
+ table th {
90
+ background: #ffa26b;
91
+ border: none;
92
+ border-bottom: 2px solid #e96e25;
93
+ border-left: 1px dotted #e96e25;
94
+ }
95
+
96
+ table {
97
+ border: 1px solid #ffa26b;
98
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
99
+ }
@@ -0,0 +1,15 @@
1
+ /*
2
+ * This is a manifest file that'll be compiled into application.css, which will include all the files
3
+ * listed below.
4
+ *
5
+ * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
6
+ * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
7
+ *
8
+ * You're free to add application-wide styles to this file and they'll appear at the bottom of the
9
+ * compiled file so the styles you add here take precedence over styles defined in any styles
10
+ * defined in the other CSS/SCSS files in this directory. It is generally better to create a new
11
+ * file per style scope.
12
+ *
13
+ *= require_tree .
14
+ *= require_self
15
+ */
@@ -0,0 +1,7 @@
1
+ require 'ablab/helper'
2
+
3
+ module Ablab
4
+ class ApplicationController < ActionController::Base
5
+ include Ablab::Helper
6
+ end
7
+ end
@@ -0,0 +1,25 @@
1
+ require_dependency "ablab/application_controller"
2
+
3
+ module Ablab
4
+ class BaseController < ApplicationController
5
+ if Ablab.dashboard_credentials
6
+ http_basic_authenticate_with Ablab.dashboard_credentials.merge(only: :dashboard)
7
+ end
8
+
9
+ def track
10
+ exp = experiment(params[:experiment].to_sym)
11
+ if params[:event] == 'success'
12
+ exp.track_success!
13
+ else
14
+ exp.track_view!
15
+ end
16
+ respond_to do |format|
17
+ format.js
18
+ end
19
+ end
20
+
21
+ def dashboard
22
+ @experiments = Ablab.experiments
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,46 @@
1
+ module Ablab
2
+ module AblabHelper
3
+ def format_ratio(numerator, denominator, na = 'n/a')
4
+ return na if denominator == 0
5
+ '%.3f' % (numerator.to_f / denominator)
6
+ end
7
+
8
+ def format_number(number, na = 'n/a')
9
+ return na if number.nil? || number.try(:nan?)
10
+ number_with_delimiter(number, separator: '.', delimiter: ',')
11
+ end
12
+
13
+ def format_decimal(decimal, na = 'n/a')
14
+ return na if decimal.nil? || decimal.nan?
15
+ '%.3f' % decimal
16
+ end
17
+
18
+ def significant?(z_score)
19
+ z_score && z_score >= 1.65
20
+ end
21
+
22
+ def confidence(z_score, na = 'n/a')
23
+ return na if z_score.nil? || z_score.nan?
24
+ if z_score >= 2.58
25
+ '99%'
26
+ elsif z_score >= 1.96
27
+ '95%'
28
+ elsif z_score >= 1.65
29
+ '90%'
30
+ else
31
+ 'insufficient'
32
+ end
33
+ end
34
+
35
+ def winner?(experiment, group_name)
36
+ winner_name, winner_results = experiment.results.max_by do |(_, r)|
37
+ if r[:sessions] > 0
38
+ r[:conversions].to_f / r[:sessions]
39
+ else
40
+ -1
41
+ end
42
+ end
43
+ significant?(winner_results[:z_score]) && winner_name == group_name
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,4 @@
1
+ module Ablab
2
+ module ApplicationHelper
3
+ end
4
+ end
@@ -0,0 +1,39 @@
1
+ <header>
2
+ <h1>A/B Tests Dashboard</h1>
3
+ </header>
4
+
5
+ <% @experiments.each do |name, experiment| %>
6
+ <div class="experiment">
7
+ <h2><%= experiment.name.to_s.titleize %></h2>
8
+ <p><%= experiment.description %></p>
9
+ <% if experiment.goal %>
10
+ <p><b>Goal:</b> <%= experiment.goal %></p>
11
+ <% end %>
12
+ <table>
13
+ <tr>
14
+ <th>Group</th>
15
+ <th>Description</th>
16
+ <th>Views</th>
17
+ <th>Sessions</th>
18
+ <th>Successes</th>
19
+ <th>Conversions</th>
20
+ <th>Conversion rate</th>
21
+ <th>Z-score</th>
22
+ <th>Confidence</th>
23
+ </tr>
24
+ <% experiment.results.each do |group_name, results| %>
25
+ <tr class="<%= 'winner' if winner?(experiment, group_name) %> <%= 'control' if results[:control] %>">
26
+ <td title="<%= results[:description] %>"><b><%= group_name.to_s.titleize %></b></td>
27
+ <td><%= results[:description] %></td>
28
+ <td><%= format_number results[:views] %></td>
29
+ <td><%= format_number results[:sessions] %></td>
30
+ <td><%= format_number results[:successes] %></td>
31
+ <td><%= format_number results[:conversions] %></td>
32
+ <td><%= format_ratio results[:conversions].to_f, results[:sessions] %></td>
33
+ <td><%= format_decimal results[:z_score] %></td>
34
+ <td><%= confidence results[:z_score] %></td>
35
+ </tr>
36
+ <% end %>
37
+ </table>
38
+ </div>
39
+ <% end %>
@@ -0,0 +1,3 @@
1
+ if (console && console.log && Ablab && Ablab.debug) {
2
+ console.log('Ablab: tracked <%= params[:event] %> on <%= params[:experiment] %>');
3
+ }
@@ -0,0 +1,14 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Ablab</title>
5
+ <%= stylesheet_link_tag "ablab/application", media: "all" %>
6
+ <%= javascript_include_tag "ablab/application" %>
7
+ <%= csrf_meta_tags %>
8
+ </head>
9
+ <body>
10
+
11
+ <%= yield %>
12
+
13
+ </body>
14
+ </html>
data/config/routes.rb ADDED
@@ -0,0 +1,5 @@
1
+ Ablab::Engine.routes.draw do
2
+ get '/track', to: 'base#track'
3
+
4
+ root to: 'base#dashboard'
5
+ end
data/lib/ablab.rb CHANGED
@@ -1,8 +1,10 @@
1
1
  require "ablab/version"
2
- require "ablab/controller"
2
+ require "ablab/helper"
3
3
  require "ablab/store"
4
+ require "ablab/engine"
5
+ require "forwardable"
4
6
 
5
- module ABLab
7
+ module Ablab
6
8
  module ModuleMethods
7
9
  attr_reader :experiments
8
10
 
@@ -20,23 +22,36 @@ module ABLab
20
22
  @tracker = Class.new(*args)
21
23
  else
22
24
  class_name = type.to_s.split('_').map(&:capitalize).join
23
- @tracker = ABLab::Store.const_get(class_name).new(*args)
25
+ @tracker = Ablab::Store.const_get(class_name).new(*args)
24
26
  end
25
27
  end
26
28
 
27
29
  def tracker
28
- @tracker ||= ABLab::Store::Memory.new
30
+ @tracker ||= Ablab::Store::Memory.new
31
+ end
32
+
33
+ def dashboard_credentials(credentials = nil)
34
+ if credentials
35
+ unless credentials[:name] && credentials[:password]
36
+ raise InvalidCredentials, 'credentials should provide name and password'
37
+ end
38
+ @dashboard_credentials = credentials
39
+ end
40
+ @dashboard_credentials
29
41
  end
30
42
  end
31
43
 
44
+ class InvalidCredentials < ArgumentError; end
45
+
32
46
  extend ModuleMethods
33
47
 
34
48
  class Experiment
35
49
  attr_reader :name, :groups, :control
36
50
 
37
51
  def initialize(name, &block)
38
- @name = name
39
- @groups = []
52
+ @name = name.to_sym
53
+ @control = Group.new(:control, 'control group')
54
+ @groups = [@control]
40
55
  instance_exec(&block)
41
56
  end
42
57
 
@@ -45,9 +60,13 @@ module ABLab
45
60
  @description
46
61
  end
47
62
 
63
+ def goal(goal = nil)
64
+ @goal = goal if goal
65
+ @goal
66
+ end
67
+
48
68
  def group(name, options = {})
49
69
  group = Group.new(name, options[:description])
50
- @control = group if options[:control]
51
70
  @groups << group
52
71
  end
53
72
 
@@ -56,19 +75,17 @@ module ABLab
56
75
  @result.data
57
76
  end
58
77
 
59
- def run(uid)
60
- draw = Random.new(uid.hash * name.hash).rand(1000)
61
- Run.new(self, draw)
78
+ def run(session_id)
79
+ Run.new(self, session_id)
62
80
  end
63
81
  end
64
82
 
65
83
  class Run
66
- attr_reader :group, :experiment
84
+ attr_reader :experiment, :session_id
67
85
 
68
- def initialize(experiment, draw)
69
- idx = (draw / (1000.0 / experiment.groups.size)).floor
86
+ def initialize(experiment, session_id)
87
+ @experiment, @session_id = experiment, session_id
70
88
  @experiment = experiment
71
- @group = experiment.groups[idx].name
72
89
  end
73
90
 
74
91
  def in_group?(name)
@@ -76,15 +93,30 @@ module ABLab
76
93
  end
77
94
 
78
95
  def track_view!
79
- ABLab.tracker.track_view!(experiment.name, group)
96
+ Ablab.tracker.track_view!(experiment.name, group, session_id)
80
97
  end
81
98
 
82
- def track_conversion!
83
- ABLab.tracker.track_conversion!(experiment.name, group)
99
+ def track_success!
100
+ Ablab.tracker.track_success!(experiment.name, group, session_id)
101
+ end
102
+
103
+ def group
104
+ return @group unless @group.nil?
105
+ idx = (draw / (1000.0 / experiment.groups.size)).floor
106
+ @group = experiment.groups[idx].name
107
+ end
108
+
109
+ def draw
110
+ Random.new(session_id.hash ^ experiment.name.hash).rand(1000)
84
111
  end
85
112
  end
86
113
 
87
- class Group < Struct.new(:name, :description); end
114
+ class Group
115
+ attr_reader :name, :description
116
+ def initialize(name, description = nil)
117
+ @name, @description = name.to_sym, description
118
+ end
119
+ end
88
120
 
89
121
  class Result
90
122
  extend Forwardable
@@ -95,30 +127,27 @@ module ABLab
95
127
  end
96
128
 
97
129
  def data
98
- raise NoControlGroup.new("no control group") if control.nil?
99
- c_views, c_conv = views_and_conversions(control)
130
+ counts_c = counts(control)
100
131
  groups.map do |group|
101
132
  if group == control
102
- next { views: c_views, conversions: c_conv, control: true }
133
+ next [group.name, counts_c.merge(control: true, description: group.description)]
103
134
  end
104
- views, conv = views_and_conversions(group)
105
- z = z_score(views, conv, c_views, c_conv)
106
- { views: views, conversions: conv, z_score: z, control: false }
107
- end
135
+ counts = counts(group)
136
+ z = z_score(counts[:sessions], counts[:conversions],
137
+ counts_c[:sessions], counts_c[:conversions])
138
+ [group.name, counts.merge(z_score: z, control: false, description: group.description)]
139
+ end.to_h
108
140
  end
109
141
 
110
- private def views_and_conversions(group)
111
- views = ABLab.tracker.views(name, group.name)
112
- conversions = ABLab.tracker.conversions(name, group.name)
113
- [views, conversions]
142
+ private def counts(group)
143
+ Ablab.tracker.counts(name, group.name)
114
144
  end
115
145
 
116
- private def z_score(views, conv, c_views, c_conv)
117
- p = conv.to_f / views
118
- pc = c_conv.to_f / c_views
119
- (p - pc) / Math.sqrt((p*(1 - p) / views) + (pc*(1 - pc) / c_views))
146
+ private def z_score(s, c, sc, cc)
147
+ return nil if s == 0 || sc == 0
148
+ p = c.to_f / s
149
+ pc = cc.to_f / sc
150
+ (p - pc) / Math.sqrt((p*(1 - p) / s) + (pc*(1 - pc) / sc))
120
151
  end
121
-
122
- class NoControlGroup < StandardError; end
123
152
  end
124
153
  end