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.
@@ -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