stefl-sinmetrics 0.0.9
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.
- data/.gitignore +2 -0
- data/README +33 -0
- data/Rakefile +50 -0
- data/VERSION +1 -0
- data/lib/sinmetrics.rb +12 -0
- data/lib/sinmetrics/abingo.rb +282 -0
- data/lib/sinmetrics/abingo/alternative.rb +13 -0
- data/lib/sinmetrics/abingo/experiment.rb +69 -0
- data/lib/sinmetrics/abingo/statistics.rb +90 -0
- data/lib/sinmetrics/kontagent.rb +128 -0
- data/lib/sinmetrics/mixpanel.rb +97 -0
- data/spec/abingo_spec.rb +137 -0
- data/spec/kontagent_spec.rb +23 -0
- data/spec/mixpanel_spec.rb +20 -0
- data/spec/spec_helper.rb +15 -0
- data/stefl-sinmetrics.gemspec +76 -0
- metadata +180 -0
data/.gitignore
ADDED
data/README
ADDED
@@ -0,0 +1,33 @@
|
|
1
|
+
sinmetrics: simple sinatra metrics helpers
|
2
|
+
(c) 2010 Luke Petre (lpetre)
|
3
|
+
|
4
|
+
=== Usage
|
5
|
+
|
6
|
+
require 'sinmetrics'
|
7
|
+
require 'sinatra'
|
8
|
+
|
9
|
+
mixpanel do
|
10
|
+
token '4579...cbb0'
|
11
|
+
end
|
12
|
+
|
13
|
+
get '/' do
|
14
|
+
mp.log_event( 'play', @user.id)
|
15
|
+
"hello world"
|
16
|
+
end
|
17
|
+
|
18
|
+
=== Features
|
19
|
+
|
20
|
+
=== Other Options
|
21
|
+
|
22
|
+
=== Standalone Usage
|
23
|
+
|
24
|
+
require 'sinmetrics'
|
25
|
+
mp = MixPanel.new(
|
26
|
+
:token => '4579...cbb0',
|
27
|
+
)
|
28
|
+
|
29
|
+
>> mp.log_event( 'play', 1234 )
|
30
|
+
=> 1
|
31
|
+
|
32
|
+
=== Special Thanks
|
33
|
+
This library is *heavily* inspired by sinbook, http://github.com/tmm1/sinbook
|
data/Rakefile
ADDED
@@ -0,0 +1,50 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'rake'
|
3
|
+
|
4
|
+
begin
|
5
|
+
require 'jeweler'
|
6
|
+
Jeweler::Tasks.new do |gemspec|
|
7
|
+
gemspec.name = "stefl-sinmetrics"
|
8
|
+
gemspec.summary = "simple sinatra metrics extension"
|
9
|
+
gemspec.description = "A full-featured metrics extension for the sinatra webapp framework"
|
10
|
+
gemspec.email = "lpetre@gmail.com"
|
11
|
+
gemspec.homepage = "http://github.com/stefl/sinmetrics"
|
12
|
+
gemspec.authors = ["Luke Petre"]
|
13
|
+
gemspec.add_dependency('activesupport', '>= 3.0.0')
|
14
|
+
gemspec.add_dependency('dm-core', '>= 1.0.0')
|
15
|
+
gemspec.add_dependency('dm-transactions', '>= 1.0.0')
|
16
|
+
gemspec.add_dependency('dm-aggregates', '>= 1.0.0')
|
17
|
+
gemspec.add_dependency('dm-validations', '>= 1.0.0')
|
18
|
+
gemspec.add_dependency('dm-adjust', '>= 1.0.0')
|
19
|
+
end
|
20
|
+
Jeweler::GemcutterTasks.new
|
21
|
+
rescue LoadError
|
22
|
+
puts "Jeweler not available. Install it with: gem install jeweler"
|
23
|
+
end
|
24
|
+
|
25
|
+
task :default => :spec
|
26
|
+
|
27
|
+
begin
|
28
|
+
require 'spec/rake/spectask'
|
29
|
+
desc "Run all examples"
|
30
|
+
Spec::Rake::SpecTask.new('spec') do |t|
|
31
|
+
t.spec_files = FileList['spec/**/*_spec.rb']
|
32
|
+
t.spec_opts = ['-cubfs']
|
33
|
+
end
|
34
|
+
|
35
|
+
Spec::Rake::SpecTask.new('spec:rcov') do |t|
|
36
|
+
t.spec_files = FileList['spec/**/*_spec.rb']
|
37
|
+
t.spec_opts = ['-cfs']
|
38
|
+
t.rcov = true
|
39
|
+
t.rcov_opts = ['--exclude', 'gems,spec/,examples/']
|
40
|
+
end
|
41
|
+
|
42
|
+
require 'spec/rake/verify_rcov'
|
43
|
+
RCov::VerifyTask.new(:verify_rcov => 'spec:rcov') do |t|
|
44
|
+
t.threshold = 77.0
|
45
|
+
t.require_exact_threshold = false
|
46
|
+
end
|
47
|
+
|
48
|
+
rescue LoadError
|
49
|
+
puts "spec targets require RSpec"
|
50
|
+
end
|
data/VERSION
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
0.0.9
|
data/lib/sinmetrics.rb
ADDED
@@ -0,0 +1,282 @@
|
|
1
|
+
#This class is outside code's main interface into the ABingo A/B testing framework.
|
2
|
+
#Unless you're fiddling with implementation details, it is the only one you need worry about.
|
3
|
+
|
4
|
+
#Usage of ABingo, including practical hints, is covered at http://www.bingocardcreator.com/abingo
|
5
|
+
|
6
|
+
begin
|
7
|
+
require 'sinatra/base'
|
8
|
+
require 'active_support'
|
9
|
+
rescue LoadError
|
10
|
+
retry if require 'rubygems'
|
11
|
+
raise
|
12
|
+
end
|
13
|
+
|
14
|
+
|
15
|
+
module Sinatra
|
16
|
+
class AbingoObject
|
17
|
+
|
18
|
+
#Defined options:
|
19
|
+
# :enable_specification => if true, allow params[test_name] to override the calculated value for a test.
|
20
|
+
def initialize app={}
|
21
|
+
if app.respond_to?(:options)
|
22
|
+
@app = app
|
23
|
+
[:identity, :enable_specification, :cache, :salt].each do |var|
|
24
|
+
begin
|
25
|
+
instance_variable_set("@#{var}", app.options.send("abingo_#{var}"))
|
26
|
+
rescue NoMethodError
|
27
|
+
end
|
28
|
+
end
|
29
|
+
else
|
30
|
+
[:identity, :enable_specification, :cache, :salt].each do |var|
|
31
|
+
instance_variable_set("@#{var}", app[var]) if app.has_key?(var)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
@identity ||= rand(10 ** 10).to_i.to_s
|
35
|
+
@cache ||= ActiveSupport::Cache::MemoryStore.new
|
36
|
+
@salt ||= ''
|
37
|
+
end
|
38
|
+
|
39
|
+
attr_reader :app, :enable_specification, :cache, :salt
|
40
|
+
attr_accessor :identity
|
41
|
+
|
42
|
+
#A simple convenience method for doing an A/B test. Returns true or false.
|
43
|
+
#If you pass it a block, it will bind the choice to the variable given to the block.
|
44
|
+
def flip(test_name)
|
45
|
+
choice = test(test_name, [true, false])
|
46
|
+
if block_given?
|
47
|
+
yield choice
|
48
|
+
else
|
49
|
+
choice
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
#This is the meat of A/Bingo.
|
54
|
+
#options accepts
|
55
|
+
# :multiple_participation (true or false)
|
56
|
+
# :no_particiation (true or false)
|
57
|
+
def test(test_name, alternatives, options = {})
|
58
|
+
experiment = Experiment.get(test_name)
|
59
|
+
experiment ||= Experiment.start_experiment!(self, test_name, parse_alternatives(alternatives))
|
60
|
+
|
61
|
+
# Test has been stopped, pick canonical alternative.
|
62
|
+
unless experiment.short_circuit.nil?
|
63
|
+
return experiment.short_circuit
|
64
|
+
end
|
65
|
+
|
66
|
+
choice = find_alternative_for_user(test_name, alternatives)
|
67
|
+
score_participation!(test_name, options) unless options[:no_participation]
|
68
|
+
|
69
|
+
if block_given?
|
70
|
+
yield(choice)
|
71
|
+
else
|
72
|
+
choice
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
#Scores conversions for tests.
|
77
|
+
#test_name_or_array supports three types of input:
|
78
|
+
#
|
79
|
+
#A test name: scores a conversion for the named test if the user is participating in it.
|
80
|
+
#
|
81
|
+
#An array of either of the above: for each element of the array, process as above.
|
82
|
+
#
|
83
|
+
#nil: score a conversion for every test the u
|
84
|
+
def bingo!(name = nil, options = {})
|
85
|
+
if name.kind_of? Array
|
86
|
+
name.map do |single_test|
|
87
|
+
self.bingo!(single_test, options)
|
88
|
+
end
|
89
|
+
else
|
90
|
+
if name.nil?
|
91
|
+
#Score all participating tests
|
92
|
+
participating_tests = cache.read("Abingo::participating_tests::#{identity}") || []
|
93
|
+
participating_tests.each do |participating_test|
|
94
|
+
self.bingo!(participating_test, options)
|
95
|
+
end
|
96
|
+
else
|
97
|
+
self.score_conversion!(name.to_s, options)
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
#protected
|
103
|
+
|
104
|
+
#For programmer convenience, we allow you to specify what the alternatives for
|
105
|
+
#an experiment are in a few ways. Thus, we need to actually be able to handle
|
106
|
+
#all of them. We fire this parser very infrequently (once per test, typically)
|
107
|
+
#so it can be as complicated as we want.
|
108
|
+
# Integer => a number 1 through N
|
109
|
+
# Range => a number within the range
|
110
|
+
# Array => an element of the array.
|
111
|
+
# Hash => assumes a hash of something to int. We pick one of the
|
112
|
+
# somethings, weighted accorded to the ints provided. e.g.
|
113
|
+
# {:a => 2, :b => 3} produces :a 40% of the time, :b 60%.
|
114
|
+
#
|
115
|
+
#Alternatives are always represented internally as an array.
|
116
|
+
def parse_alternatives(alternatives)
|
117
|
+
if alternatives.kind_of? Array
|
118
|
+
return alternatives
|
119
|
+
elsif alternatives.kind_of? Integer
|
120
|
+
return (1..alternatives).to_a
|
121
|
+
elsif alternatives.kind_of? Range
|
122
|
+
return alternatives.to_a
|
123
|
+
elsif alternatives.kind_of? Hash
|
124
|
+
alternatives_array = []
|
125
|
+
alternatives.each do |key, value|
|
126
|
+
if value.kind_of? Integer
|
127
|
+
alternatives_array += [key] * value
|
128
|
+
else
|
129
|
+
raise "You gave a hash with #{key} => #{value} as an element. The value must be an integral weight."
|
130
|
+
end
|
131
|
+
end
|
132
|
+
return alternatives_array
|
133
|
+
else
|
134
|
+
raise "I don't know how to turn [#{alternatives}] into an array of alternatives."
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
def find_alternative_for_user(test_name, alternatives)
|
139
|
+
cache_key = "Abingo::Experiment::#{test_name}::alternatives".gsub(" ","_")
|
140
|
+
alternative_array = self.cache.fetch(cache_key) do
|
141
|
+
self.parse_alternatives(alternatives)
|
142
|
+
end
|
143
|
+
|
144
|
+
#Quickly determines what alternative to show a given user. Given a test name
|
145
|
+
#and their identity, we hash them together (which, for MD5, provably introduces
|
146
|
+
#enough entropy that we don't care) otherwise
|
147
|
+
choice = Digest::MD5.hexdigest(salt.to_s + test_name + self.identity.to_s).to_i(16) % alternative_array.size
|
148
|
+
alternative_array[choice]
|
149
|
+
end
|
150
|
+
|
151
|
+
def alternatives_for_test(test_name)
|
152
|
+
cache_key = "Abingo::Experiment::#{test_name}::alternatives".gsub(" ","_")
|
153
|
+
self.cache.fetch(cache_key) do
|
154
|
+
Experiment.get(test_name).alternatives.map do |alt|
|
155
|
+
if alt.weight > 1
|
156
|
+
[alt.content] * alt.weight
|
157
|
+
else
|
158
|
+
alt.content
|
159
|
+
end
|
160
|
+
end.flatten
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
def score_conversion!(test_name, options = {})
|
165
|
+
participating_tests = cache.read("Abingo::participating_tests::#{identity}") || []
|
166
|
+
if options[:assume_participation] || participating_tests.include?(test_name)
|
167
|
+
cache_key = "Abingo::conversions(#{identity},#{test_name}"
|
168
|
+
if options[:multiple_conversions] || !cache.read(cache_key)
|
169
|
+
viewed_alternative = find_alternative_for_user(test_name, alternatives_for_test(test_name))
|
170
|
+
lookup = calculate_alternative_lookup(test_name, viewed_alternative)
|
171
|
+
Alternative.all(:lookup => lookup).adjust!(:conversions => 1)
|
172
|
+
if cache.exist?(cache_key)
|
173
|
+
cache.increment(cache_key)
|
174
|
+
else
|
175
|
+
cache.write(cache_key, 1)
|
176
|
+
end
|
177
|
+
end
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
def score_participation!(test_name, options = {})
|
182
|
+
participating_tests = cache.read("Abingo::participating_tests::#{identity}") || []
|
183
|
+
|
184
|
+
#Set this user to participate in this experiment, and increment participants count.
|
185
|
+
if options[:multiple_participation] || !(participating_tests.include?(test_name))
|
186
|
+
participating_tests = participating_tests.dup if participating_tests.frozen?
|
187
|
+
unless participating_tests.include?(test_name)
|
188
|
+
participating_tests << test_name
|
189
|
+
cache.write("Abingo::participating_tests::#{identity}", participating_tests)
|
190
|
+
end
|
191
|
+
|
192
|
+
choice = find_alternative_for_user(test_name, alternatives_for_test(test_name))
|
193
|
+
lookup = calculate_alternative_lookup(test_name, choice)
|
194
|
+
Alternative.all(:lookup => lookup).adjust!(:participants => 1)
|
195
|
+
end
|
196
|
+
end
|
197
|
+
|
198
|
+
def end_experiment(alternative_id)
|
199
|
+
alternative = Alternative.get(alternative_id)
|
200
|
+
experiment = alternative.experiment
|
201
|
+
if (experiment.status != "Completed")
|
202
|
+
experiment.end_experiment!(self, alternative.content)
|
203
|
+
end
|
204
|
+
end
|
205
|
+
|
206
|
+
def calculate_alternative_lookup(test_name, alternative_name)
|
207
|
+
Digest::MD5.hexdigest(self.salt + test_name + alternative_name.to_s)
|
208
|
+
end
|
209
|
+
end
|
210
|
+
|
211
|
+
module AbingoObject::ConversionRate
|
212
|
+
def conversion_rate
|
213
|
+
return 0 if participants == 0
|
214
|
+
1.0 * conversions / participants
|
215
|
+
end
|
216
|
+
|
217
|
+
def pretty_conversion_rate
|
218
|
+
sprintf("%4.2f%%", conversion_rate * 100)
|
219
|
+
end
|
220
|
+
end
|
221
|
+
|
222
|
+
module AbingoHelper
|
223
|
+
def abingo
|
224
|
+
env['abingo.helper'] ||= AbingoObject.new(self)
|
225
|
+
end
|
226
|
+
|
227
|
+
def ab_test(test_name, alternatives = nil, options = {})
|
228
|
+
if (abingo.enable_specification && !params[test_name].blank?)
|
229
|
+
choice = params[test_name]
|
230
|
+
elsif (alternatives.nil?)
|
231
|
+
choice = abingo.flip(test_name)
|
232
|
+
else
|
233
|
+
choice = abingo.test(test_name, alternatives, options)
|
234
|
+
end
|
235
|
+
|
236
|
+
if block_given?
|
237
|
+
yield(choice)
|
238
|
+
else
|
239
|
+
choice
|
240
|
+
end
|
241
|
+
end
|
242
|
+
|
243
|
+
alias ab abingo
|
244
|
+
end
|
245
|
+
|
246
|
+
class AbingoSettings
|
247
|
+
def initialize app, &blk
|
248
|
+
@app = app
|
249
|
+
instance_eval &blk
|
250
|
+
end
|
251
|
+
%w[identity backend enable_specification cache salt].each do |param|
|
252
|
+
class_eval %[
|
253
|
+
def #{param} val, &blk
|
254
|
+
@app.set :abingo_#{param}, val
|
255
|
+
end
|
256
|
+
]
|
257
|
+
end
|
258
|
+
end
|
259
|
+
|
260
|
+
module Abingo
|
261
|
+
def abingo &blk
|
262
|
+
AbingoSettings.new(self, &blk)
|
263
|
+
end
|
264
|
+
|
265
|
+
def self.registered app
|
266
|
+
app.helpers AbingoHelper
|
267
|
+
end
|
268
|
+
end
|
269
|
+
|
270
|
+
Application.register Abingo
|
271
|
+
end
|
272
|
+
|
273
|
+
Abingo = Sinatra::AbingoObject
|
274
|
+
|
275
|
+
require 'dm-core'
|
276
|
+
require 'dm-transactions'
|
277
|
+
require 'dm-aggregates'
|
278
|
+
require 'dm-validations'
|
279
|
+
require 'dm-adjust'
|
280
|
+
require 'abingo/statistics'
|
281
|
+
require 'abingo/alternative'
|
282
|
+
require 'abingo/experiment'
|
@@ -0,0 +1,13 @@
|
|
1
|
+
class Abingo::Alternative
|
2
|
+
include Abingo::ConversionRate
|
3
|
+
include DataMapper::Resource
|
4
|
+
|
5
|
+
property :id, Serial
|
6
|
+
property :content, String
|
7
|
+
property :lookup, String, :length => 32, :index => true
|
8
|
+
property :weight, Integer, :default => 1
|
9
|
+
property :participants, Integer, :default => 0
|
10
|
+
property :conversions, Integer, :default => 0
|
11
|
+
|
12
|
+
belongs_to :experiment
|
13
|
+
end
|
@@ -0,0 +1,69 @@
|
|
1
|
+
class Abingo::Experiment
|
2
|
+
include DataMapper::Resource
|
3
|
+
include Abingo::Statistics
|
4
|
+
include Abingo::ConversionRate
|
5
|
+
|
6
|
+
property :test_name, String, :key => true
|
7
|
+
property :status, String
|
8
|
+
property :short_circuit, String
|
9
|
+
|
10
|
+
has n, :alternatives, "Alternative"
|
11
|
+
|
12
|
+
def cache_keys
|
13
|
+
["Abingo::Experiment::exists(#{test_name})".gsub(" ", "_"),
|
14
|
+
"Abingo::Experiment::#{test_name}::alternatives".gsub(" ","_"),
|
15
|
+
"Abingo::Experiment::short_circuit(#{test_name})".gsub(" ", "_")
|
16
|
+
]
|
17
|
+
end
|
18
|
+
|
19
|
+
def before_destroy
|
20
|
+
cache_keys.each do |key|
|
21
|
+
Abingo.cache.delete key
|
22
|
+
end
|
23
|
+
true
|
24
|
+
end
|
25
|
+
|
26
|
+
def participants
|
27
|
+
alternatives.sum(:participants)
|
28
|
+
end
|
29
|
+
|
30
|
+
def conversions
|
31
|
+
alternatives.sum(:conversions)
|
32
|
+
end
|
33
|
+
|
34
|
+
def best_alternative
|
35
|
+
alts = Array.new alternatives.each { |a| a }
|
36
|
+
alts.max do |a,b|
|
37
|
+
a.conversion_rate <=> b.conversion_rate
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def self.start_experiment!(abingo, test_name, alternatives_array)
|
42
|
+
cloned_alternatives_array = alternatives_array.clone
|
43
|
+
Abingo::Experiment.transaction do |txn|
|
44
|
+
experiment = Abingo::Experiment.first_or_create(:test_name => test_name)
|
45
|
+
experiment.alternatives.destroy #Blows away alternatives for pre-existing experiments.
|
46
|
+
while (cloned_alternatives_array.size > 0)
|
47
|
+
alt = cloned_alternatives_array[0]
|
48
|
+
weight = cloned_alternatives_array.size - (cloned_alternatives_array - [alt]).size
|
49
|
+
experiment.alternatives << Abingo::Alternative.new(:content => alt, :weight => weight,
|
50
|
+
:lookup => abingo.calculate_alternative_lookup(test_name, alt))
|
51
|
+
cloned_alternatives_array -= [alt]
|
52
|
+
end
|
53
|
+
experiment.status = "Live"
|
54
|
+
experiment.save
|
55
|
+
experiment
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def end_experiment!(abingo, final_alternative)
|
60
|
+
Abingo::Experiment.transaction do
|
61
|
+
alternatives.each do |alternative|
|
62
|
+
alternative.lookup = "Experiment completed. #{alternative.id}"
|
63
|
+
alternative.save!
|
64
|
+
end
|
65
|
+
update(:status => "Finished", :short_circuit => final_alternative)
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
end
|
@@ -0,0 +1,90 @@
|
|
1
|
+
#Designed to be included into Abingo::Experiment, but you can feel free to adapt this
|
2
|
+
#to anything you want.
|
3
|
+
|
4
|
+
module Abingo::Statistics
|
5
|
+
|
6
|
+
HANDY_Z_SCORE_CHEATSHEET = [[0.10, 1.29], [0.05, 1.65], [0.01, 2.33], [0.001, 3.08]]
|
7
|
+
|
8
|
+
PERCENTAGES = {0.10 => '90%', 0.05 => '95%', 0.01 => '99%', 0.001 => '99.9%'}
|
9
|
+
|
10
|
+
DESCRIPTION_IN_WORDS = {0.10 => 'fairly confident', 0.05 => 'confident',
|
11
|
+
0.01 => 'very confident', 0.001 => 'extremely confident'}
|
12
|
+
def zscore
|
13
|
+
if alternatives.size != 2
|
14
|
+
raise "Sorry, can't currently automatically calculate statistics for A/B tests with > 2 alternatives."
|
15
|
+
end
|
16
|
+
|
17
|
+
if (alternatives[0].participants == 0) || (alternatives[1].participants == 0)
|
18
|
+
raise "Can't calculate the z score if either of the alternatives lacks participants."
|
19
|
+
end
|
20
|
+
|
21
|
+
cr1 = alternatives[0].conversion_rate
|
22
|
+
cr2 = alternatives[1].conversion_rate
|
23
|
+
|
24
|
+
n1 = alternatives[0].participants
|
25
|
+
n2 = alternatives[1].participants
|
26
|
+
|
27
|
+
numerator = cr1 - cr2
|
28
|
+
frac1 = cr1 * (1 - cr1) / n1
|
29
|
+
frac2 = cr2 * (1 - cr2) / n2
|
30
|
+
|
31
|
+
numerator / ((frac1 + frac2) ** 0.5)
|
32
|
+
end
|
33
|
+
|
34
|
+
def p_value
|
35
|
+
index = 0
|
36
|
+
z = zscore
|
37
|
+
z = z.abs
|
38
|
+
found_p = nil
|
39
|
+
while index < HANDY_Z_SCORE_CHEATSHEET.size do
|
40
|
+
if (z > HANDY_Z_SCORE_CHEATSHEET[index][1])
|
41
|
+
found_p = HANDY_Z_SCORE_CHEATSHEET[index][0]
|
42
|
+
end
|
43
|
+
index += 1
|
44
|
+
end
|
45
|
+
found_p
|
46
|
+
end
|
47
|
+
|
48
|
+
def is_statistically_significant?(p = 0.05)
|
49
|
+
p_value <= p
|
50
|
+
end
|
51
|
+
|
52
|
+
def pretty_conversion_rate
|
53
|
+
sprintf("%4.2f%%", conversion_rate * 100)
|
54
|
+
end
|
55
|
+
|
56
|
+
def describe_result_in_words
|
57
|
+
begin
|
58
|
+
z = zscore
|
59
|
+
rescue
|
60
|
+
return "Could not execute the significance test because one or more of the alternatives has not been seen yet."
|
61
|
+
end
|
62
|
+
p = p_value
|
63
|
+
|
64
|
+
words = ""
|
65
|
+
if (alternatives[0].participants < 10) || (alternatives[1].participants < 10)
|
66
|
+
words += "Take these results with a grain of salt since your samples are so small: "
|
67
|
+
end
|
68
|
+
|
69
|
+
alts = alternatives - [best_alternative]
|
70
|
+
worst_alternative = alts.first
|
71
|
+
|
72
|
+
words += "The best alternative you have is: [#{best_alternative.content}], which had "
|
73
|
+
words += "#{best_alternative.conversions} conversions from #{best_alternative.participants} participants "
|
74
|
+
words += "(#{best_alternative.pretty_conversion_rate}). The other alternative was [#{worst_alternative.content}], "
|
75
|
+
words += "which had #{worst_alternative.conversions} conversions from #{worst_alternative.participants} participants "
|
76
|
+
words += "(#{worst_alternative.pretty_conversion_rate}). "
|
77
|
+
|
78
|
+
if (p.nil?)
|
79
|
+
words += "However, this difference is not statistically significant."
|
80
|
+
else
|
81
|
+
words += "This difference is #{PERCENTAGES[p]} likely to be statistically significant, which means you can be "
|
82
|
+
words += "#{DESCRIPTION_IN_WORDS[p]} that it is the result of your alternatives actually mattering, rather than "
|
83
|
+
words += "being due to random chance. However, this statistical test can't measure how likely the currently "
|
84
|
+
words += "observed magnitude of the difference is to be accurate or not. It only says \"better\", not \"better "
|
85
|
+
words += "by so much\"."
|
86
|
+
end
|
87
|
+
words
|
88
|
+
end
|
89
|
+
|
90
|
+
end
|
@@ -0,0 +1,128 @@
|
|
1
|
+
begin
|
2
|
+
require 'sinatra/base'
|
3
|
+
rescue LoadError
|
4
|
+
retry if require 'rubygems'
|
5
|
+
raise
|
6
|
+
end
|
7
|
+
|
8
|
+
module Sinatra
|
9
|
+
require 'digest/md5'
|
10
|
+
require 'net/http'
|
11
|
+
|
12
|
+
class KontagentObject
|
13
|
+
@@version = 1
|
14
|
+
def initialize app
|
15
|
+
if app.respond_to?(:options)
|
16
|
+
@app = app
|
17
|
+
[:api_key, :secret, :env].each do |var|
|
18
|
+
instance_variable_set("@#{var}", app.options.send("kontagent_#{var}"))
|
19
|
+
end
|
20
|
+
else
|
21
|
+
@request = Proc.new { |url| Net::HTTP.get( URI.parse(url) ) }
|
22
|
+
[:api_key, :secret, :env, :request].each do |var|
|
23
|
+
instance_variable_set("@#{var}", app[var]) if app.has_key?(var)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
attr_reader :app
|
29
|
+
attr_accessor :api_key, :secret, :env
|
30
|
+
def request *args
|
31
|
+
if @app
|
32
|
+
@app.options.kontagent_request *args
|
33
|
+
else
|
34
|
+
@request.call *args
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
class APIProxy
|
39
|
+
Types = %w[
|
40
|
+
ins inr
|
41
|
+
nts ntr
|
42
|
+
nes nei
|
43
|
+
pst psr
|
44
|
+
apa apr
|
45
|
+
mes mer
|
46
|
+
ucc
|
47
|
+
pgr
|
48
|
+
cpu
|
49
|
+
gci
|
50
|
+
mtu
|
51
|
+
]
|
52
|
+
end
|
53
|
+
|
54
|
+
APIProxy::Types.each do |n|
|
55
|
+
class_eval %[
|
56
|
+
def #{n}_url args
|
57
|
+
make_url '#{n}', args
|
58
|
+
end
|
59
|
+
def #{n} args = {}
|
60
|
+
request( #{n}_url( args ) ) unless env == :test
|
61
|
+
end
|
62
|
+
]
|
63
|
+
end
|
64
|
+
|
65
|
+
def base_url
|
66
|
+
case env
|
67
|
+
when :production ; 'http://api.geo.kontagent.net/api'
|
68
|
+
else ; 'http://api.test.kontagent.com/api'
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
def make_url method, args = {}
|
73
|
+
args = { :ts => Time.now.getgm.strftime("%Y-%m-%dT%H:%M:%S") }.merge(args)
|
74
|
+
sorted = args.map{ |k,v|
|
75
|
+
next nil unless v
|
76
|
+
next nil if k == :url_only
|
77
|
+
"#{k}=" + case v
|
78
|
+
when Array
|
79
|
+
v.join('%2C')
|
80
|
+
else
|
81
|
+
v.to_s
|
82
|
+
end
|
83
|
+
}.compact.sort
|
84
|
+
|
85
|
+
sorted << "an_sig=" + Digest::MD5.hexdigest(sorted.join+self.secret)
|
86
|
+
query = sorted.map{|v| v.gsub('&', '%26').gsub(' ', '+')}.join('&')
|
87
|
+
"#{base_url}/v#{@@version}/#{api_key}/#{method}/?#{query}"
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
module KontagentHelper
|
92
|
+
def kontagent
|
93
|
+
env['kontagent.helper'] ||= KontagentObject.new(self)
|
94
|
+
end
|
95
|
+
|
96
|
+
alias kt kontagent
|
97
|
+
end
|
98
|
+
|
99
|
+
class KontagentSettings
|
100
|
+
def initialize app, &blk
|
101
|
+
@app = app
|
102
|
+
@app.set :kontagent_env, @app.environment
|
103
|
+
@app.set :kontagent_request, Proc.new { |url| Net::HTTP.get( URI.parse(url) ) }
|
104
|
+
instance_eval &blk
|
105
|
+
end
|
106
|
+
%w[ api_key secret env request ].each do |param|
|
107
|
+
class_eval %[
|
108
|
+
def #{param} val, &blk
|
109
|
+
@app.set :kontagent_#{param}, val
|
110
|
+
end
|
111
|
+
]
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
module Kontagent
|
116
|
+
def kontagent &blk
|
117
|
+
KontagentSettings.new(self, &blk)
|
118
|
+
end
|
119
|
+
|
120
|
+
def self.registered app
|
121
|
+
app.helpers KontagentHelper
|
122
|
+
end
|
123
|
+
end
|
124
|
+
|
125
|
+
Application.register Kontagent
|
126
|
+
end
|
127
|
+
|
128
|
+
Kontagent = Sinatra::KontagentObject
|
@@ -0,0 +1,97 @@
|
|
1
|
+
module Sinatra
|
2
|
+
require 'digest/md5'
|
3
|
+
require 'base64'
|
4
|
+
require 'net/http'
|
5
|
+
|
6
|
+
class MixpanelObject
|
7
|
+
@@version = 1
|
8
|
+
def initialize app
|
9
|
+
if app.respond_to?(:options)
|
10
|
+
@app = app
|
11
|
+
[:api_key, :secret, :token].each do |var|
|
12
|
+
instance_variable_set("@#{var}", app.options.send("mixpanel_#{var}"))
|
13
|
+
end
|
14
|
+
else
|
15
|
+
@request = Proc.new { |url| Net::HTTP.get( URI.parse(url) ) }
|
16
|
+
[:api_key, :secret, :token, :request ].each do |var|
|
17
|
+
instance_variable_set("@#{var}", app[var]) if app.has_key?(var)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
attr_reader :app
|
23
|
+
attr_accessor :api_key, :secret, :token
|
24
|
+
|
25
|
+
def request *args
|
26
|
+
if @app
|
27
|
+
@app.options.mixpanel_request *args
|
28
|
+
else
|
29
|
+
@request.call *args
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def log_event(event, user_id, opts = {})
|
34
|
+
options = {}
|
35
|
+
options['ip'] = @app.request.ip if @app
|
36
|
+
options['time'] = Time.now.to_i
|
37
|
+
options['token'] = token
|
38
|
+
options['distinct_id'] = user_id if user_id
|
39
|
+
opts.each do |key, value|
|
40
|
+
if [:step].include? key
|
41
|
+
options[key.to_s] = value.to_i
|
42
|
+
else
|
43
|
+
options[key.to_s] = value.to_s
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
data = ::Base64.encode64( { 'event' => event, 'properties' => options }.to_json ).gsub(/\n/, '')
|
48
|
+
data = "#{data}&ip=1" if options.has_key? 'ip'
|
49
|
+
request "http://api.mixpanel.com/track/?data=#{data}"
|
50
|
+
end
|
51
|
+
|
52
|
+
def log_funnel(funnel_name, step_number, step_name, user_id, opts = {})
|
53
|
+
funnel_opts = opts.merge({:funnel => funnel_name, :step => step_number, :goal => step_name})
|
54
|
+
log_event("mp_funnel", user_id, funnel_opts)
|
55
|
+
end
|
56
|
+
|
57
|
+
end
|
58
|
+
|
59
|
+
module MixpanelHelper
|
60
|
+
def mixpanel
|
61
|
+
env['mixpanel.helper'] ||= MixpanelObject.new(self)
|
62
|
+
end
|
63
|
+
|
64
|
+
alias mp mixpanel
|
65
|
+
end
|
66
|
+
|
67
|
+
class MixpanelSettings
|
68
|
+
def initialize app, &blk
|
69
|
+
@app = app
|
70
|
+
@app.set :mixpanel_api_key, nil
|
71
|
+
@app.set :mixpanel_secret, nil
|
72
|
+
@app.set :mixpanel_request, Proc.new { |url| Net::HTTP.get(URI.parse(url)) }
|
73
|
+
instance_eval &blk
|
74
|
+
end
|
75
|
+
%w[ api_key secret token request ].each do |param|
|
76
|
+
class_eval %[
|
77
|
+
def #{param} val, &blk
|
78
|
+
@app.set :mixpanel_#{param}, val
|
79
|
+
end
|
80
|
+
]
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
module Mixpanel
|
85
|
+
def mixpanel &blk
|
86
|
+
MixpanelSettings.new(self, &blk)
|
87
|
+
end
|
88
|
+
|
89
|
+
def self.registered app
|
90
|
+
app.helpers MixpanelHelper
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
Application.register Mixpanel
|
95
|
+
end
|
96
|
+
|
97
|
+
Mixpanel = Sinatra::MixpanelObject
|
data/spec/abingo_spec.rb
ADDED
@@ -0,0 +1,137 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe 'Sinatra Abingo' do
|
4
|
+
|
5
|
+
it 'should be configurable standalone' do
|
6
|
+
ab = Abingo.new :identity => 'abcd'
|
7
|
+
end
|
8
|
+
|
9
|
+
it 'should be configurable w/in sinatra' do
|
10
|
+
class AbingoApp < Sinatra::Base
|
11
|
+
register Sinatra::Abingo
|
12
|
+
abingo do
|
13
|
+
identity 'abcd'
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
app = AbingoApp.new
|
18
|
+
end
|
19
|
+
|
20
|
+
end
|
21
|
+
|
22
|
+
describe 'Abingo Specs' do
|
23
|
+
before :each do
|
24
|
+
@abingo = Abingo.new
|
25
|
+
end
|
26
|
+
|
27
|
+
it "should automatically assign identity" do
|
28
|
+
@abingo.identity.should_not be_nil
|
29
|
+
end
|
30
|
+
|
31
|
+
it "should parse alternatives" do
|
32
|
+
array = %w{a b c}
|
33
|
+
@abingo.parse_alternatives(array).should == array
|
34
|
+
@abingo.parse_alternatives(65).size.should == 65
|
35
|
+
@abingo.parse_alternatives(2..5).size.should == 4
|
36
|
+
@abingo.parse_alternatives(2..5).should_not include(1)
|
37
|
+
end
|
38
|
+
|
39
|
+
it "should parse weighted alternatives" do
|
40
|
+
hash = { 'a' => 2, 'b' => 3, 'c' => 1}
|
41
|
+
array = %w{a a a b b c}
|
42
|
+
@abingo.parse_alternatives(array).should == array
|
43
|
+
end
|
44
|
+
|
45
|
+
|
46
|
+
it "should create experiments" do
|
47
|
+
Abingo::Experiment.count.should == 0
|
48
|
+
Abingo::Alternative.count.should == 0
|
49
|
+
alternatives = %w{A B}
|
50
|
+
alternative_selected = @abingo.test("unit_test_sample_A", alternatives)
|
51
|
+
Abingo::Experiment.count.should == 1
|
52
|
+
Abingo::Alternative.count.should == 2
|
53
|
+
alternatives.should include(alternative_selected)
|
54
|
+
end
|
55
|
+
|
56
|
+
it "should pick alternatives consistently" do
|
57
|
+
alternative_picked = @abingo.test("consistency_test", 1..100)
|
58
|
+
100.times do
|
59
|
+
@abingo.test("consistency_test", 1..100).should == alternative_picked
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
it "should have working participation" do
|
64
|
+
new_tests = %w{participationA participationB participationC}
|
65
|
+
new_tests.map do |test_name|
|
66
|
+
@abingo.test(test_name, 1..5)
|
67
|
+
end
|
68
|
+
|
69
|
+
participating_tests = @abingo.cache.read("Abingo::participating_tests::#{@abingo.identity}") || []
|
70
|
+
|
71
|
+
new_tests.map do |test_name|
|
72
|
+
participating_tests.should include(test_name)
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
it "should count participants" do
|
77
|
+
test_name = "participants_counted_test"
|
78
|
+
alternative = @abingo.test(test_name, %w{a b c})
|
79
|
+
|
80
|
+
ex = Abingo::Experiment.get(test_name)
|
81
|
+
lookup = @abingo.calculate_alternative_lookup(test_name, alternative)
|
82
|
+
chosen_alt = Abingo::Alternative.first(:lookup => lookup)
|
83
|
+
ex.participants.should == 1
|
84
|
+
chosen_alt.participants.should == 1
|
85
|
+
end
|
86
|
+
|
87
|
+
it "should track conversions by test name" do
|
88
|
+
test_name = "conversion_test_by_name"
|
89
|
+
alternative = @abingo.test(test_name, %w{a b c})
|
90
|
+
@abingo.bingo!(test_name)
|
91
|
+
ex = Abingo::Experiment.get(test_name)
|
92
|
+
lookup = @abingo.calculate_alternative_lookup(test_name, alternative)
|
93
|
+
chosen_alt = Abingo::Alternative.first(:lookup => lookup)
|
94
|
+
ex.conversions.should == 1
|
95
|
+
chosen_alt.conversions.should == 1
|
96
|
+
|
97
|
+
@abingo.bingo!(test_name)
|
98
|
+
|
99
|
+
#Should still only have one because this conversion should not be double counted.
|
100
|
+
#We haven't specified that in the test options.
|
101
|
+
ex = Abingo::Experiment.get(test_name)
|
102
|
+
ex.conversions.should == 1
|
103
|
+
end
|
104
|
+
|
105
|
+
it "should know the best alternative" do
|
106
|
+
test_name = "conversion_test_by_name"
|
107
|
+
alternative = @abingo.test(test_name, {'a' => 3, 'b' => 2, 'c' => 1})
|
108
|
+
@abingo.bingo!(test_name)
|
109
|
+
ex = Abingo::Experiment.get(test_name)
|
110
|
+
ex.best_alternative.content.should == alternative
|
111
|
+
end
|
112
|
+
|
113
|
+
it "should be possible to short circuit tests" do
|
114
|
+
test_name = "short circuit test"
|
115
|
+
alt_picked = @abingo.test(test_name, %w{A B})
|
116
|
+
ex = Abingo::Experiment.get(test_name)
|
117
|
+
alt_not_picked = (%w{A B} - [alt_picked]).first
|
118
|
+
|
119
|
+
ex.end_experiment!(@abingo, alt_not_picked)
|
120
|
+
|
121
|
+
ex.reload
|
122
|
+
ex.status.should == "Finished"
|
123
|
+
|
124
|
+
@abingo.bingo!(test_name) #Should not be counted, test is over.
|
125
|
+
ex.conversions.should == 0
|
126
|
+
|
127
|
+
old_identity = @abingo.identity
|
128
|
+
@abingo.identity = "shortCircuitTestNewIdentity"
|
129
|
+
@abingo.test(test_name, %w{A B})
|
130
|
+
@abingo.identity = old_identity
|
131
|
+
ex.reload
|
132
|
+
|
133
|
+
# Original identity counted, new identity not counted b/c test stopped
|
134
|
+
ex.participants.should == 1
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
@@ -0,0 +1,23 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe 'Sinatra Kontagent' do
|
4
|
+
|
5
|
+
it 'should be configurable standalone' do
|
6
|
+
kontagent = Kontagent.new :api_key => '1234', :secret => '5678'
|
7
|
+
end
|
8
|
+
|
9
|
+
it 'should be configurable w/in sinatra' do
|
10
|
+
class KontagentApp < Sinatra::Base
|
11
|
+
register Sinatra::Kontagent
|
12
|
+
kontagent do
|
13
|
+
api_key "1234"
|
14
|
+
secret "5678"
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
app = KontagentApp.new
|
19
|
+
end
|
20
|
+
|
21
|
+
|
22
|
+
end
|
23
|
+
|
@@ -0,0 +1,20 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe 'Sinatra Mixpanel' do
|
4
|
+
|
5
|
+
it 'should be configurable standalone' do
|
6
|
+
mp = Mixpanel.new :token => "abcdefghijklmnopqrstuvwyxz"
|
7
|
+
end
|
8
|
+
|
9
|
+
it 'should be configurable w/in sinatra' do
|
10
|
+
class MixpanelApp < Sinatra::Base
|
11
|
+
register Sinatra::Mixpanel
|
12
|
+
mixpanel do
|
13
|
+
token "abcdefghijklmnopqrstuvwyxz"
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
app = MixpanelApp.new
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
$:.unshift(File.dirname(__FILE__) + '/../lib')
|
2
|
+
|
3
|
+
require 'rubygems'
|
4
|
+
require 'spec'
|
5
|
+
require 'sinmetrics'
|
6
|
+
require 'dm-migrations'
|
7
|
+
|
8
|
+
# establish in-memory database for testing
|
9
|
+
#DataMapper::Logger.new($stdout, :debug)
|
10
|
+
DataMapper.setup(:default, "sqlite3::memory:")
|
11
|
+
|
12
|
+
Spec::Runner.configure do |config|
|
13
|
+
# reset database before each example is run
|
14
|
+
config.before(:each) { DataMapper.auto_migrate! }
|
15
|
+
end
|
@@ -0,0 +1,76 @@
|
|
1
|
+
# Generated by jeweler
|
2
|
+
# DO NOT EDIT THIS FILE DIRECTLY
|
3
|
+
# Instead, edit Jeweler::Tasks in Rakefile, and run the gemspec command
|
4
|
+
# -*- encoding: utf-8 -*-
|
5
|
+
|
6
|
+
Gem::Specification.new do |s|
|
7
|
+
s.name = %q{stefl-sinmetrics}
|
8
|
+
s.version = "0.0.9"
|
9
|
+
|
10
|
+
s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
|
11
|
+
s.authors = ["Luke Petre"]
|
12
|
+
s.date = %q{2010-10-05}
|
13
|
+
s.description = %q{A full-featured metrics extension for the sinatra webapp framework}
|
14
|
+
s.email = %q{lpetre@gmail.com}
|
15
|
+
s.extra_rdoc_files = [
|
16
|
+
"README"
|
17
|
+
]
|
18
|
+
s.files = [
|
19
|
+
".gitignore",
|
20
|
+
"README",
|
21
|
+
"Rakefile",
|
22
|
+
"VERSION",
|
23
|
+
"lib/sinmetrics.rb",
|
24
|
+
"lib/sinmetrics/abingo.rb",
|
25
|
+
"lib/sinmetrics/abingo/alternative.rb",
|
26
|
+
"lib/sinmetrics/abingo/experiment.rb",
|
27
|
+
"lib/sinmetrics/abingo/statistics.rb",
|
28
|
+
"lib/sinmetrics/kontagent.rb",
|
29
|
+
"lib/sinmetrics/mixpanel.rb",
|
30
|
+
"spec/abingo_spec.rb",
|
31
|
+
"spec/kontagent_spec.rb",
|
32
|
+
"spec/mixpanel_spec.rb",
|
33
|
+
"spec/spec_helper.rb",
|
34
|
+
"stefl-sinmetrics.gemspec"
|
35
|
+
]
|
36
|
+
s.homepage = %q{http://github.com/stefl/sinmetrics}
|
37
|
+
s.rdoc_options = ["--charset=UTF-8"]
|
38
|
+
s.require_paths = ["lib"]
|
39
|
+
s.rubygems_version = %q{1.3.7}
|
40
|
+
s.summary = %q{simple sinatra metrics extension}
|
41
|
+
s.test_files = [
|
42
|
+
"spec/abingo_spec.rb",
|
43
|
+
"spec/kontagent_spec.rb",
|
44
|
+
"spec/mixpanel_spec.rb",
|
45
|
+
"spec/spec_helper.rb"
|
46
|
+
]
|
47
|
+
|
48
|
+
if s.respond_to? :specification_version then
|
49
|
+
current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION
|
50
|
+
s.specification_version = 3
|
51
|
+
|
52
|
+
if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
|
53
|
+
s.add_runtime_dependency(%q<activesupport>, [">= 3.0.0"])
|
54
|
+
s.add_runtime_dependency(%q<dm-core>, [">= 1.0.0"])
|
55
|
+
s.add_runtime_dependency(%q<dm-transactions>, [">= 1.0.0"])
|
56
|
+
s.add_runtime_dependency(%q<dm-aggregates>, [">= 1.0.0"])
|
57
|
+
s.add_runtime_dependency(%q<dm-validations>, [">= 1.0.0"])
|
58
|
+
s.add_runtime_dependency(%q<dm-adjust>, [">= 1.0.0"])
|
59
|
+
else
|
60
|
+
s.add_dependency(%q<activesupport>, [">= 3.0.0"])
|
61
|
+
s.add_dependency(%q<dm-core>, [">= 1.0.0"])
|
62
|
+
s.add_dependency(%q<dm-transactions>, [">= 1.0.0"])
|
63
|
+
s.add_dependency(%q<dm-aggregates>, [">= 1.0.0"])
|
64
|
+
s.add_dependency(%q<dm-validations>, [">= 1.0.0"])
|
65
|
+
s.add_dependency(%q<dm-adjust>, [">= 1.0.0"])
|
66
|
+
end
|
67
|
+
else
|
68
|
+
s.add_dependency(%q<activesupport>, [">= 3.0.0"])
|
69
|
+
s.add_dependency(%q<dm-core>, [">= 1.0.0"])
|
70
|
+
s.add_dependency(%q<dm-transactions>, [">= 1.0.0"])
|
71
|
+
s.add_dependency(%q<dm-aggregates>, [">= 1.0.0"])
|
72
|
+
s.add_dependency(%q<dm-validations>, [">= 1.0.0"])
|
73
|
+
s.add_dependency(%q<dm-adjust>, [">= 1.0.0"])
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
metadata
ADDED
@@ -0,0 +1,180 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: stefl-sinmetrics
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
hash: 13
|
5
|
+
prerelease: false
|
6
|
+
segments:
|
7
|
+
- 0
|
8
|
+
- 0
|
9
|
+
- 9
|
10
|
+
version: 0.0.9
|
11
|
+
platform: ruby
|
12
|
+
authors:
|
13
|
+
- Luke Petre
|
14
|
+
autorequire:
|
15
|
+
bindir: bin
|
16
|
+
cert_chain: []
|
17
|
+
|
18
|
+
date: 2010-10-05 00:00:00 +01:00
|
19
|
+
default_executable:
|
20
|
+
dependencies:
|
21
|
+
- !ruby/object:Gem::Dependency
|
22
|
+
name: activesupport
|
23
|
+
prerelease: false
|
24
|
+
requirement: &id001 !ruby/object:Gem::Requirement
|
25
|
+
none: false
|
26
|
+
requirements:
|
27
|
+
- - ">="
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
hash: 7
|
30
|
+
segments:
|
31
|
+
- 3
|
32
|
+
- 0
|
33
|
+
- 0
|
34
|
+
version: 3.0.0
|
35
|
+
type: :runtime
|
36
|
+
version_requirements: *id001
|
37
|
+
- !ruby/object:Gem::Dependency
|
38
|
+
name: dm-core
|
39
|
+
prerelease: false
|
40
|
+
requirement: &id002 !ruby/object:Gem::Requirement
|
41
|
+
none: false
|
42
|
+
requirements:
|
43
|
+
- - ">="
|
44
|
+
- !ruby/object:Gem::Version
|
45
|
+
hash: 23
|
46
|
+
segments:
|
47
|
+
- 1
|
48
|
+
- 0
|
49
|
+
- 0
|
50
|
+
version: 1.0.0
|
51
|
+
type: :runtime
|
52
|
+
version_requirements: *id002
|
53
|
+
- !ruby/object:Gem::Dependency
|
54
|
+
name: dm-transactions
|
55
|
+
prerelease: false
|
56
|
+
requirement: &id003 !ruby/object:Gem::Requirement
|
57
|
+
none: false
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
hash: 23
|
62
|
+
segments:
|
63
|
+
- 1
|
64
|
+
- 0
|
65
|
+
- 0
|
66
|
+
version: 1.0.0
|
67
|
+
type: :runtime
|
68
|
+
version_requirements: *id003
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: dm-aggregates
|
71
|
+
prerelease: false
|
72
|
+
requirement: &id004 !ruby/object:Gem::Requirement
|
73
|
+
none: false
|
74
|
+
requirements:
|
75
|
+
- - ">="
|
76
|
+
- !ruby/object:Gem::Version
|
77
|
+
hash: 23
|
78
|
+
segments:
|
79
|
+
- 1
|
80
|
+
- 0
|
81
|
+
- 0
|
82
|
+
version: 1.0.0
|
83
|
+
type: :runtime
|
84
|
+
version_requirements: *id004
|
85
|
+
- !ruby/object:Gem::Dependency
|
86
|
+
name: dm-validations
|
87
|
+
prerelease: false
|
88
|
+
requirement: &id005 !ruby/object:Gem::Requirement
|
89
|
+
none: false
|
90
|
+
requirements:
|
91
|
+
- - ">="
|
92
|
+
- !ruby/object:Gem::Version
|
93
|
+
hash: 23
|
94
|
+
segments:
|
95
|
+
- 1
|
96
|
+
- 0
|
97
|
+
- 0
|
98
|
+
version: 1.0.0
|
99
|
+
type: :runtime
|
100
|
+
version_requirements: *id005
|
101
|
+
- !ruby/object:Gem::Dependency
|
102
|
+
name: dm-adjust
|
103
|
+
prerelease: false
|
104
|
+
requirement: &id006 !ruby/object:Gem::Requirement
|
105
|
+
none: false
|
106
|
+
requirements:
|
107
|
+
- - ">="
|
108
|
+
- !ruby/object:Gem::Version
|
109
|
+
hash: 23
|
110
|
+
segments:
|
111
|
+
- 1
|
112
|
+
- 0
|
113
|
+
- 0
|
114
|
+
version: 1.0.0
|
115
|
+
type: :runtime
|
116
|
+
version_requirements: *id006
|
117
|
+
description: A full-featured metrics extension for the sinatra webapp framework
|
118
|
+
email: lpetre@gmail.com
|
119
|
+
executables: []
|
120
|
+
|
121
|
+
extensions: []
|
122
|
+
|
123
|
+
extra_rdoc_files:
|
124
|
+
- README
|
125
|
+
files:
|
126
|
+
- .gitignore
|
127
|
+
- README
|
128
|
+
- Rakefile
|
129
|
+
- VERSION
|
130
|
+
- lib/sinmetrics.rb
|
131
|
+
- lib/sinmetrics/abingo.rb
|
132
|
+
- lib/sinmetrics/abingo/alternative.rb
|
133
|
+
- lib/sinmetrics/abingo/experiment.rb
|
134
|
+
- lib/sinmetrics/abingo/statistics.rb
|
135
|
+
- lib/sinmetrics/kontagent.rb
|
136
|
+
- lib/sinmetrics/mixpanel.rb
|
137
|
+
- spec/abingo_spec.rb
|
138
|
+
- spec/kontagent_spec.rb
|
139
|
+
- spec/mixpanel_spec.rb
|
140
|
+
- spec/spec_helper.rb
|
141
|
+
- stefl-sinmetrics.gemspec
|
142
|
+
has_rdoc: true
|
143
|
+
homepage: http://github.com/stefl/sinmetrics
|
144
|
+
licenses: []
|
145
|
+
|
146
|
+
post_install_message:
|
147
|
+
rdoc_options:
|
148
|
+
- --charset=UTF-8
|
149
|
+
require_paths:
|
150
|
+
- lib
|
151
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
152
|
+
none: false
|
153
|
+
requirements:
|
154
|
+
- - ">="
|
155
|
+
- !ruby/object:Gem::Version
|
156
|
+
hash: 3
|
157
|
+
segments:
|
158
|
+
- 0
|
159
|
+
version: "0"
|
160
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
161
|
+
none: false
|
162
|
+
requirements:
|
163
|
+
- - ">="
|
164
|
+
- !ruby/object:Gem::Version
|
165
|
+
hash: 3
|
166
|
+
segments:
|
167
|
+
- 0
|
168
|
+
version: "0"
|
169
|
+
requirements: []
|
170
|
+
|
171
|
+
rubyforge_project:
|
172
|
+
rubygems_version: 1.3.7
|
173
|
+
signing_key:
|
174
|
+
specification_version: 3
|
175
|
+
summary: simple sinatra metrics extension
|
176
|
+
test_files:
|
177
|
+
- spec/abingo_spec.rb
|
178
|
+
- spec/kontagent_spec.rb
|
179
|
+
- spec/mixpanel_spec.rb
|
180
|
+
- spec/spec_helper.rb
|