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.
- checksums.yaml +7 -0
- data/README.md +31 -26
- data/{charted → bin/charted} +1 -2
- data/config.ru +10 -8
- data/lib/charted.rb +12 -483
- data/lib/charted/app.rb +59 -0
- data/lib/charted/command.rb +210 -0
- data/lib/charted/config.rb +30 -0
- data/lib/charted/model.rb +161 -0
- data/lib/charted/version.rb +3 -0
- data/migrate/001_init_schema.rb +56 -0
- data/public/charted/script.js +96 -0
- data/test/app_test.rb +160 -0
- data/test/command_test.rb +70 -0
- data/test/config_test.rb +11 -0
- data/test/fixtures.rb +1 -1
- data/test/helper.rb +43 -0
- data/test/model_test.rb +101 -0
- metadata +86 -65
- data/test/charted_test.rb +0 -389
data/lib/charted/app.rb
ADDED
@@ -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
|