stefl-sinmetrics 0.0.9

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,2 @@
1
+ coverage
2
+ *.gem
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
@@ -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
@@ -0,0 +1,12 @@
1
+ begin
2
+ require 'sinatra/base'
3
+ rescue LoadError
4
+ retry if require 'rubygems'
5
+ raise
6
+ end
7
+
8
+ $LOAD_PATH.unshift(File.dirname(__FILE__) + '/sinmetrics')
9
+
10
+ require 'abingo'
11
+ require 'kontagent'
12
+ require 'mixpanel'
@@ -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
@@ -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
+
@@ -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