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 +4 -4
- data/MIT-LICENSE +20 -0
- data/Rakefile +21 -3
- data/app/assets/javascripts/ablab/application.js +13 -0
- data/app/assets/javascripts/ablab/tracker.js +19 -0
- data/app/assets/stylesheets/ablab/ablab.css +99 -0
- data/app/assets/stylesheets/ablab/application.css +15 -0
- data/app/controllers/ablab/application_controller.rb +7 -0
- data/app/controllers/ablab/base_controller.rb +25 -0
- data/app/helpers/ablab/ablab_helper.rb +46 -0
- data/app/helpers/ablab/application_helper.rb +4 -0
- data/app/views/ablab/base/dashboard.html.erb +39 -0
- data/app/views/ablab/base/track.js.erb +3 -0
- data/app/views/layouts/ablab/application.html.erb +14 -0
- data/config/routes.rb +5 -0
- data/lib/ablab.rb +64 -35
- data/lib/ablab/engine.rb +11 -0
- data/lib/ablab/helper.rb +27 -0
- data/lib/ablab/store/memory.rb +33 -6
- data/lib/ablab/store/redis.rb +37 -11
- data/lib/ablab/version.rb +2 -2
- data/lib/tasks/ablab_tasks.rake +4 -0
- data/spec/ablab/helper_spec.rb +50 -0
- data/spec/ablab/store/memory_spec.rb +7 -0
- data/spec/ablab/store/redis_spec.rb +25 -0
- data/spec/ablab/store/store_examples.rb +74 -0
- data/spec/ablab_spec.rb +160 -0
- data/spec/spec_helper.rb +2 -0
- metadata +62 -28
- data/.gitignore +0 -10
- data/.rspec +0 -2
- data/.travis.yml +0 -4
- data/Gemfile +0 -4
- data/README.md +0 -76
- data/ablab.gemspec +0 -26
- data/bin/console +0 -14
- data/bin/setup +0 -7
- data/lib/ablab/controller.rb +0 -16
data/lib/ablab/engine.rb
ADDED
data/lib/ablab/helper.rb
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
require 'securerandom'
|
2
|
+
|
3
|
+
module Ablab
|
4
|
+
module Helper
|
5
|
+
def self.included(klass)
|
6
|
+
if klass.respond_to? :helper_method
|
7
|
+
self.instance_methods(false).each do |method|
|
8
|
+
klass.send(:helper_method, method)
|
9
|
+
klass.send(:private, method)
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
def experiment(name)
|
15
|
+
@experiments ||= {}
|
16
|
+
unless Ablab.experiments.has_key?(name)
|
17
|
+
raise "No experiment with name #{name}"
|
18
|
+
end
|
19
|
+
@experiments[name] ||=
|
20
|
+
Ablab.experiments[name].run(ablab_session_id)
|
21
|
+
end
|
22
|
+
|
23
|
+
def ablab_session_id
|
24
|
+
cookies[:ablab_sid] || cookies[:ablab_sid] = SecureRandom.hex
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
data/lib/ablab/store/memory.rb
CHANGED
@@ -1,29 +1,56 @@
|
|
1
|
-
|
1
|
+
require 'set'
|
2
|
+
|
3
|
+
module Ablab
|
2
4
|
module Store
|
3
5
|
class Memory
|
4
6
|
def initialize
|
5
7
|
@views = Hash.new do |hash, key|
|
6
8
|
hash[key] = Hash.new { |hash, key| hash[key] = 0 }
|
7
9
|
end
|
8
|
-
@
|
10
|
+
@sessions = Hash.new do |hash, key|
|
11
|
+
hash[key] = Hash.new { |hash, key| hash[key] = Set.new }
|
12
|
+
end
|
13
|
+
@successes = Hash.new do |hash, key|
|
9
14
|
hash[key] = Hash.new { |hash, key| hash[key] = 0 }
|
10
15
|
end
|
16
|
+
@conversions = Hash.new do |hash, key|
|
17
|
+
hash[key] = Hash.new { |hash, key| hash[key] = Set.new }
|
18
|
+
end
|
11
19
|
end
|
12
20
|
|
13
|
-
def track_view!(experiment, bucket)
|
21
|
+
def track_view!(experiment, bucket, session_id)
|
14
22
|
@views[experiment][bucket] += 1
|
23
|
+
@sessions[experiment][bucket].add(session_id)
|
15
24
|
end
|
16
25
|
|
17
|
-
def
|
18
|
-
@
|
26
|
+
def track_success!(experiment, bucket, session_id)
|
27
|
+
@successes[experiment][bucket] += 1
|
28
|
+
@conversions[experiment][bucket].add(session_id)
|
19
29
|
end
|
20
30
|
|
21
31
|
def views(experiment, bucket)
|
22
32
|
@views[experiment][bucket]
|
23
33
|
end
|
24
34
|
|
35
|
+
def sessions(experiment, bucket)
|
36
|
+
@sessions[experiment][bucket].size
|
37
|
+
end
|
38
|
+
|
39
|
+
def successes(experiment, bucket)
|
40
|
+
@successes[experiment][bucket]
|
41
|
+
end
|
42
|
+
|
25
43
|
def conversions(experiment, bucket)
|
26
|
-
@conversions[experiment][bucket]
|
44
|
+
@conversions[experiment][bucket].size
|
45
|
+
end
|
46
|
+
|
47
|
+
def counts(experiment, bucket)
|
48
|
+
{
|
49
|
+
views: views(experiment, bucket),
|
50
|
+
sessions: sessions(experiment, bucket),
|
51
|
+
successes: successes(experiment, bucket),
|
52
|
+
conversions: conversions(experiment, bucket)
|
53
|
+
}
|
27
54
|
end
|
28
55
|
end
|
29
56
|
end
|
data/lib/ablab/store/redis.rb
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
require 'redis'
|
2
2
|
|
3
|
-
module
|
3
|
+
module Ablab
|
4
4
|
module Store
|
5
5
|
class Redis
|
6
6
|
attr_reader :redis
|
@@ -10,28 +10,54 @@ module ABLab
|
|
10
10
|
@redis = ::Redis.new(opts)
|
11
11
|
end
|
12
12
|
|
13
|
-
def track_view!(experiment, bucket)
|
14
|
-
redis.
|
13
|
+
def track_view!(experiment, bucket, session_id)
|
14
|
+
redis.pipelined do
|
15
|
+
redis.incr(key(:views, experiment, bucket))
|
16
|
+
redis.pfadd(key(:sessions, experiment, bucket), session_id)
|
17
|
+
end
|
15
18
|
end
|
16
19
|
|
17
|
-
def
|
18
|
-
redis.
|
20
|
+
def track_success!(experiment, bucket, session_id)
|
21
|
+
redis.pipelined do
|
22
|
+
redis.incr(key(:successes, experiment, bucket))
|
23
|
+
redis.pfadd(key(:conversions, experiment, bucket), session_id)
|
24
|
+
end
|
19
25
|
end
|
20
26
|
|
21
27
|
def views(experiment, bucket)
|
22
|
-
(redis.
|
28
|
+
(redis.get(key(:views, experiment, bucket)) || 0).to_i
|
29
|
+
end
|
30
|
+
|
31
|
+
def sessions(experiment, bucket)
|
32
|
+
(redis.pfcount(key(:sessions, experiment, bucket)) || 0).to_i
|
33
|
+
end
|
34
|
+
|
35
|
+
def successes(experiment, bucket)
|
36
|
+
(redis.get(key(:successes, experiment, bucket)) || 0).to_i
|
23
37
|
end
|
24
38
|
|
25
39
|
def conversions(experiment, bucket)
|
26
|
-
(redis.
|
40
|
+
(redis.pfcount(key(:conversions, experiment, bucket)) || 0).to_i
|
27
41
|
end
|
28
42
|
|
29
|
-
|
30
|
-
|
43
|
+
def counts(experiment, bucket)
|
44
|
+
v, s, x, c = nil, nil, nil, nil
|
45
|
+
redis.multi do
|
46
|
+
v = redis.get(key(:views, experiment, bucket))
|
47
|
+
s = redis.pfcount(key(:sessions, experiment, bucket))
|
48
|
+
x = redis.get(key(:successes, experiment, bucket))
|
49
|
+
c = redis.pfcount(key(:conversions, experiment, bucket))
|
50
|
+
end
|
51
|
+
{
|
52
|
+
views: (v.value || 0).to_i,
|
53
|
+
sessions: (s.value || 0).to_i,
|
54
|
+
successes: (x.value || 0).to_i,
|
55
|
+
conversions: (c.value || 0).to_i
|
56
|
+
}
|
31
57
|
end
|
32
58
|
|
33
|
-
private def
|
34
|
-
"#{experiment}:#{bucket}"
|
59
|
+
private def key(type, experiment, bucket)
|
60
|
+
"#{@key_prefix}:#{type}:#{experiment}:#{bucket}"
|
35
61
|
end
|
36
62
|
end
|
37
63
|
end
|
data/lib/ablab/version.rb
CHANGED
@@ -1,3 +1,3 @@
|
|
1
|
-
module
|
2
|
-
VERSION = "0.
|
1
|
+
module Ablab
|
2
|
+
VERSION = "0.2.0"
|
3
3
|
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'ostruct'
|
3
|
+
|
4
|
+
describe Ablab::Helper do
|
5
|
+
let(:cookies) { Hash.new }
|
6
|
+
|
7
|
+
let(:controller) do
|
8
|
+
Class.new do
|
9
|
+
include Ablab::Helper
|
10
|
+
|
11
|
+
def env
|
12
|
+
{ 'rack.session' => OpenStruct.new(id: 'abc123') }
|
13
|
+
end
|
14
|
+
end.new
|
15
|
+
end
|
16
|
+
|
17
|
+
before do
|
18
|
+
Ablab.setup do
|
19
|
+
experiment :xxx do
|
20
|
+
group :a
|
21
|
+
group :b
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
it 'calls helper_method if the including class implements it' do
|
27
|
+
klass = Class.new do
|
28
|
+
def self.helper_method(_); end
|
29
|
+
end
|
30
|
+
expect(klass).to receive(:helper_method).with :experiment
|
31
|
+
expect(klass).to receive(:helper_method).with :ablab_session_id
|
32
|
+
klass.send(:include, Ablab::Helper)
|
33
|
+
end
|
34
|
+
|
35
|
+
describe '#experiment' do
|
36
|
+
it 'returns a Run for the given experiment name' do
|
37
|
+
allow(controller).to receive(:cookies).and_return(cookies)
|
38
|
+
run = controller.send(:experiment, :xxx)
|
39
|
+
expect(run).to be_a(Ablab::Run)
|
40
|
+
expect(run.experiment).to be(Ablab.experiments[:xxx])
|
41
|
+
end
|
42
|
+
|
43
|
+
it 'returns the same Run if called twice with the same name' do
|
44
|
+
allow(controller).to receive(:cookies).and_return(cookies)
|
45
|
+
run = controller.send(:experiment, :xxx)
|
46
|
+
run2 = controller.send(:experiment, :xxx)
|
47
|
+
expect(run).to be(run2)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'ablab/store/store_examples'
|
3
|
+
|
4
|
+
describe Ablab::Store::Redis do
|
5
|
+
def cleanup
|
6
|
+
redis = ::Redis.new(db: 2)
|
7
|
+
keys = redis.keys('ablabtest:*')
|
8
|
+
redis.pipelined do
|
9
|
+
keys.each { |key| redis.del(key) }
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
around do |example|
|
14
|
+
begin
|
15
|
+
cleanup
|
16
|
+
example.run
|
17
|
+
ensure
|
18
|
+
cleanup
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
let(:store) { Ablab::Store::Redis.new(db: 2, key_prefix: 'ablabtest') }
|
23
|
+
include_examples 'store'
|
24
|
+
end
|
25
|
+
|
@@ -0,0 +1,74 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
RSpec.shared_examples 'store' do
|
4
|
+
describe '#track_view!' do
|
5
|
+
it 'tracks views' do
|
6
|
+
5.times { store.track_view!(:foo, :a, 'abc') }
|
7
|
+
7.times { store.track_view!(:foo, :b, 'abc') }
|
8
|
+
3.times { store.track_view!(:bar, :b, 'abc') }
|
9
|
+
expect(store.views(:foo, :a)).to eq(5)
|
10
|
+
expect(store.views(:foo, :b)).to eq(7)
|
11
|
+
expect(store.views(:bar, :b)).to eq(3)
|
12
|
+
end
|
13
|
+
|
14
|
+
it 'tracks unique sessions' do
|
15
|
+
2.times { store.track_view!(:foo, :a, 'foo') }
|
16
|
+
3.times { store.track_view!(:foo, :a, 'bar') }
|
17
|
+
3.times { store.track_view!(:foo, :b, 'foo') }
|
18
|
+
3.times { store.track_view!(:bar, :b, 'bar') }
|
19
|
+
expect(store.sessions(:foo, :a)).to eq(2)
|
20
|
+
expect(store.sessions(:foo, :b)).to eq(1)
|
21
|
+
expect(store.sessions(:bar, :b)).to eq(1)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
describe '#track_success!' do
|
26
|
+
it 'tracks successes' do
|
27
|
+
5.times { store.track_success!(:foo, :a, 'abc') }
|
28
|
+
7.times { store.track_success!(:foo, :b, 'abc') }
|
29
|
+
3.times { store.track_success!(:bar, :b, 'abc') }
|
30
|
+
expect(store.successes(:foo, :a)).to eq(5)
|
31
|
+
expect(store.successes(:foo, :b)).to eq(7)
|
32
|
+
expect(store.successes(:bar, :b)).to eq(3)
|
33
|
+
end
|
34
|
+
|
35
|
+
it 'tracks unique conversions' do
|
36
|
+
5.times { |i| store.track_success!(:foo, :a, i) }
|
37
|
+
2.times { store.track_success!(:foo, :b, 'foo') }
|
38
|
+
3.times { store.track_success!(:foo, :b, 'bar') }
|
39
|
+
3.times { store.track_success!(:bar, :b, 'foo') }
|
40
|
+
expect(store.conversions(:foo, :a)).to eq(5)
|
41
|
+
expect(store.conversions(:foo, :b)).to eq(2)
|
42
|
+
expect(store.conversions(:bar, :b)).to eq(1)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
describe '#views' do
|
47
|
+
it 'returns 0 if nothing was tracked' do
|
48
|
+
expect(store.views(:xxx, :a)).to eq(0)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
describe '#conversions' do
|
53
|
+
it 'returns 0 if nothing was tracked' do
|
54
|
+
expect(store.conversions(:xxx, :a)).to eq(0)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
describe '#sessions' do
|
59
|
+
it 'returns 0 if nothing was tracked' do
|
60
|
+
expect(store.sessions(:xxx, :a)).to eq(0)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
describe '#counts' do
|
65
|
+
it 'returns all counts in one call' do
|
66
|
+
2.times { store.track_view!(:foo, :a, 'foo') }
|
67
|
+
3.times { store.track_view!(:foo, :a, 'bar') }
|
68
|
+
1.times { store.track_success!(:foo, :a, 'foo') }
|
69
|
+
5.times { store.track_success!(:foo, :a, 'bar') }
|
70
|
+
2.times { store.track_success!(:foo, :a, 'baz') }
|
71
|
+
expect(store.counts(:foo, :a)).to eq(views: 5, sessions: 2, successes: 8, conversions: 3)
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
data/spec/ablab_spec.rb
ADDED
@@ -0,0 +1,160 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Ablab do
|
4
|
+
let(:ab) do
|
5
|
+
Module.new { extend Ablab::ModuleMethods }
|
6
|
+
end
|
7
|
+
|
8
|
+
it 'has a version number' do
|
9
|
+
expect(Ablab::VERSION).not_to be nil
|
10
|
+
end
|
11
|
+
|
12
|
+
describe '.experiment' do
|
13
|
+
it 'creates an experiment' do
|
14
|
+
ab.experiment :foo_bar do; end
|
15
|
+
expect(ab.experiments[:foo_bar]).to be_a(Ablab::Experiment)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
describe '.tracker' do
|
20
|
+
around do |example|
|
21
|
+
begin
|
22
|
+
Ablab::Store::Dummy = Class.new
|
23
|
+
example.run
|
24
|
+
ensure
|
25
|
+
Ablab::Store.send(:remove_const, :Dummy)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
it 'returns a Ablab::Store::Memory instance if store was never set' do
|
30
|
+
expect(ab.tracker).to be_a(Ablab::Store::Memory)
|
31
|
+
end
|
32
|
+
|
33
|
+
it 'returns the store if it was set' do
|
34
|
+
ab.store :dummy
|
35
|
+
expect(ab.tracker).to be_a(Ablab::Store::Dummy)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
describe '.dashboard_credentials' do
|
40
|
+
it 'raises if called without name or password' do
|
41
|
+
expect {
|
42
|
+
ab.dashboard_credentials(name: 'foo')
|
43
|
+
}.to raise_error(Ablab::InvalidCredentials)
|
44
|
+
|
45
|
+
expect {
|
46
|
+
ab.dashboard_credentials(password: 'foo')
|
47
|
+
}.to raise_error(Ablab::InvalidCredentials)
|
48
|
+
end
|
49
|
+
|
50
|
+
it 'sets and gets the credentials' do
|
51
|
+
ab.dashboard_credentials(name: 'foo', password: 'bar')
|
52
|
+
expect(ab.dashboard_credentials).to eq(name: 'foo', password: 'bar')
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
describe Ablab::Experiment do
|
57
|
+
let(:experiment) do
|
58
|
+
Ablab::Experiment.new('foo') do; end
|
59
|
+
end
|
60
|
+
|
61
|
+
it 'automatically creates a control group' do
|
62
|
+
expect(experiment.control).to be_a(Ablab::Group)
|
63
|
+
expect(experiment.groups).to_not be_empty
|
64
|
+
end
|
65
|
+
|
66
|
+
it 'symbolizes its name' do
|
67
|
+
expect(experiment.name).to eq(:foo)
|
68
|
+
end
|
69
|
+
|
70
|
+
describe '#description' do
|
71
|
+
it 'sets the description' do
|
72
|
+
experiment.description 'foo bar'
|
73
|
+
expect(experiment.description).to eq('foo bar')
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
describe '#description' do
|
78
|
+
it 'sets the experiment goal' do
|
79
|
+
experiment.goal 'foo bar'
|
80
|
+
expect(experiment.goal).to eq('foo bar')
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
describe '#group' do
|
85
|
+
it 'creates a group' do
|
86
|
+
experiment.group :a, description: 'foo bar baz'
|
87
|
+
expect(experiment.groups.last).to be_a(Ablab::Group)
|
88
|
+
expect(experiment.groups.last.description).to eq('foo bar baz')
|
89
|
+
end
|
90
|
+
|
91
|
+
it 'symbolizes the group name' do
|
92
|
+
experiment.group 'yeah'
|
93
|
+
expect(experiment.groups.last.name).to eq(:yeah)
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
describe '.results' do
|
98
|
+
it 'returns the results of the experiment' do
|
99
|
+
experiment.group :x, description: 'a test group'
|
100
|
+
allow(Ablab.tracker).to receive(:views) do |_, group|
|
101
|
+
{ control: 201, x: 238 }[group]
|
102
|
+
end
|
103
|
+
allow(Ablab.tracker).to receive(:sessions) do |_, group|
|
104
|
+
{ control: 182, x: 188 }[group]
|
105
|
+
end
|
106
|
+
allow(Ablab.tracker).to receive(:successes) do |_, group|
|
107
|
+
{ control: 38, x: 70 }[group]
|
108
|
+
end
|
109
|
+
allow(Ablab.tracker).to receive(:conversions) do |_, group|
|
110
|
+
{ control: 35, x: 61 }[group]
|
111
|
+
end
|
112
|
+
results = experiment.results
|
113
|
+
expect(results[:control]).to eq({
|
114
|
+
views: 201,
|
115
|
+
sessions: 182,
|
116
|
+
successes: 38,
|
117
|
+
conversions: 35,
|
118
|
+
control: true,
|
119
|
+
description: 'control group'
|
120
|
+
})
|
121
|
+
expect(results[:x]).to eq({
|
122
|
+
views: 238,
|
123
|
+
sessions: 188,
|
124
|
+
successes: 70,
|
125
|
+
conversions: 61,
|
126
|
+
control: false,
|
127
|
+
z_score: 2.9410157224928595,
|
128
|
+
description: 'a test group'
|
129
|
+
})
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
describe Ablab::Run do
|
135
|
+
let(:experiment) do
|
136
|
+
Ablab::Experiment.new(:foo) do
|
137
|
+
group :a
|
138
|
+
group :b
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
it 'gets assigned to the right group' do
|
143
|
+
run = Ablab::Run.new(experiment, 0)
|
144
|
+
allow(run).to receive(:draw).and_return 0
|
145
|
+
expect(run).to be_in_group(:control)
|
146
|
+
run = Ablab::Run.new(experiment, 0)
|
147
|
+
allow(run).to receive(:draw).and_return 334
|
148
|
+
expect(run).to be_in_group(:a)
|
149
|
+
run = Ablab::Run.new(experiment, 0)
|
150
|
+
allow(run).to receive(:draw).and_return 999
|
151
|
+
expect(run).to be_in_group(:b)
|
152
|
+
end
|
153
|
+
|
154
|
+
it 'assigns the same session ID to the same group' do
|
155
|
+
run1 = Ablab::Run.new(experiment, 'foobar')
|
156
|
+
run2 = Ablab::Run.new(experiment, 'foobar')
|
157
|
+
expect(run1.group).to eq(run2.group)
|
158
|
+
end
|
159
|
+
end
|
160
|
+
end
|