charted 0.0.8 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: a42efb242a29b6a2634fce409c83332faec2bb9f
4
+ data.tar.gz: 9107198182f58f9f07c10fe72d27b44f5fefca70
5
+ SHA512:
6
+ metadata.gz: 9fc09e83cc5c4bfb4cc34c9c13b461a40d6d8d07bdca7f582dd8a706452245deefb2ced18e8aebfaa38114333ae7c8df7f258169fd8fe93e396f069806636225
7
+ data.tar.gz: 8ad15e63db5b8576d3955088f292ea92b77f48b0e5f55771ab3c58bc92dde8f3aca49480bcf6cb6ac3f6eb877fd8ff06f2162896c074b133d8ac013407502563
data/README.md CHANGED
@@ -1,10 +1,8 @@
1
- Description
2
- ===========
1
+ # Description
3
2
 
4
3
  Charted is a minimal web traffic analytics app.
5
4
 
6
- Installation
7
- ============
5
+ # Installation
8
6
 
9
7
  $ gem install charted
10
8
 
@@ -18,14 +16,19 @@ Setup a `config.ru` file and run it like any other Sinatra application.
18
16
  require 'charted'
19
17
 
20
18
  Charted.configure do |c|
21
- c.email 'john@mailinator.com' # production exceptions are sent here
22
- c.delete_after 365 # only keep a years worth of data
23
- c.db_adapter 'mysql'
24
- c.db_host 'localhost'
25
- c.db_username 'root'
26
- c.db_password 'secret'
27
- c.db_database 'charted'
19
+ c.delete_after 365 # only keep a years worth of data
20
+ c.error_email 'john@mailinator.com' # production exceptions are sent here
28
21
  c.sites ['hughbien.com', 'example.com']
22
+ c.db_options( # database config
23
+ adapter: 'postgres',
24
+ host: 'localhost',
25
+ username: 'root',
26
+ password: 'secret',
27
+ database: 'charted')
28
+ c.email_options( # error email config (see Pony gem for options)
29
+ from: 'errors@mailinator.com',
30
+ via: :smtp,
31
+ via_options: {host: 'smtp.example.org'})
29
32
  end
30
33
 
31
34
  run Charted::App if !ENV['CHARTED_CMD']
@@ -45,8 +48,7 @@ you other JavaScript assets.
45
48
 
46
49
  $ charted --js > /path/to/my/project/public/charted.js
47
50
 
48
- Updating
49
- ========
51
+ # Updating
50
52
 
51
53
  $ gem install charted
52
54
 
@@ -54,8 +56,7 @@ You may need to generate a `charted.js` file again:
54
56
 
55
57
  $ charted --js > /path/to/my/project/public/charted.js
56
58
 
57
- Usage
58
- =====
59
+ # Usage
59
60
 
60
61
  The web application is for end users, to get information about your traffic use
61
62
  the included command line application.
@@ -110,8 +111,14 @@ events/conversions/experiments table:
110
111
 
111
112
  charted --clean "Buy Button"
112
113
 
113
- Development
114
- ===========
114
+ # Rack Extensions
115
+
116
+ I recommend using these extensions with Charted:
117
+
118
+ * [Rack Timeout](https://github.com/heroku/rack-timeout)
119
+ * [Rack Throttle](https://github.com/datagraph/rack-throttle)
120
+
121
+ # Development
115
122
 
116
123
  Put this in your `zshrc` or `bashrc`:
117
124
 
@@ -119,7 +126,7 @@ Put this in your `zshrc` or `bashrc`:
119
126
 
120
127
  Then run:
121
128
 
122
- $ ./charted --migrate
129
+ $ bin/charted --migrate
123
130
  $ shotgun
124
131
 
125
132
  Head on over to `http://localhost:9393/charted/prime.html`. This is where
@@ -127,16 +134,14 @@ recordings should occur.
127
134
 
128
135
  Tests are setup to run via `ruby test/*_test.rb` or via `rake`.
129
136
 
130
- TODO
131
- ====
137
+ # TODO
132
138
 
133
- * don't catch-all load error, makes debugging config.ru difficult
134
- * add --full or --single
135
- * browser version (IE6, IE7, IE8...) ?
136
- * handle 255 string length limit
139
+ * handle case where visitor visits "/charted" or any other route directly (bad input)
140
+ * record/display uniques for pages, etc... perhaps --unique option
141
+ * add --limit NUMBER to limit number of rows per table outputted
142
+ * add date range options (--start "3 days ago" --end "1 day ago")
137
143
 
138
- License
139
- =======
144
+ # License
140
145
 
141
146
  `geoip.dat` is provided by MaxMind at <http://dev.maxmind.com/geoip/geolite>.
142
147
 
@@ -1,9 +1,8 @@
1
1
  #!/usr/bin/env ruby
2
2
  require 'optparse'
3
- require File.expand_path('lib/charted', File.dirname(__FILE__))
4
- require 'dm-migrations'
5
3
  require 'date'
6
4
  require 'fileutils'
5
+ require_relative '../lib/charted'
7
6
 
8
7
  ENV['CHARTED_CMD'] = '1'
9
8
 
data/config.ru CHANGED
@@ -1,15 +1,17 @@
1
1
  require File.expand_path('lib/charted', File.dirname(__FILE__))
2
2
 
3
- Charted.configure(ENV['RACK_ENV'] != 'test') do |c|
3
+ Charted.configure do |c|
4
4
  c.delete_after 365
5
- c.email 'dev@localhost'
6
- c.db_adapter 'sqlite3'
7
- c.db_host 'localhost'
8
- c.db_username 'root'
9
- c.db_password 'secret'
10
- c.db_database 'db.sqlite3'
11
5
  c.sites ['localhost', 'example.org']
12
- end
6
+ c.error_email 'dev@localhost'
7
+ c.db_options(
8
+ adapter: 'sqlite',
9
+ host: 'localhost',
10
+ username: 'root',
11
+ password: 'secret',
12
+ database: 'db.sqlite3')
13
+ c.email_options(via: :sendmail)
14
+ end if ENV['RACK_ENV'] != 'test'
13
15
 
14
16
  if !ENV['CHARTED_CMD']
15
17
  map('/charted') { run Charted::App }
@@ -1,43 +1,25 @@
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'
1
+ require 'bundler/setup'
2
+ require 'dashes'
8
3
  require 'date'
9
- require 'digest/sha1'
10
- require 'json'
11
- require 'uri'
12
4
  require 'geoip'
5
+ require 'json'
13
6
  require 'pony'
14
- require 'useragent'
15
7
  require 'search_terms'
16
- require 'dashes'
8
+ require 'securerandom'
9
+ require 'sequel'
10
+ require 'sinatra/base'
11
+ require 'uri'
12
+ require 'useragent'
17
13
 
18
- DataMapper::Model.raise_on_save_failure = true
19
- DataMapper::Property::String.length(255)
14
+ require_relative 'charted/app'
15
+ require_relative 'charted/command'
16
+ require_relative 'charted/config'
17
+ require_relative 'charted/version'
20
18
 
21
19
  module Charted
22
- VERSION = '0.0.8'
23
20
  GEOIP = GeoIP.new("#{File.dirname(__FILE__)}/../geoip.dat")
24
21
  JS_FILE = "#{File.dirname(__FILE__)}/../public/charted/script.js"
25
22
 
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
23
  def self.prev_month(date, delta=1)
42
24
  date = Date.new(date.year, date.month, 1)
43
25
  delta.times { date = Date.new((date - 1).year, (date - 1).month, 1) }
@@ -49,457 +31,4 @@ module Charted
49
31
  delta.times { date = Date.new((date + 32).year, (date + 32).month, 1) }
50
32
  date
51
33
  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
- module Endable
69
- def ended?
70
- !!ended_at
71
- end
72
-
73
- def end!
74
- self.ended_at = DateTime.now
75
- self.save
76
- end
77
- end
78
-
79
- class Site
80
- include DataMapper::Resource
81
-
82
- property :id, Serial
83
- property :domain, String, :required => true, :unique => true
84
- property :created_at, DateTime
85
-
86
- has n, :visitors
87
- has n, :visits, :through => :visitors
88
- has n, :events, :through => :visitors
89
- has n, :conversions, :through => :visitors
90
- has n, :experiments, :through => :visitors
91
-
92
- def visitor_with_cookie(cookie)
93
- visitor = self.visitors.get(cookie.to_s.split('-').first)
94
- visitor && visitor.cookie == cookie ? visitor : nil
95
- end
96
- end
97
-
98
- class Visitor
99
- include DataMapper::Resource
100
-
101
- property :id, Serial
102
- property :secret, String, :required => true
103
- property :resolution, String
104
- property :created_at, DateTime
105
- property :platform, String
106
- property :browser, String
107
- property :browser_version, String
108
- property :country, String
109
- property :bucket, Integer
110
-
111
- belongs_to :site
112
- has n, :visits
113
- has n, :events
114
- has n, :conversions
115
- has n, :experiments
116
-
117
- validates_presence_of :site
118
-
119
- def initialize(*args)
120
- super
121
- self.secret = self.class.generate_secret
122
- end
123
-
124
- def cookie
125
- "#{self.id}-#{self.bucket}-#{self.secret}"
126
- end
127
-
128
- def user_agent=(user_agent)
129
- ua = UserAgent.parse(user_agent)
130
- self.browser = ua.browser
131
- self.browser_version = ua.version
132
- self.platform = ua.platform == 'X11' ? 'Linux' : ua.platform
133
- end
134
-
135
- def ip_address=(ip)
136
- return if ip.to_s =~ /^\s*$/ || ip == '127.0.0.1'
137
- name = GEOIP.country(ip).country_name
138
-
139
- return if name =~ /^\s*$/ || name == 'N/A'
140
- self.country = name
141
- rescue SocketError
142
- # invalid IP address, skip setting country
143
- end
144
-
145
- def make_events(labels)
146
- labels.to_s.split(';').map(&:strip).map do |label|
147
- events.create(label: label)
148
- end
149
- end
150
-
151
- def start_conversions(labels)
152
- labels.to_s.split(';').map(&:strip).map do |label|
153
- conversions.first(label: label) || self.conversions.create(label: label)
154
- end
155
- end
156
-
157
- def start_experiments(labels) # label:bucket;...
158
- labels.to_s.split(';').map do |str|
159
- label, bucket = str.split(':', 2).map(&:strip)
160
- exp = experiments.first(label: label)
161
- if exp
162
- exp.update(bucket: bucket) if exp.bucket != bucket
163
- exp
164
- else
165
- self.experiments.create(label: label, bucket: bucket)
166
- end
167
- end
168
- end
169
-
170
- def end_goals(labels)
171
- labels.to_s.split(';').map(&:strip).each do |label|
172
- exp = experiments.first(label: label)
173
- exp.end! if exp
174
- conv = conversions.first(label: label)
175
- conv.end! if conv
176
- end
177
- end
178
-
179
- def self.generate_secret
180
- Digest::SHA1.hexdigest("#{Time.now}-#{rand(100)}")[0..4]
181
- end
182
- end
183
-
184
- class Visit
185
- include DataMapper::Resource
186
-
187
- property :id, Serial
188
- property :path, String, required: true
189
- property :title, String, required: true
190
- property :referrer, String, length: 2048
191
- property :search_terms, String
192
- property :created_at, DateTime
193
-
194
- belongs_to :visitor
195
- has 1, :site, :through => :visitor
196
-
197
- validates_presence_of :visitor
198
-
199
- before :save, :set_search_terms
200
-
201
- def set_search_terms
202
- return if self.referrer.to_s =~ /^\s*$/
203
- self.search_terms = URI.parse(self.referrer).search_string
204
- end
205
- end
206
-
207
- class Event
208
- include DataMapper::Resource
209
-
210
- property :id, Serial
211
- property :label, String, :required => true
212
- property :created_at, DateTime
213
-
214
- belongs_to :visitor
215
- has 1, :site, :through => :visitor
216
-
217
- validates_presence_of :visitor
218
- end
219
-
220
- class Conversion
221
- include DataMapper::Resource
222
- include Endable
223
-
224
- property :id, Serial
225
- property :label, String, :required => true
226
- property :created_at, DateTime
227
- property :ended_at, DateTime
228
-
229
- belongs_to :visitor
230
- has 1, :site, :through => :visitor
231
-
232
- validates_presence_of :visitor
233
- end
234
-
235
- class Experiment
236
- include DataMapper::Resource
237
- include Endable
238
-
239
- property :id, Serial
240
- property :label, String, :required => true
241
- property :bucket, String, :required => true
242
- property :created_at, DateTime
243
- property :ended_at, DateTime
244
-
245
- belongs_to :visitor
246
- has 1, :site, :through => :visitor
247
-
248
- validates_presence_of :visitor
249
- end
250
-
251
- DataMapper.finalize
252
-
253
- class App < Sinatra::Base
254
- set :logging, true
255
-
256
- before do
257
- @site = Site.first(domain: request.host)
258
- halt(404) if @site.nil?
259
- @visitor = @site.visitor_with_cookie(request.cookies['charted'])
260
- end
261
-
262
- get '/' do
263
- if @visitor.nil?
264
- @visitor = @site.visitors.create(
265
- resolution: params[:resolution],
266
- user_agent: request.user_agent,
267
- ip_address: request.ip,
268
- bucket: params[:bucket])
269
- response.set_cookie(
270
- 'charted',
271
- value: @visitor.cookie,
272
- expires: (Date.today + 365*2).to_time)
273
- end
274
-
275
- begin
276
- referrer = params[:referrer].to_s
277
- referrer = nil if URI.parse(referrer).host == @site.domain || referrer =~ /^\s*$/
278
- rescue URI::InvalidURIError
279
- referrer = nil
280
- end
281
- @visitor.visits.create(
282
- path: params[:path],
283
- title: params[:title],
284
- referrer: referrer)
285
- @visitor.start_conversions(params[:conversions])
286
- @visitor.start_experiments(params[:experiments])
287
- '/**/'
288
- end
289
-
290
- get '/record' do
291
- halt(404) if @visitor.nil?
292
- @visitor.make_events(params[:events])
293
- @visitor.end_goals(params[:goals])
294
- '/**/'
295
- end
296
-
297
- error do
298
- Pony.mail(
299
- to: Charted.config.email,
300
- from: "charted@#{Charted.config.email.split('@')[1..-1].join}",
301
- subject: 'Charted Error',
302
- body: request.env['sinatra.error'].to_s
303
- ) if Charted.config.email && self.class.environment == :production
304
- end
305
- end
306
-
307
- class Command
308
- attr_accessor :config_loaded, :output
309
- attr_reader :site
310
-
311
- def clean(label=nil)
312
- load_config
313
- sys_exit("Please set 'delete_after' config.") if Charted.config.delete_after.nil?
314
-
315
- threshold = Date.today - Charted.config.delete_after
316
- Visit.all(:created_at.lt => threshold).destroy
317
- Event.all(:created_at.lt => threshold).destroy
318
- Conversion.all(:created_at.lt => threshold).destroy
319
- Experiment.all(:created_at.lt => threshold).destroy
320
- Visitor.all(:created_at.lt => threshold).each do |visitor|
321
- visitor.destroy if visitor.visits.count == 0 &&
322
- visitor.events.count == 0 &&
323
- visitor.conversions.count == 0 &&
324
- visitor.experiments.count == 0
325
- end
326
-
327
- if label
328
- Event.all(label: label).destroy
329
- Conversion.all(label: label).destroy
330
- Experiment.all(label: label).destroy
331
- end
332
- end
333
-
334
- def dashboard
335
- site_required
336
- nodes = []
337
- max_width = [`tput cols`.to_i / 2, 60].min
338
- chart = Dashes::Chart.new.
339
- max_width(max_width).
340
- title("Total Visits")
341
- chart2 = Dashes::Chart.new.
342
- max_width(max_width).
343
- title("Unique Visits")
344
- table = Dashes::Table.new.
345
- max_width(max_width).
346
- spacing(:min, :min, :max).
347
- align(:right, :right, :left).
348
- row('Total', 'Unique', 'Visits').
349
- separator
350
- (0..11).each do |delta|
351
- date = Charted.prev_month(Date.today, delta)
352
- visits = @site.visits.count(
353
- :created_at.gte => date,
354
- :created_at.lt => Charted.next_month(date))
355
- unique = @site.visitors.count(:visits => {
356
- :created_at.gte => date,
357
- :created_at.lt => Charted.next_month(date)})
358
- table.row(format(visits), format(unique), date.strftime('%B %Y'))
359
- chart.row(date.strftime('%b %Y'), visits)
360
- chart2.row(date.strftime('%b %Y'), unique)
361
- end
362
- nodes += [table, chart, chart2]
363
- [[:browser, 'Browsers', :visitors],
364
- [:resolution, 'Resolutions', :visitors],
365
- [:platform, 'Platforms', :visitors],
366
- [:country, 'Countries', :visitors],
367
- [:title, 'Pages', :visits],
368
- [:referrer, 'Referrers', :visits],
369
- [:search_terms, 'Searches', :visits]].each do |field, column, type|
370
- table = Dashes::Table.new.
371
- max_width(max_width).
372
- spacing(:min, :min, :max).
373
- align(:right, :right, :left).
374
- row('Total', '%', column).separator
375
- rows = []
376
- total = @site.send(type).count(field.not => nil)
377
- @site.send(type).aggregate(field, :all.count).each do |label, count|
378
- label = label.to_s.strip
379
- next if label == ""
380
- label = "#{label[0..37]}..." if label.length > 40
381
- rows << [format(count), "#{((count / total.to_f) * 100).round}%", label]
382
- end
383
- add_truncated(table, rows)
384
- nodes << table
385
- end
386
- table = Dashes::Table.new.
387
- max_width(max_width).
388
- spacing(:min, :min, :max).
389
- align(:right, :right, :left).
390
- row('Total', 'Unique', 'Events').
391
- separator
392
- rows = []
393
- @site.events.aggregate(:label, :all.count).each do |label, count|
394
- unique = @site.visitors.count(:events => {label: label})
395
- rows << [format(count), format(unique), label]
396
- end
397
- add_truncated(table, rows)
398
- nodes << table
399
-
400
- table = Dashes::Table.new.
401
- max_width(max_width).
402
- spacing(:min, :min, :max).
403
- align(:right, :right, :left).
404
- row('Start', 'End', 'Conversions').
405
- separator
406
- rows = []
407
- @site.conversions.aggregate(:label, :all.count).each do |label, count|
408
- ended = @site.conversions.count(label: label, :ended_at.not => nil)
409
- rows << [format(count), format(ended), label]
410
- end
411
- add_truncated(table, rows)
412
- nodes << table
413
-
414
- table = Dashes::Table.new.
415
- max_width(max_width).
416
- spacing(:min, :min, :max).
417
- align(:right, :right, :left).
418
- row('Start', 'End', 'Experiments').
419
- separator
420
- rows = []
421
- @site.experiments.aggregate(:label, :bucket, :all.count).each do |label, bucket, count|
422
- ended = @site.experiments.count(label: label, bucket: bucket, :ended_at.not => nil)
423
- rows << [format(count), format(ended), "#{label}: #{bucket}"]
424
- end
425
- add_truncated(table, rows)
426
- nodes << table
427
-
428
- nodes.reject! do |node|
429
- minimum = node.is_a?(Dashes::Table) ? 1 : 0
430
- node.instance_variable_get(:@rows).size == minimum # TODO: hacked
431
- end
432
- print(Dashes::Grid.new.width(`tput cols`.to_i).add(*nodes))
433
- end
434
-
435
- def js
436
- print(File.read(JS_FILE))
437
- end
438
-
439
- def migrate
440
- load_config
441
- DataMapper.auto_upgrade!
442
- Charted.config.sites.each do |domain|
443
- if Site.first(:domain => domain).nil?
444
- Site.create(:domain => domain)
445
- end
446
- end
447
- end
448
-
449
- def site=(domain)
450
- load_config
451
- sites = Site.all(:domain.like => "%#{domain}%")
452
-
453
- if sites.length > 1
454
- sys_exit("\"#{domain}\" ambiguous: #{sites.map(&:domain).join(', ')}")
455
- elsif sites.length < 1
456
- sys_exit("No sites matching \"#{domain}\"")
457
- else
458
- @site = sites.first
459
- end
460
- end
461
-
462
- private
463
- def load_config
464
- return if @config_loaded
465
- file = ENV['CHARTED_CONFIG']
466
- load(file)
467
- @config_loaded = true
468
- rescue LoadError
469
- sys_exit("CHARTED_CONFIG not set, please set to `config.ru` file.")
470
- end
471
-
472
- def sys_exit(reason)
473
- print(reason)
474
- ENV['RACK_ENV'] == 'test' ? raise(ExitError.new) : exit
475
- end
476
-
477
- def print(string)
478
- ENV['RACK_ENV'] == 'test' ? (@output ||= []) << string : puts(string)
479
- end
480
-
481
- def site_required
482
- load_config
483
- if @site.nil? && Site.count == 1
484
- @site = Site.first
485
- elsif @site.nil?
486
- sys_exit('Please specify website with --site')
487
- end
488
- end
489
-
490
- def format(num)
491
- num.to_s.reverse.gsub(/...(?=.)/,'\&,').reverse
492
- end
493
-
494
- def add_truncated(table, rows)
495
- rows = rows.sort_by { |r| r[0].gsub(/[^\d]/, '').to_i }.reverse
496
- if rows.length > 12
497
- rows = rows[0..11]
498
- rows << ['...', '...', '...']
499
- end
500
- rows.each { |row| table.row(*row) }
501
- end
502
- end
503
-
504
- class ExitError < RuntimeError; end
505
34
  end