charted 0.0.8 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,59 @@
1
+ module Charted
2
+ class App < Sinatra::Base
3
+ set :logging, true
4
+ set :raise_errors, false
5
+ set :show_exceptions, false
6
+
7
+ before do
8
+ @site = Site.first(domain: request.host)
9
+ halt(404) if @site.nil?
10
+ @visitor = @site.visitor_with_cookie(request.cookies['charted'])
11
+ end
12
+
13
+ get '/' do
14
+ if @visitor.nil?
15
+ @visitor = @site.add_visitor(
16
+ resolution: params[:resolution],
17
+ user_agent: request.user_agent,
18
+ ip_address: request.ip,
19
+ bucket: params[:bucket])
20
+ response.set_cookie(
21
+ 'charted',
22
+ value: @visitor.cookie,
23
+ path: '/',
24
+ expires: (Date.today + 365*2).to_time)
25
+ end
26
+
27
+ begin
28
+ referrer = params[:referrer].to_s
29
+ referrer = nil if URI.parse(referrer).host == @site.domain || referrer =~ /^\s*$/
30
+ rescue URI::InvalidURIError
31
+ referrer = nil
32
+ end
33
+ @visitor.add_visit(
34
+ path: params[:path],
35
+ title: params[:title],
36
+ referrer: referrer)
37
+ @visitor.start_conversions(params[:conversions])
38
+ @visitor.start_experiments(params[:experiments])
39
+ '/**/'
40
+ end
41
+
42
+ get '/record' do
43
+ halt(404) if @visitor.nil?
44
+ @visitor.make_events(params[:events])
45
+ @visitor.end_goals(params[:goals])
46
+ '/**/'
47
+ end
48
+
49
+ error do
50
+ err = request.env['sinatra.error']
51
+ Pony.mail(
52
+ to: Charted.config.error_email,
53
+ subject: "[Charted Error] #{err.message}",
54
+ body: [request.env.to_s, err.message, err.backtrace].compact.flatten.join("\n")
55
+ ) if Charted.config.error_email
56
+ raise err
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,210 @@
1
+ module Charted
2
+ class Command
3
+ attr_accessor :config_loaded, :output
4
+ attr_reader :site
5
+
6
+ def clean(label=nil)
7
+ load_config
8
+ sys_exit("Please set 'delete_after' config.") if Charted.config.delete_after.nil?
9
+
10
+ threshold = Date.today - Charted.config.delete_after
11
+ Visit.where { created_at < threshold }.delete
12
+ Event.where { created_at < threshold }.delete
13
+ Conversion.where { created_at < threshold }.delete
14
+ Experiment.where { created_at < threshold }.delete
15
+ Visitor.where { created_at < threshold }.each do |visitor|
16
+ visitor.delete if visitor.visits.count == 0 &&
17
+ visitor.events.count == 0 &&
18
+ visitor.conversions.count == 0 &&
19
+ visitor.experiments.count == 0
20
+ end
21
+
22
+ if label
23
+ Event.where(label: label).delete
24
+ Conversion.where(label: label).delete
25
+ Experiment.where(label: label).delete
26
+ end
27
+ end
28
+
29
+ def dashboard
30
+ site_required
31
+ nodes = []
32
+ max_width = [`tput cols`.to_i / 2, 60].min
33
+ table = Dashes::Table.new.
34
+ align(:right, :right, :left).
35
+ row('Total', 'Unique', 'Visits').
36
+ separator
37
+ (0..11).each do |delta|
38
+ date = Charted.prev_month(Date.today, delta)
39
+ query = Charted::Visit.
40
+ join(:visitors, id: :visitor_id).
41
+ where(visitors__site_id: @site.id).
42
+ where('visits.created_at >= ? AND visits.created_at < ?', date, Charted.next_month(date))
43
+ visits = query.count
44
+ unique = query.select(:visitor_id).distinct.count
45
+ table.row(format(visits), format(unique), date.strftime('%B %Y'))
46
+ end
47
+ nodes += [table]
48
+ [[:browser, 'Browsers', Charted::Visitor],
49
+ [:resolution, 'Resolutions', Charted::Visitor],
50
+ [:platform, 'Platforms', Charted::Visitor],
51
+ [:country, 'Countries', Charted::Visitor],
52
+ [:title, 'Pages', Charted::Visit],
53
+ [:referrer, 'Referrers', Charted::Visit],
54
+ [:search_terms, 'Searches', Charted::Visit]].each do |field, column, type|
55
+ table = Dashes::Table.new.
56
+ max_width(max_width).
57
+ spacing(:min, :min, :max).
58
+ align(:right, :right, :left).
59
+ row('Total', '%', column).separator
60
+ rows = []
61
+ query = type.exclude(field => nil)
62
+ query = query.join(:visitors, id: :visitor_id) if type == Charted::Visit
63
+ query = query.where(visitors__site_id: @site.id)
64
+ total = query.count
65
+ query.group_and_count(field).each do |row|
66
+ count = row[:count]
67
+ label = row[:label].to_s.strip
68
+ next if label == ""
69
+ label = "#{label[0..37]}..." if label.length > 40
70
+ rows << [format(count), "#{((count / total.to_f) * 100).round}%", label]
71
+ end
72
+ add_truncated(table, rows)
73
+ nodes << table
74
+ end
75
+ table = Dashes::Table.new.
76
+ max_width(max_width).
77
+ spacing(:min, :min, :max).
78
+ align(:right, :right, :left).
79
+ row('Total', 'Unique', 'Events').
80
+ separator
81
+ rows = []
82
+ events = Charted::Event.join(:visitors, id: :visitor_id).where(visitors__site_id: @site.id)
83
+ events.group_and_count(:label).all.each do |row|
84
+ label, count = row[:label], row[:count]
85
+ unique = Charted::Visitor.
86
+ join(:events, visitor_id: :id).
87
+ where(site_id: @site.id, events__label: label).
88
+ select(:visitors__id).distinct.count
89
+ rows << [format(count), format(unique), label]
90
+ end
91
+ add_truncated(table, rows)
92
+ nodes << table
93
+
94
+ table = Dashes::Table.new.
95
+ max_width(max_width).
96
+ spacing(:min, :min, :max).
97
+ align(:right, :right, :left).
98
+ row('Start', 'End', 'Conversions').
99
+ separator
100
+ rows = []
101
+ conversions = Charted::Conversion.join(:visitors, id: :visitor_id).where(visitors__site_id: @site.id)
102
+ conversions.group_and_count(:label).all.each do |row|
103
+ label, count = row[:label], row[:count]
104
+ ended = Charted::Visitor.
105
+ join(:conversions, visitor_id: :id).
106
+ where(site_id: @site.id, conversions__label: label).
107
+ exclude(conversions__ended_at: nil).
108
+ select(:visitors__id).distinct.count
109
+ rows << [format(count), format(ended), label]
110
+ end
111
+ add_truncated(table, rows)
112
+ nodes << table
113
+
114
+ table = Dashes::Table.new.
115
+ max_width(max_width).
116
+ spacing(:min, :min, :max).
117
+ align(:right, :right, :left).
118
+ row('Start', 'End', 'Experiments').
119
+ separator
120
+ rows = []
121
+ experiments = Charted::Experiment.join(:visitors, id: :visitor_id).where(visitors__site_id: @site.id)
122
+ experiments.group_and_count(:label, :experiments__bucket).all.each do |row|
123
+ label, bucket, count = row[:label], row[:bucket], row[:count]
124
+ ended = Charted::Visitor.
125
+ join(:experiments, visitor_id: :id).
126
+ where(site_id: @site.id, experiments__label: label, experiments__bucket: bucket).
127
+ exclude(experiments__ended_at: nil).
128
+ select(:visitors__id).distinct.count
129
+ rows << [format(count), format(ended), "#{label}: #{bucket}"]
130
+ end
131
+ add_truncated(table, rows)
132
+ nodes << table
133
+
134
+ nodes.reject! do |node|
135
+ minimum = node.is_a?(Dashes::Table) ? 1 : 0
136
+ node.instance_variable_get(:@rows).size == minimum # TODO: hacked
137
+ end
138
+ print(Dashes::Grid.new.width(`tput cols`.to_i).add(*nodes))
139
+ end
140
+
141
+ def js
142
+ print(File.read(JS_FILE))
143
+ end
144
+
145
+ def migrate
146
+ load_config
147
+ Charted::Migrate.run
148
+ Charted.config.sites.each do |domain|
149
+ if Site.first(domain: domain).nil?
150
+ Site.create(domain: domain)
151
+ end
152
+ end
153
+ end
154
+
155
+ def site=(domain)
156
+ load_config
157
+ sites = Site.where(Sequel.like(:domain, "%#{domain}%")).all
158
+
159
+ if sites.length > 1
160
+ sys_exit("\"#{domain}\" ambiguous: #{sites.map(&:domain).join(', ')}")
161
+ elsif sites.length < 1
162
+ sys_exit("No sites matching \"#{domain}\"")
163
+ else
164
+ @site = sites.first
165
+ end
166
+ end
167
+
168
+ private
169
+ def load_config
170
+ return if @config_loaded
171
+ file = ENV['CHARTED_CONFIG']
172
+ sys_exit("Please set CHARTED_CONFIG to `config.ru` file.") if !File.exist?(file.to_s)
173
+ load(file)
174
+ @config_loaded = true
175
+ end
176
+
177
+ def sys_exit(reason)
178
+ print(reason)
179
+ ENV['RACK_ENV'] == 'test' ? raise(ExitError.new(reason)) : exit
180
+ end
181
+
182
+ def print(string)
183
+ ENV['RACK_ENV'] == 'test' ? (@output ||= []) << string : puts(string)
184
+ end
185
+
186
+ def site_required
187
+ load_config
188
+ if @site.nil? && Site.count == 1
189
+ @site = Site.first
190
+ elsif @site.nil?
191
+ sys_exit('Please specify website with --site')
192
+ end
193
+ end
194
+
195
+ def format(num)
196
+ num.to_s.reverse.gsub(/...(?=.)/,'\&,').reverse
197
+ end
198
+
199
+ def add_truncated(table, rows)
200
+ rows = rows.sort_by { |r| r[0].gsub(/[^\d]/, '').to_i }.reverse
201
+ if rows.length > 12
202
+ rows = rows[0..11]
203
+ rows << ['...', '...', '...']
204
+ end
205
+ rows.each { |row| table.row(*row) }
206
+ end
207
+ end
208
+
209
+ class ExitError < RuntimeError; end
210
+ end
@@ -0,0 +1,30 @@
1
+ module Charted
2
+ class << self
3
+ attr_accessor :database
4
+
5
+ def configure
6
+ yield self.config
7
+ Pony.options = config.email_options if !config.email_options.nil?
8
+ Charted.database = Sequel.connect(config.db_options)
9
+ require_relative 'model'
10
+ end
11
+
12
+ def config
13
+ @config ||= Config.new
14
+ end
15
+ end
16
+
17
+ class Config
18
+ def self.attr_option(*names)
19
+ names.each do |name|
20
+ define_method(name) do |*args|
21
+ value = args[0]
22
+ instance_variable_set("@#{name}".to_sym, value) if !value.nil?
23
+ instance_variable_get("@#{name}".to_sym)
24
+ end
25
+ end
26
+ end
27
+
28
+ attr_option :error_email, :email_options, :db_options, :delete_after, :sites
29
+ end
30
+ end
@@ -0,0 +1,161 @@
1
+ module Charted
2
+ class Migrate
3
+ def self.run
4
+ Sequel.extension :migration
5
+ Sequel::Migrator.run(Charted.database, File.join(File.dirname(__FILE__), '..', '..', 'migrate'))
6
+ # re-parse the schema after table changes
7
+ [Site, Visitor, Visit, Event, Conversion, Experiment].each do |table|
8
+ table.dataset = table.dataset
9
+ end
10
+ end
11
+ end
12
+
13
+ module HasVisitor
14
+ def site
15
+ self.visitor.site
16
+ end
17
+ end
18
+
19
+ module Endable
20
+ def ended?
21
+ !!ended_at
22
+ end
23
+
24
+ def end!
25
+ self.ended_at = DateTime.now
26
+ self.save
27
+ end
28
+ end
29
+
30
+ class Site < Sequel::Model
31
+ one_to_many :visitors
32
+
33
+ def initialize(*args)
34
+ super
35
+ self.created_at ||= DateTime.now
36
+ end
37
+
38
+ def visitor_with_cookie(cookie)
39
+ visitor = self.visitors_dataset[cookie.to_s.split('-').first.to_i]
40
+ visitor && visitor.cookie == cookie ? visitor : nil
41
+ end
42
+ end
43
+
44
+ class Visitor < Sequel::Model
45
+ many_to_one :site
46
+ one_to_many :visits
47
+ one_to_many :events
48
+ one_to_many :conversions
49
+ one_to_many :experiments
50
+
51
+ def initialize(*args)
52
+ super
53
+ self.created_at ||= DateTime.now
54
+ self.bucket ||= rand(10)
55
+ self.secret = SecureRandom.hex(3)
56
+ end
57
+
58
+ def cookie
59
+ # TODO: raise if nil id, bucket, or secret
60
+ "#{self.id}-#{self.bucket}-#{self.secret}"
61
+ end
62
+
63
+ def user_agent=(user_agent)
64
+ ua = UserAgent.parse(user_agent)
65
+ self.browser = ua.browser
66
+ self.browser_version = ua.version
67
+ self.platform = ua.platform == 'X11' ? 'Linux' : ua.platform
68
+ end
69
+
70
+ def ip_address=(ip)
71
+ return if ip.to_s =~ /^\s*$/ || ip == '127.0.0.1'
72
+ name = GEOIP.country(ip).country_name
73
+
74
+ return if name =~ /^\s*$/ || name == 'N/A'
75
+ self.country = name
76
+ rescue SocketError
77
+ # invalid IP address, skip setting country
78
+ end
79
+
80
+ def make_events(labels)
81
+ labels.to_s.split(';').map(&:strip).map do |label|
82
+ add_event(label: label)
83
+ end
84
+ end
85
+
86
+ def start_conversions(labels)
87
+ labels.to_s.split(';').map(&:strip).map do |label|
88
+ conversions_dataset.first(label: label) || self.add_conversion(label: label)
89
+ end
90
+ end
91
+
92
+ def start_experiments(labels) # label:bucket;...
93
+ labels.to_s.split(';').map do |str|
94
+ label, bucket = str.split(':', 2).map(&:strip)
95
+ exp = experiments_dataset.first(label: label)
96
+ if exp
97
+ exp.update(bucket: bucket) if exp.bucket != bucket
98
+ exp
99
+ else
100
+ self.add_experiment(label: label, bucket: bucket)
101
+ end
102
+ end
103
+ end
104
+
105
+ def end_goals(labels)
106
+ labels.to_s.split(';').map(&:strip).each do |label|
107
+ exp = experiments_dataset.first(label: label)
108
+ exp.end! if exp
109
+ conv = conversions_dataset.first(label: label)
110
+ conv.end! if conv
111
+ end
112
+ end
113
+ end
114
+
115
+ class Visit < Sequel::Model
116
+ include HasVisitor
117
+ many_to_one :visitor
118
+
119
+ def initialize(*args)
120
+ super
121
+ self.created_at ||= DateTime.now
122
+ end
123
+
124
+ def before_save
125
+ self.search_terms = URI.parse(self.referrer).search_string if self.referrer.to_s !~ /^\s*$/
126
+ super
127
+ end
128
+ end
129
+
130
+ class Event < Sequel::Model
131
+ include HasVisitor
132
+ many_to_one :visitor
133
+
134
+ def initialize(*args)
135
+ super
136
+ self.created_at ||= DateTime.now
137
+ end
138
+ end
139
+
140
+ class Conversion < Sequel::Model
141
+ include HasVisitor
142
+ include Endable
143
+ many_to_one :visitor
144
+
145
+ def initialize(*args)
146
+ super
147
+ self.created_at ||= DateTime.now
148
+ end
149
+ end
150
+
151
+ class Experiment < Sequel::Model
152
+ include HasVisitor
153
+ include Endable
154
+ many_to_one :visitor
155
+
156
+ def initialize(*args)
157
+ super
158
+ self.created_at ||= DateTime.now
159
+ end
160
+ end
161
+ end