charted 0.0.1
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.
- data/LICENSE.md +23 -0
- data/README.md +112 -0
- data/charted +20 -0
- data/config.ru +15 -0
- data/geoip.dat +0 -0
- data/lib/charted.rb +327 -0
- data/test/charted_test.rb +253 -0
- data/test/fixtures.rb +59 -0
- metadata +199 -0
data/LICENSE.md
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
Copyright (c) 2012, Hugh Bien
|
2
|
+
All rights reserved.
|
3
|
+
|
4
|
+
Redistribution and use in source and binary forms, with or without modification,
|
5
|
+
are permitted provided that the following conditions are met:
|
6
|
+
|
7
|
+
Redistributions of source code must retain the above copyright notice, this list
|
8
|
+
of conditions and the following disclaimer.
|
9
|
+
|
10
|
+
Redistributions in binary form must reproduce the above copyright notice, this
|
11
|
+
list of conditions and the following disclaimer in the documentation and/or
|
12
|
+
other materials provided with the distribution.
|
13
|
+
|
14
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
15
|
+
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
16
|
+
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
17
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
18
|
+
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
19
|
+
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
20
|
+
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
21
|
+
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
22
|
+
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
23
|
+
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
data/README.md
ADDED
@@ -0,0 +1,112 @@
|
|
1
|
+
Description
|
2
|
+
===========
|
3
|
+
|
4
|
+
Charted is a minimal web traffic analytics app. Still under development!
|
5
|
+
|
6
|
+
Installation
|
7
|
+
============
|
8
|
+
|
9
|
+
$ gem install charted
|
10
|
+
|
11
|
+
Setup a `config.ru` file and run it like any other Sinatra application.
|
12
|
+
|
13
|
+
require 'rubygems'
|
14
|
+
require 'charted'
|
15
|
+
|
16
|
+
Charted.configure do |c|
|
17
|
+
c.email 'john@mailinator.com' # production exceptions are sent here
|
18
|
+
c.delete_after 365 # only keep a years worth of data
|
19
|
+
c.db_adapter 'mysql'
|
20
|
+
c.db_host 'localhost'
|
21
|
+
c.db_username 'root'
|
22
|
+
c.db_password 'secret'
|
23
|
+
c.db_database 'charted'
|
24
|
+
c.sites ['hughbien.com', 'example.com']
|
25
|
+
end
|
26
|
+
|
27
|
+
run Charted::App if !ENV['CHARTED_CMD']
|
28
|
+
|
29
|
+
Stick this in your `bashrc` or `zshrc`:
|
30
|
+
|
31
|
+
CHARTED_CONFIG='/path/to/config.ru'
|
32
|
+
|
33
|
+
Then initialize the database:
|
34
|
+
|
35
|
+
$ charted --migrate
|
36
|
+
|
37
|
+
The app should be mounted to `/charted` path on your domain. Then in your app,
|
38
|
+
include the script right before the closing `</body>` tag:
|
39
|
+
|
40
|
+
<script src="/charted/script.js" async></script>
|
41
|
+
|
42
|
+
If you concatenate your JavaScript, you can generate the `script.js` file and
|
43
|
+
add it to your project. The downside being when you update the charted gem,
|
44
|
+
you'll also have to remember to update the JavaScript:
|
45
|
+
|
46
|
+
$ charted --js > /path/to/my/project/public/charted.js
|
47
|
+
|
48
|
+
Updating
|
49
|
+
========
|
50
|
+
|
51
|
+
$ gem install charted
|
52
|
+
|
53
|
+
Usage
|
54
|
+
=====
|
55
|
+
|
56
|
+
The web application is for end users, to get information about your traffic use
|
57
|
+
the included command line application.
|
58
|
+
|
59
|
+
$ charted --help
|
60
|
+
$ charted --dashboard --site hugh # just needs the first few letters
|
61
|
+
+-------+--------+--------------------------------------+
|
62
|
+
| Total | Unique | Visits |
|
63
|
+
+-------+--------+--------------------------------------+
|
64
|
+
| 7,012 | 5,919 | February 2013 |
|
65
|
+
| 6,505 | 4,722 | January 2013 |
|
66
|
+
| 5,342 | 3,988 | December 2012 |
|
67
|
+
...
|
68
|
+
|
69
|
+
Development
|
70
|
+
===========
|
71
|
+
|
72
|
+
Put this in your `zshrc` or `bashrc`:
|
73
|
+
|
74
|
+
export CHARTED_CONFIG="/path/to/charted/config.ru"
|
75
|
+
|
76
|
+
Then run:
|
77
|
+
|
78
|
+
$ ./charted --migrate
|
79
|
+
$ shotgun
|
80
|
+
|
81
|
+
Head on over to `http://localhost:9393/charted/prime.html`. This is where
|
82
|
+
recordings should occur.
|
83
|
+
|
84
|
+
Tests are setup to run via `ruby test/*_test.rb` or via `rake`.
|
85
|
+
|
86
|
+
TODO
|
87
|
+
====
|
88
|
+
|
89
|
+
* clean up `--dashboard` code
|
90
|
+
* track RSS subscribers
|
91
|
+
* add event tracking
|
92
|
+
* add funnel conversion tracking
|
93
|
+
* add AB testing
|
94
|
+
* add stats summary for AB tests
|
95
|
+
* add deletion via CLI
|
96
|
+
* add deletion every N days for fresh data
|
97
|
+
* add `--js` option
|
98
|
+
* add plugin system to CLI and recordings
|
99
|
+
* add config for dashboards (what to show/hide, how to order)
|
100
|
+
* ignore same domain referrers
|
101
|
+
* optimize with If-Not-Modified (or use ?timestamp parameter)
|
102
|
+
* ignore switch for developers
|
103
|
+
* deploy task in Rakefile for development
|
104
|
+
* add `--sync` option
|
105
|
+
|
106
|
+
License
|
107
|
+
=======
|
108
|
+
|
109
|
+
`geoip.dat` is provided by MaxMind at <http://dev.maxmind.com/geoip/geolite>.
|
110
|
+
|
111
|
+
Copyright Hugh Bien - http://hughbien.com.
|
112
|
+
Released under BSD License, see LICENSE.md for more info.
|
data/charted
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
require 'optparse'
|
3
|
+
require File.expand_path('lib/charted', File.dirname(__FILE__))
|
4
|
+
require 'dm-migrations'
|
5
|
+
require 'date'
|
6
|
+
require 'fileutils'
|
7
|
+
|
8
|
+
ENV['CHARTED_CMD'] = '1'
|
9
|
+
|
10
|
+
ARGV.options do |o|
|
11
|
+
cmd, action = Charted::Command.new, nil
|
12
|
+
o.set_summary_indent(' ')
|
13
|
+
o.banner = "Usage: #{File.basename($0)} [OPTION]"
|
14
|
+
o.on('-d', '--dashboard', 'show dashboard') { action = :dashboard }
|
15
|
+
o.on('-h', '--help', 'show this help message') { puts o; exit }
|
16
|
+
o.on('-m', '--migrate', 'migrates database') { cmd.migrate; exit }
|
17
|
+
o.on('-s', '--site domain', 'set site') { |site| cmd.site = site }
|
18
|
+
o.parse!
|
19
|
+
action.nil? ? puts(o) : cmd.send(action)
|
20
|
+
end
|
data/config.ru
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
require File.expand_path('lib/charted', File.dirname(__FILE__))
|
2
|
+
|
3
|
+
Charted.configure(ENV['RACK_ENV'] != 'test') do |c|
|
4
|
+
c.email 'dev@localhost'
|
5
|
+
c.db_adapter 'sqlite3'
|
6
|
+
c.db_host 'localhost'
|
7
|
+
c.db_username 'root'
|
8
|
+
c.db_password 'secret'
|
9
|
+
c.db_database 'db.sqlite3'
|
10
|
+
c.sites ['localhost', 'example.org']
|
11
|
+
end
|
12
|
+
|
13
|
+
if !ENV['CHARTED_CMD']
|
14
|
+
map('/charted') { run Charted::App }
|
15
|
+
end
|
data/geoip.dat
ADDED
Binary file
|
data/lib/charted.rb
ADDED
@@ -0,0 +1,327 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'sinatra/base'
|
3
|
+
require 'dm-core'
|
4
|
+
require 'dm-types'
|
5
|
+
require 'dm-timestamps'
|
6
|
+
require 'dm-validations'
|
7
|
+
require 'dm-aggregates'
|
8
|
+
require 'date'
|
9
|
+
require 'digest/sha1'
|
10
|
+
require 'json'
|
11
|
+
require 'uri'
|
12
|
+
require 'geoip'
|
13
|
+
require 'pony'
|
14
|
+
require 'useragent'
|
15
|
+
require 'search_terms'
|
16
|
+
require 'terminal-table'
|
17
|
+
require 'colorize'
|
18
|
+
require 'dashes'
|
19
|
+
|
20
|
+
DataMapper::Model.raise_on_save_failure = true
|
21
|
+
|
22
|
+
module Charted
|
23
|
+
VERSION = '0.0.1'
|
24
|
+
GEOIP = GeoIP.new("#{File.dirname(__FILE__)}/../geoip.dat")
|
25
|
+
|
26
|
+
def self.configure(setup_db=true)
|
27
|
+
yield self.config
|
28
|
+
DataMapper.setup(:default,
|
29
|
+
:adapter => config.db_adapter,
|
30
|
+
:host => config.db_host,
|
31
|
+
:username => config.db_username,
|
32
|
+
:password => config.db_password,
|
33
|
+
:database => config.db_database
|
34
|
+
) if setup_db
|
35
|
+
end
|
36
|
+
|
37
|
+
def self.config
|
38
|
+
@config ||= Config.new
|
39
|
+
end
|
40
|
+
|
41
|
+
def self.prev_month(date, delta=1)
|
42
|
+
date = Date.new(date.year, date.month, 1)
|
43
|
+
delta.times { date = Date.new((date - 1).year, (date - 1).month, 1) }
|
44
|
+
date
|
45
|
+
end
|
46
|
+
|
47
|
+
def self.next_month(date, delta=1)
|
48
|
+
date = Date.new(date.year, date.month, 1)
|
49
|
+
delta.times { date = Date.new((date + 32).year, (date + 32).month, 1) }
|
50
|
+
date
|
51
|
+
end
|
52
|
+
|
53
|
+
class Config
|
54
|
+
def self.attr_option(*names)
|
55
|
+
names.each do |name|
|
56
|
+
define_method(name) do |*args|
|
57
|
+
value = args[0]
|
58
|
+
instance_variable_set("@#{name}".to_sym, value) if !value.nil?
|
59
|
+
instance_variable_get("@#{name}".to_sym)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
attr_option :email, :delete_after, :sites,
|
65
|
+
:db_adapter, :db_host, :db_username, :db_password, :db_database
|
66
|
+
end
|
67
|
+
|
68
|
+
class Site
|
69
|
+
include DataMapper::Resource
|
70
|
+
|
71
|
+
property :id, Serial
|
72
|
+
property :domain, String, :required => true, :unique => true
|
73
|
+
property :created_at, DateTime
|
74
|
+
|
75
|
+
has n, :visitors
|
76
|
+
has n, :visits, :through => :visitors
|
77
|
+
end
|
78
|
+
|
79
|
+
class Visitor
|
80
|
+
include DataMapper::Resource
|
81
|
+
|
82
|
+
property :id, Serial
|
83
|
+
property :secret, String, :required => true
|
84
|
+
property :resolution, String
|
85
|
+
property :created_at, DateTime
|
86
|
+
property :platform, String
|
87
|
+
property :browser, String
|
88
|
+
property :browser_version, String
|
89
|
+
property :country, String
|
90
|
+
|
91
|
+
belongs_to :site
|
92
|
+
has n, :visits
|
93
|
+
|
94
|
+
validates_presence_of :site
|
95
|
+
|
96
|
+
def initialize(*args)
|
97
|
+
super
|
98
|
+
self.secret = self.class.generate_secret
|
99
|
+
end
|
100
|
+
|
101
|
+
def cookie
|
102
|
+
"#{self.id}-#{self.secret}"
|
103
|
+
end
|
104
|
+
|
105
|
+
def user_agent=(user_agent)
|
106
|
+
ua = UserAgent.parse(user_agent)
|
107
|
+
self.browser = ua.browser
|
108
|
+
self.browser_version = ua.version
|
109
|
+
self.platform = ua.platform == 'X11' ? 'Linux' : ua.platform
|
110
|
+
end
|
111
|
+
|
112
|
+
def ip_address=(ip)
|
113
|
+
return if ip.to_s =~ /^\s*$/ || ip == '127.0.0.1'
|
114
|
+
name = GEOIP.country(ip).country_name
|
115
|
+
|
116
|
+
return if name =~ /^\s*$/ || name == 'N/A'
|
117
|
+
self.country = name
|
118
|
+
rescue SocketError
|
119
|
+
# invalid IP address, skip setting country
|
120
|
+
end
|
121
|
+
|
122
|
+
def self.get_by_cookie(site, cookie)
|
123
|
+
visitor = Visitor.get(cookie.to_s.split('-').first)
|
124
|
+
visitor && visitor.site == site && visitor.cookie == cookie ?
|
125
|
+
visitor :
|
126
|
+
nil
|
127
|
+
end
|
128
|
+
|
129
|
+
def self.generate_secret
|
130
|
+
Digest::SHA1.hexdigest("#{Time.now}-#{rand(100)}")[0..4]
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
class Visit
|
135
|
+
include DataMapper::Resource
|
136
|
+
|
137
|
+
property :id, Serial
|
138
|
+
property :path, String, :required => true
|
139
|
+
property :title, String, :required => true
|
140
|
+
property :referrer, String
|
141
|
+
property :search_terms, String
|
142
|
+
property :created_at, DateTime
|
143
|
+
|
144
|
+
belongs_to :visitor
|
145
|
+
has 1, :site, :through => :visitor
|
146
|
+
|
147
|
+
validates_presence_of :visitor
|
148
|
+
|
149
|
+
before :save, :set_search_terms
|
150
|
+
|
151
|
+
def set_search_terms
|
152
|
+
return if self.referrer.to_s =~ /^\s*$/
|
153
|
+
self.search_terms = URI.parse(self.referrer).search_string
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
DataMapper.finalize
|
158
|
+
|
159
|
+
class App < Sinatra::Base
|
160
|
+
set :logging, true
|
161
|
+
|
162
|
+
get '/' do
|
163
|
+
site = Site.first(:domain => request.host)
|
164
|
+
halt(404) if site.nil?
|
165
|
+
|
166
|
+
if request.cookies['charted']
|
167
|
+
visitor = Visitor.get_by_cookie(site, request.cookies['charted'])
|
168
|
+
end
|
169
|
+
|
170
|
+
if visitor.nil?
|
171
|
+
visitor = Visitor.create(
|
172
|
+
:site => site,
|
173
|
+
:resolution => params[:resolution],
|
174
|
+
:user_agent => request.user_agent,
|
175
|
+
:ip_address => request.ip)
|
176
|
+
response.set_cookie(
|
177
|
+
'charted',
|
178
|
+
:value => visitor.cookie,
|
179
|
+
:expires => (Date.today + 365*2).to_time)
|
180
|
+
end
|
181
|
+
|
182
|
+
visit = Visit.create(
|
183
|
+
:visitor => visitor,
|
184
|
+
:path => params[:path],
|
185
|
+
:title => params[:title],
|
186
|
+
:referrer => params[:referrer])
|
187
|
+
'/**/'
|
188
|
+
end
|
189
|
+
|
190
|
+
error do
|
191
|
+
Pony.mail(
|
192
|
+
:to => Charted.config.email,
|
193
|
+
:from => "charted@#{Charted.config.email.split('@')[1..-1].join}",
|
194
|
+
:subject => 'Charted Error',
|
195
|
+
:body => request.env['sinatra.error'].to_s
|
196
|
+
) if Charted.config.email && self.class.environment == :production
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
class Command
|
201
|
+
attr_accessor :config_loaded, :output
|
202
|
+
attr_reader :site
|
203
|
+
|
204
|
+
def dashboard
|
205
|
+
site_required
|
206
|
+
tables = []
|
207
|
+
chart = Dashes::Chart.new
|
208
|
+
chart2 = Dashes::Chart.new
|
209
|
+
max_width = [`tput cols`.to_i / 2, 60].min
|
210
|
+
chart.max_width(max_width)
|
211
|
+
chart2.max_width(max_width)
|
212
|
+
chart.title "Total Visits".colorize(:light_green)
|
213
|
+
chart2.title "Unique Visits".colorize(:light_green)
|
214
|
+
table = Dashes::Table.new
|
215
|
+
table.spacing :min, :min, :max
|
216
|
+
table.row('Total'.colorize(:light_blue),
|
217
|
+
'Unique'.colorize(:light_blue),
|
218
|
+
'Visits'.colorize(:light_green))
|
219
|
+
table.separator
|
220
|
+
table.max_width(max_width)
|
221
|
+
(0..11).each do |delta|
|
222
|
+
date = Charted.prev_month(Date.today, delta)
|
223
|
+
visits = @site.visits.count(
|
224
|
+
:created_at.gte => date,
|
225
|
+
:created_at.lt => Charted.next_month(date))
|
226
|
+
unique = @site.visitors.count(:visits => {
|
227
|
+
:created_at.gte => date,
|
228
|
+
:created_at.lt => Charted.next_month(date)})
|
229
|
+
table.row(format(visits), format(unique), date.strftime('%B %Y'))
|
230
|
+
table.align :right, :right, :left
|
231
|
+
chart.row date.strftime('%b %Y'), visits
|
232
|
+
chart2.row date.strftime('%b %Y'), unique
|
233
|
+
end
|
234
|
+
tables << table
|
235
|
+
tables << chart
|
236
|
+
tables << chart2
|
237
|
+
[[:browser, 'Browsers', :visitors],
|
238
|
+
[:resolution, 'Resolutions', :visitors],
|
239
|
+
[:platform, 'Platforms', :visitors],
|
240
|
+
[:country, 'Countries', :visitors],
|
241
|
+
[:title, 'Pages', :visits],
|
242
|
+
[:referrer, 'Referrers', :visits],
|
243
|
+
[:search_terms, 'Searches', :visits]].each do |field, column, type|
|
244
|
+
table = Dashes::Table.new
|
245
|
+
table.max_width(max_width)
|
246
|
+
table.spacing :min, :min, :max
|
247
|
+
table.row('Total'.colorize(:light_blue),
|
248
|
+
'%'.colorize(:light_blue),
|
249
|
+
column.colorize(:light_green))
|
250
|
+
table.separator
|
251
|
+
rows = []
|
252
|
+
total = @site.send(type).count(field.not => nil)
|
253
|
+
@site.send(type).aggregate(field, :all.count).each do |label, count|
|
254
|
+
label = label.to_s.strip
|
255
|
+
next if label == ""
|
256
|
+
label = "#{label[0..37]}..." if label.length > 40
|
257
|
+
rows << [format(count), "#{((count / total.to_f) * 100).round}%", label]
|
258
|
+
end
|
259
|
+
rows.sort_by { |r| r[1] }.reverse.each { |row| table.row(*row) }
|
260
|
+
table.align :right, :right, :left
|
261
|
+
tables << table
|
262
|
+
end
|
263
|
+
|
264
|
+
grid = Dashes::Grid.new
|
265
|
+
grid.width(`tput cols`.to_i)
|
266
|
+
tables.each { |t| grid.add(t) }
|
267
|
+
print(grid)
|
268
|
+
end
|
269
|
+
|
270
|
+
def migrate
|
271
|
+
load_config
|
272
|
+
DataMapper.auto_upgrade!
|
273
|
+
Charted.config.sites.each do |domain|
|
274
|
+
if Site.first(:domain => domain).nil?
|
275
|
+
Site.create(:domain => domain)
|
276
|
+
end
|
277
|
+
end
|
278
|
+
end
|
279
|
+
|
280
|
+
def site=(domain)
|
281
|
+
load_config
|
282
|
+
sites = Site.all(:domain.like => "%#{domain}%")
|
283
|
+
|
284
|
+
if sites.length > 1
|
285
|
+
sys_exit("\"#{domain}\" ambiguous: #{sites.map(&:domain).join(', ')}")
|
286
|
+
elsif sites.length < 1
|
287
|
+
sys_exit("No sites matching \"#{domain}\"")
|
288
|
+
else
|
289
|
+
@site = sites.first
|
290
|
+
end
|
291
|
+
end
|
292
|
+
|
293
|
+
private
|
294
|
+
def load_config
|
295
|
+
return if @config_loaded
|
296
|
+
file = ENV['CHARTED_CONFIG']
|
297
|
+
load(file)
|
298
|
+
@config_loaded = true
|
299
|
+
rescue LoadError
|
300
|
+
sys_exit("CHARTED_CONFIG not set, please set to `config.ru` file.")
|
301
|
+
end
|
302
|
+
|
303
|
+
def sys_exit(reason)
|
304
|
+
print(reason)
|
305
|
+
ENV['RACK_ENV'] == 'test' ? raise(ExitError.new) : exit
|
306
|
+
end
|
307
|
+
|
308
|
+
def print(string)
|
309
|
+
ENV['RACK_ENV'] == 'test' ? (@output ||= []) << string : puts(string)
|
310
|
+
end
|
311
|
+
|
312
|
+
def site_required
|
313
|
+
load_config
|
314
|
+
if @site.nil? && Site.count == 1
|
315
|
+
@site = Site.first
|
316
|
+
elsif @site.nil?
|
317
|
+
sys_exit('Please specify website with --site')
|
318
|
+
end
|
319
|
+
end
|
320
|
+
|
321
|
+
def format(num)
|
322
|
+
num.to_s.reverse.gsub(/...(?=.)/,'\&,').reverse
|
323
|
+
end
|
324
|
+
end
|
325
|
+
|
326
|
+
class ExitError < RuntimeError; end
|
327
|
+
end
|
@@ -0,0 +1,253 @@
|
|
1
|
+
ENV['RACK_ENV'] = 'test'
|
2
|
+
|
3
|
+
require File.expand_path('../lib/charted', File.dirname(__FILE__))
|
4
|
+
require 'dm-migrations'
|
5
|
+
require 'minitest/autorun'
|
6
|
+
require 'rack'
|
7
|
+
require 'rack/test'
|
8
|
+
require 'rack/server'
|
9
|
+
require 'fileutils'
|
10
|
+
|
11
|
+
DataMapper.setup(:default, 'sqlite::memory:')
|
12
|
+
DataMapper.auto_migrate!
|
13
|
+
|
14
|
+
module Pony
|
15
|
+
def self.mail(fields)
|
16
|
+
@last_mail = fields
|
17
|
+
end
|
18
|
+
|
19
|
+
def self.last_mail
|
20
|
+
@last_mail
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
class ChartedTest < MiniTest::Unit::TestCase
|
25
|
+
def setup
|
26
|
+
Charted.configure(false) do |c|
|
27
|
+
c.email 'dev@localhost'
|
28
|
+
c.db_adapter 'sqlite3'
|
29
|
+
c.db_host 'localhost'
|
30
|
+
c.db_username 'root'
|
31
|
+
c.db_password 'secret'
|
32
|
+
c.db_database 'test.sqlite3'
|
33
|
+
c.sites ['localhost']
|
34
|
+
end
|
35
|
+
Pony.mail(nil)
|
36
|
+
end
|
37
|
+
|
38
|
+
def teardown
|
39
|
+
FileUtils.rm_rf(File.expand_path('../temp', File.dirname(__FILE__)))
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
class ConfigTest < ChartedTest
|
44
|
+
def test_db
|
45
|
+
assert_equal('dev@localhost', Charted.config.email)
|
46
|
+
assert_equal('sqlite3', Charted.config.db_adapter)
|
47
|
+
assert_equal('localhost', Charted.config.db_host)
|
48
|
+
assert_equal('root', Charted.config.db_username)
|
49
|
+
assert_equal('secret', Charted.config.db_password)
|
50
|
+
assert_equal('test.sqlite3', Charted.config.db_database)
|
51
|
+
assert_equal(['localhost'], Charted.config.sites)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
class ModelTest < ChartedTest
|
56
|
+
def setup
|
57
|
+
Charted::Site.destroy
|
58
|
+
Charted::Visitor.destroy
|
59
|
+
Charted::Visit.destroy
|
60
|
+
end
|
61
|
+
|
62
|
+
def test_create
|
63
|
+
site = Charted::Site.create(:domain => 'localhost')
|
64
|
+
visitor = Charted::Visitor.create(
|
65
|
+
:site => site,
|
66
|
+
:ip_address => '67.188.42.140',
|
67
|
+
:user_agent =>
|
68
|
+
'Mozilla/5.0 (X11; Linux i686; rv:14.0) Gecko/20100101 Firefox/14.0.1')
|
69
|
+
visit = Charted::Visit.create(
|
70
|
+
:visitor => visitor,
|
71
|
+
:path => '/',
|
72
|
+
:title => 'Prime',
|
73
|
+
:referrer => 'http://www.google.com?q=Charted+Test')
|
74
|
+
assert_equal(site, visit.site)
|
75
|
+
assert_equal([visit], site.visits)
|
76
|
+
assert_equal('Charted Test', visit.search_terms)
|
77
|
+
assert_match(/^\w{5}$/, visitor.secret)
|
78
|
+
assert_equal("#{visitor.id}-#{visitor.secret}", visitor.cookie)
|
79
|
+
assert_equal('Linux', visitor.platform)
|
80
|
+
assert_equal('Firefox', visitor.browser)
|
81
|
+
assert_equal('14.0.1', visitor.browser_version)
|
82
|
+
|
83
|
+
assert_equal(visitor, Charted::Visitor.get_by_cookie(site, visitor.cookie))
|
84
|
+
assert_nil(Charted::Visitor.get_by_cookie(site, "#{visitor.id}-zzzzz"))
|
85
|
+
end
|
86
|
+
|
87
|
+
def test_unique_identifier
|
88
|
+
assert_match(/^\w{5}$/, Charted::Visitor.generate_secret)
|
89
|
+
end
|
90
|
+
|
91
|
+
def test_user_agents
|
92
|
+
visitor = Charted::Visitor.new
|
93
|
+
|
94
|
+
visitor.user_agent = 'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 6.0)'
|
95
|
+
assert_equal('Internet Explorer', visitor.browser)
|
96
|
+
assert_equal('7.0', visitor.browser_version)
|
97
|
+
assert_equal('Windows', visitor.platform)
|
98
|
+
|
99
|
+
visitor.user_agent = 'Mozilla/5.0 (Macintosh; U; PPC Mac OS X; en) AppleWebKit/125.2 (KHTML, like Gecko) Safari/125.8'
|
100
|
+
assert_equal('Safari', visitor.browser)
|
101
|
+
assert_equal('1.2.2', visitor.browser_version)
|
102
|
+
assert_equal('Macintosh', visitor.platform)
|
103
|
+
|
104
|
+
visitor.user_agent = 'Mozilla/5.0 (X11; U; Linux i686; en-US; rv:1.8.1.19) Gecko/20081216 Ubuntu/8.04 (hardy) Firefox/2.0.0.19'
|
105
|
+
assert_equal('Firefox', visitor.browser)
|
106
|
+
assert_equal('2.0.0.19', visitor.browser_version)
|
107
|
+
assert_equal('Linux', visitor.platform)
|
108
|
+
end
|
109
|
+
|
110
|
+
def test_blanks
|
111
|
+
site = Charted::Site.create(:domain => 'localhost')
|
112
|
+
visitor = Charted::Visitor.create(
|
113
|
+
:site => site,
|
114
|
+
:user_agent => '',
|
115
|
+
:ip_address => '')
|
116
|
+
visit = Charted::Visit.create(
|
117
|
+
:visitor => visitor,
|
118
|
+
:path => '/',
|
119
|
+
:title => 'Prime',
|
120
|
+
:referrer => '')
|
121
|
+
assert(site.id)
|
122
|
+
assert(visitor.id)
|
123
|
+
assert(visit.id)
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
class AppTest < ChartedTest
|
128
|
+
include Rack::Test::Methods
|
129
|
+
|
130
|
+
def setup
|
131
|
+
Charted::Site.destroy
|
132
|
+
Charted::Visitor.destroy
|
133
|
+
Charted::Visit.destroy
|
134
|
+
clear_cookies
|
135
|
+
|
136
|
+
@site = Charted::Site.create(:domain => 'example.org')
|
137
|
+
@params = {
|
138
|
+
:path => '/',
|
139
|
+
:title => 'Prime',
|
140
|
+
:referrer => 'localhost',
|
141
|
+
:resolution => '1280x800'
|
142
|
+
}
|
143
|
+
@env = {
|
144
|
+
'HTTP_USER_AGENT' =>
|
145
|
+
'Mozilla/5.0 (X11; Linux i686; rv:14.0) Gecko/20100101 Firefox/14.0.1',
|
146
|
+
'REMOTE_ADDR' => '67.188.42.140'
|
147
|
+
}
|
148
|
+
end
|
149
|
+
|
150
|
+
def test_environment
|
151
|
+
assert_equal(:test, Charted::App.environment)
|
152
|
+
end
|
153
|
+
|
154
|
+
def test_bad_domain
|
155
|
+
get '/charted', @params, 'HTTP_HOST' => 'localhost'
|
156
|
+
assert_equal(404, last_response.status)
|
157
|
+
assert_equal(0, Charted::Visitor.count)
|
158
|
+
assert_equal(0, Charted::Visit.count)
|
159
|
+
end
|
160
|
+
|
161
|
+
def test_new_visitor
|
162
|
+
get '/charted', @params, @env
|
163
|
+
assert(last_response.ok?)
|
164
|
+
assert_equal(1, Charted::Visitor.count)
|
165
|
+
assert_equal(1, Charted::Visit.count)
|
166
|
+
|
167
|
+
visitor = Charted::Visitor.first
|
168
|
+
visit = Charted::Visit.first
|
169
|
+
assert_equal(@site, visitor.site)
|
170
|
+
assert_equal(@site, visit.site)
|
171
|
+
assert_equal('Prime', visit.title)
|
172
|
+
assert_equal('/', visit.path)
|
173
|
+
assert_equal('localhost', visit.referrer)
|
174
|
+
assert_equal('1280x800', visitor.resolution)
|
175
|
+
assert_equal('United States', visitor.country)
|
176
|
+
assert_equal(visitor.cookie, rack_mock_session.cookie_jar['charted'])
|
177
|
+
end
|
178
|
+
|
179
|
+
def test_old_visitor
|
180
|
+
visitor = Charted::Visitor.create(:site => @site)
|
181
|
+
visit = Charted::Visit.create(
|
182
|
+
:visitor => visitor, :path => '/', :title => 'Prime')
|
183
|
+
set_cookie("charted=#{visitor.cookie}")
|
184
|
+
|
185
|
+
get '/charted', @params, @env
|
186
|
+
assert(last_response.ok?)
|
187
|
+
assert_equal(1, Charted::Visitor.count)
|
188
|
+
assert_equal(2, Charted::Visit.count)
|
189
|
+
assert_equal(visitor.cookie, rack_mock_session.cookie_jar['charted'])
|
190
|
+
end
|
191
|
+
|
192
|
+
def test_visitor_bad_cookie
|
193
|
+
visitor = Charted::Visitor.create(:site => @site)
|
194
|
+
visit = Charted::Visit.create(
|
195
|
+
:visitor => visitor, :path => '/', :title => 'Prime')
|
196
|
+
set_cookie("charted=#{visitor.id}-zzzzz")
|
197
|
+
|
198
|
+
get '/charted', @params, @env
|
199
|
+
assert(last_response.ok?)
|
200
|
+
assert_equal(2, Charted::Visitor.count)
|
201
|
+
assert_equal(2, Charted::Visit.count)
|
202
|
+
refute_equal(visitor.cookie, rack_mock_session.cookie_jar['charted'])
|
203
|
+
end
|
204
|
+
|
205
|
+
private
|
206
|
+
def app
|
207
|
+
@app ||= Rack::Server.new.app
|
208
|
+
end
|
209
|
+
end
|
210
|
+
|
211
|
+
class CommandTest < ChartedTest
|
212
|
+
def setup
|
213
|
+
@cmd = Charted::Command.new
|
214
|
+
@cmd.config_loaded = true
|
215
|
+
Charted::Site.destroy
|
216
|
+
Charted::Visitor.destroy
|
217
|
+
Charted::Visit.destroy
|
218
|
+
Charted::Site.create(:domain => 'localhost')
|
219
|
+
Charted::Site.create(:domain => 'example.org')
|
220
|
+
end
|
221
|
+
|
222
|
+
def test_site
|
223
|
+
assert_raises(Charted::ExitError) { @cmd.site = 'nomatch' }
|
224
|
+
assert_equal(['No sites matching "nomatch"'], @cmd.output)
|
225
|
+
assert_nil(@cmd.site)
|
226
|
+
|
227
|
+
@cmd.output = nil
|
228
|
+
assert_raises(Charted::ExitError) { @cmd.site = 'l' }
|
229
|
+
assert_equal(['"l" ambiguous: localhost, example.org'], @cmd.output)
|
230
|
+
|
231
|
+
@cmd.site = 'local'
|
232
|
+
assert_equal('localhost', @cmd.site.domain)
|
233
|
+
|
234
|
+
@cmd.site = 'ample'
|
235
|
+
assert_equal('example.org', @cmd.site.domain)
|
236
|
+
end
|
237
|
+
|
238
|
+
def test_dashboard
|
239
|
+
assert_raises(Charted::ExitError) { @cmd.dashboard }
|
240
|
+
assert_equal(['Please specify website with --site'], @cmd.output)
|
241
|
+
|
242
|
+
@cmd.output = nil
|
243
|
+
@cmd.site = 'localhost'
|
244
|
+
@cmd.dashboard
|
245
|
+
end
|
246
|
+
|
247
|
+
def test_format
|
248
|
+
assert_equal('-10,200', @cmd.send(:format, -10200))
|
249
|
+
assert_equal('-1', @cmd.send(:format, -1))
|
250
|
+
assert_equal('1', @cmd.send(:format, 1))
|
251
|
+
assert_equal('1,200,300', @cmd.send(:format, 1200300))
|
252
|
+
end
|
253
|
+
end
|
data/test/fixtures.rb
ADDED
@@ -0,0 +1,59 @@
|
|
1
|
+
require File.expand_path('../lib/charted', File.dirname(__FILE__))
|
2
|
+
require 'date'
|
3
|
+
|
4
|
+
module Charted
|
5
|
+
class Fixtures
|
6
|
+
def self.load!
|
7
|
+
Charted::Site.destroy
|
8
|
+
Charted::Visitor.destroy
|
9
|
+
Charted::Visit.destroy
|
10
|
+
localhost = Charted::Site.create(:domain => 'localhost')
|
11
|
+
example = Charted::Site.create(:domain => 'example.org')
|
12
|
+
|
13
|
+
months = (0..11).map { |d| Charted.prev_month(Date.today, d) }
|
14
|
+
1000.times do
|
15
|
+
visitor = Charted::Visitor.create(
|
16
|
+
:site => select_rand([localhost, example]),
|
17
|
+
:created_at => select_rand(months),
|
18
|
+
:resolution => select_rand(%w(1400x900 1280x800 1024x768)),
|
19
|
+
:platform => select_rand(['Linux', 'OS X', 'Windows']),
|
20
|
+
:browser => select_rand(%w(IE Firefox Safari Chrome)),
|
21
|
+
:browser_version => select_rand(%w(1 2 3 4 5)),
|
22
|
+
:country => select_rand(%w(USA CA FR CH)),
|
23
|
+
:ip_address => select_rand(%w(67.188.42.140 67.184.24.140)),
|
24
|
+
:user_agent => select_rand([
|
25
|
+
'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 6.0)',
|
26
|
+
'Mozilla/5.0 (X11; Linux i686; rv:14.0) Gecko/20100101 Firefox/14.0.1',
|
27
|
+
'Mozilla/5.0 (Macintosh; U; PPC Mac OS X; en) AppleWebKit/125.2 (KHTML, like Gecko) Safari/125.8',
|
28
|
+
'Mozilla/5.0 (X11; U; Linux i686; en-US; rv:1.8.1.19) Gecko/20081216 Ubuntu/8.04 (hardy) Firefox/2.0.0.19']))
|
29
|
+
(rand(2) + 1).times do |index|
|
30
|
+
Charted::Visit.create(
|
31
|
+
:visitor => visitor,
|
32
|
+
:created_at => Charted.next_month(visitor.created_at, select_rand([0, index])),
|
33
|
+
:path => select_rand(%w(/ /page-one/ /page-two/ /page-three/)),
|
34
|
+
:title => select_rand(%w(Prime Optimus Alpha Beta Omega)),
|
35
|
+
:referrer => select_rand([
|
36
|
+
'http://www.google.com?q=Charted+Test',
|
37
|
+
'http://coverstrap.com',
|
38
|
+
'http://news.ycombinator.com',
|
39
|
+
'http://example.org']),
|
40
|
+
:search_terms => select_rand([
|
41
|
+
'Charted Keywords',
|
42
|
+
'Web Analytics',
|
43
|
+
'Command Line Analytics']))
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
def self.select_rand(items)
|
50
|
+
items[rand(items.length)]
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
if __FILE__ == $0
|
56
|
+
ENV['CHARTED_CMD'] = '1'
|
57
|
+
load(File.expand_path('../config.ru', File.dirname(__FILE__)))
|
58
|
+
Charted::Fixtures.load!
|
59
|
+
end
|
metadata
ADDED
@@ -0,0 +1,199 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: charted
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Hugh Bien
|
9
|
+
autorequire:
|
10
|
+
bindir: .
|
11
|
+
cert_chain: []
|
12
|
+
date: 2013-03-03 00:00:00.000000000 Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: sinatra
|
16
|
+
requirement: !ruby/object:Gem::Requirement
|
17
|
+
none: false
|
18
|
+
requirements:
|
19
|
+
- - ! '>='
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: '0'
|
22
|
+
type: :runtime
|
23
|
+
prerelease: false
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
25
|
+
none: false
|
26
|
+
requirements:
|
27
|
+
- - ! '>='
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
version: '0'
|
30
|
+
- !ruby/object:Gem::Dependency
|
31
|
+
name: data_mapper
|
32
|
+
requirement: !ruby/object:Gem::Requirement
|
33
|
+
none: false
|
34
|
+
requirements:
|
35
|
+
- - ! '>='
|
36
|
+
- !ruby/object:Gem::Version
|
37
|
+
version: '0'
|
38
|
+
type: :runtime
|
39
|
+
prerelease: false
|
40
|
+
version_requirements: !ruby/object:Gem::Requirement
|
41
|
+
none: false
|
42
|
+
requirements:
|
43
|
+
- - ! '>='
|
44
|
+
- !ruby/object:Gem::Version
|
45
|
+
version: '0'
|
46
|
+
- !ruby/object:Gem::Dependency
|
47
|
+
name: geoip
|
48
|
+
requirement: !ruby/object:Gem::Requirement
|
49
|
+
none: false
|
50
|
+
requirements:
|
51
|
+
- - ! '>='
|
52
|
+
- !ruby/object:Gem::Version
|
53
|
+
version: '0'
|
54
|
+
type: :runtime
|
55
|
+
prerelease: false
|
56
|
+
version_requirements: !ruby/object:Gem::Requirement
|
57
|
+
none: false
|
58
|
+
requirements:
|
59
|
+
- - ! '>='
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
- !ruby/object:Gem::Dependency
|
63
|
+
name: pony
|
64
|
+
requirement: !ruby/object:Gem::Requirement
|
65
|
+
none: false
|
66
|
+
requirements:
|
67
|
+
- - ! '>='
|
68
|
+
- !ruby/object:Gem::Version
|
69
|
+
version: '0'
|
70
|
+
type: :runtime
|
71
|
+
prerelease: false
|
72
|
+
version_requirements: !ruby/object:Gem::Requirement
|
73
|
+
none: false
|
74
|
+
requirements:
|
75
|
+
- - ! '>='
|
76
|
+
- !ruby/object:Gem::Version
|
77
|
+
version: '0'
|
78
|
+
- !ruby/object:Gem::Dependency
|
79
|
+
name: useragent
|
80
|
+
requirement: !ruby/object:Gem::Requirement
|
81
|
+
none: false
|
82
|
+
requirements:
|
83
|
+
- - ! '>='
|
84
|
+
- !ruby/object:Gem::Version
|
85
|
+
version: '0'
|
86
|
+
type: :runtime
|
87
|
+
prerelease: false
|
88
|
+
version_requirements: !ruby/object:Gem::Requirement
|
89
|
+
none: false
|
90
|
+
requirements:
|
91
|
+
- - ! '>='
|
92
|
+
- !ruby/object:Gem::Version
|
93
|
+
version: '0'
|
94
|
+
- !ruby/object:Gem::Dependency
|
95
|
+
name: search_terms
|
96
|
+
requirement: !ruby/object:Gem::Requirement
|
97
|
+
none: false
|
98
|
+
requirements:
|
99
|
+
- - ! '>='
|
100
|
+
- !ruby/object:Gem::Version
|
101
|
+
version: '0'
|
102
|
+
type: :runtime
|
103
|
+
prerelease: false
|
104
|
+
version_requirements: !ruby/object:Gem::Requirement
|
105
|
+
none: false
|
106
|
+
requirements:
|
107
|
+
- - ! '>='
|
108
|
+
- !ruby/object:Gem::Version
|
109
|
+
version: '0'
|
110
|
+
- !ruby/object:Gem::Dependency
|
111
|
+
name: colorize
|
112
|
+
requirement: !ruby/object:Gem::Requirement
|
113
|
+
none: false
|
114
|
+
requirements:
|
115
|
+
- - ! '>='
|
116
|
+
- !ruby/object:Gem::Version
|
117
|
+
version: '0'
|
118
|
+
type: :runtime
|
119
|
+
prerelease: false
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
121
|
+
none: false
|
122
|
+
requirements:
|
123
|
+
- - ! '>='
|
124
|
+
- !ruby/object:Gem::Version
|
125
|
+
version: '0'
|
126
|
+
- !ruby/object:Gem::Dependency
|
127
|
+
name: dashes
|
128
|
+
requirement: !ruby/object:Gem::Requirement
|
129
|
+
none: false
|
130
|
+
requirements:
|
131
|
+
- - ! '>='
|
132
|
+
- !ruby/object:Gem::Version
|
133
|
+
version: '0'
|
134
|
+
type: :runtime
|
135
|
+
prerelease: false
|
136
|
+
version_requirements: !ruby/object:Gem::Requirement
|
137
|
+
none: false
|
138
|
+
requirements:
|
139
|
+
- - ! '>='
|
140
|
+
- !ruby/object:Gem::Version
|
141
|
+
version: '0'
|
142
|
+
- !ruby/object:Gem::Dependency
|
143
|
+
name: minitest
|
144
|
+
requirement: !ruby/object:Gem::Requirement
|
145
|
+
none: false
|
146
|
+
requirements:
|
147
|
+
- - ! '>='
|
148
|
+
- !ruby/object:Gem::Version
|
149
|
+
version: '0'
|
150
|
+
type: :development
|
151
|
+
prerelease: false
|
152
|
+
version_requirements: !ruby/object:Gem::Requirement
|
153
|
+
none: false
|
154
|
+
requirements:
|
155
|
+
- - ! '>='
|
156
|
+
- !ruby/object:Gem::Version
|
157
|
+
version: '0'
|
158
|
+
description: A Sinatra app for tracking web traffic on multiple sites.
|
159
|
+
email:
|
160
|
+
- hugh@hughbien.com
|
161
|
+
executables:
|
162
|
+
- charted
|
163
|
+
extensions: []
|
164
|
+
extra_rdoc_files: []
|
165
|
+
files:
|
166
|
+
- LICENSE.md
|
167
|
+
- README.md
|
168
|
+
- config.ru
|
169
|
+
- geoip.dat
|
170
|
+
- charted
|
171
|
+
- lib/charted.rb
|
172
|
+
- test/charted_test.rb
|
173
|
+
- test/fixtures.rb
|
174
|
+
- ./charted
|
175
|
+
homepage: https://github.com/hughbien/charted
|
176
|
+
licenses: []
|
177
|
+
post_install_message:
|
178
|
+
rdoc_options: []
|
179
|
+
require_paths:
|
180
|
+
- lib
|
181
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
182
|
+
none: false
|
183
|
+
requirements:
|
184
|
+
- - ! '>='
|
185
|
+
- !ruby/object:Gem::Version
|
186
|
+
version: '0'
|
187
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
188
|
+
none: false
|
189
|
+
requirements:
|
190
|
+
- - ! '>='
|
191
|
+
- !ruby/object:Gem::Version
|
192
|
+
version: 1.3.6
|
193
|
+
requirements: []
|
194
|
+
rubyforge_project:
|
195
|
+
rubygems_version: 1.8.23
|
196
|
+
signing_key:
|
197
|
+
specification_version: 3
|
198
|
+
summary: Minimal web traffic analytics
|
199
|
+
test_files: []
|