charted 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- 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: []
|