charted 0.0.1

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