charted 0.0.8 → 0.1.0

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