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
|