mongoid_traffic 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,32 @@
1
+ module MongoidTraffic
2
+ module ControllerAdditions
3
+
4
+ def log_traffic scope: nil
5
+ MongoidTraffic::Logger.log(
6
+ ip_address: request.remote_ip,
7
+ referer: request.headers['Referer'],
8
+ unique_id: request.session_options[:id], # FIXME: not sure about this
9
+ user_agent: request.headers['User-Agent']
10
+ )
11
+ end
12
+
13
+ def log_scoped_traffic scope: nil
14
+ log_traffic(scope: (scope || request.fullpath.split('?').first))
15
+ end
16
+
17
+ # ---------------------------------------------------------------------
18
+
19
+ def self.included base
20
+ base.extend ClassMethods
21
+ base.helper_method :log_traffic
22
+ base.helper_method :log_scoped_traffic
23
+ end
24
+
25
+ end
26
+ end
27
+
28
+ if defined? ActionController::Base
29
+ ActionController::Base.class_eval do
30
+ include MongoidTraffic::ControllerAdditions
31
+ end
32
+ end
@@ -0,0 +1,86 @@
1
+ module MongoidTraffic
2
+ class Log
3
+
4
+ include Mongoid::Document
5
+
6
+ # ---------------------------------------------------------------------
7
+
8
+ field :s, as: :scope, type: String
9
+
10
+ field :ac, as: :access_count, type: Integer
11
+
12
+ field :b, as: :browsers, type: Hash, default: {}
13
+ field :c, as: :countries, type: Hash, default: {}
14
+ field :r, as: :referers, type: Hash, default: {}
15
+ field :u, as: :unique_ids, type: Hash, default: {}
16
+
17
+ field :uat, as: :updated_at, type: Time
18
+
19
+ # ---------------------------------------------------------------------
20
+
21
+ field :df, as: :date_from, type: Date
22
+ field :dt, as: :date_to, type: Date
23
+
24
+ # ---------------------------------------------------------------------
25
+
26
+ validates :date_from, presence: true
27
+ validates :date_to, presence: true
28
+
29
+ # ---------------------------------------------------------------------
30
+
31
+ default_scope -> { where(scope: nil) }
32
+
33
+ scope :for_dates, -> date_from, date_to { where(date_from: date_from, date_to: date_to) }
34
+
35
+ scope :yearly, -> year { self.for_dates(Date.parse("01/01/#{year}"), Date.parse("01/01/#{year}").at_end_of_year) }
36
+ scope :monthly, -> month, year { self.for_dates(Date.parse("01/#{month}/#{year}"), Date.parse("01/#{month}/#{year}").at_end_of_month) }
37
+ scope :weekly, -> week, year { self.for_dates(Date.commercial(year, week), Date.commercial(year, week).at_end_of_week) }
38
+ scope :daily, -> date { self.for_dates(date, date) }
39
+
40
+ scope :scoped_to, -> scope { where(scope: scope) }
41
+
42
+ # ---------------------------------------------------------------------
43
+
44
+ index({ scope: 1, date_from: 1, date_to: 1 })
45
+
46
+ # =====================================================================
47
+
48
+ def self.aggregate_on att
49
+ case find_field_by_name(att).type.to_s
50
+ when 'Integer' then sum(att)
51
+ when 'Hash' then sum_hash(att)
52
+ end
53
+ end
54
+
55
+ def self.sum att
56
+ if att.to_sym == :unique_ids
57
+ aggregate_on(:unique_ids).keys.count
58
+ else
59
+ super(att)
60
+ end
61
+ end
62
+
63
+ private # =============================================================
64
+
65
+ def self.find_field_by_name field_name
66
+ return unless f = fields.detect{ |k,v| k == field_name.to_s or v.options[:as].to_s == field_name.to_s }
67
+ f.last
68
+ end
69
+
70
+ def self.sum_hash field_name
71
+ self.pluck(field_name).inject({}) do |res, h|
72
+ merger = proc { |key, v1, v2|
73
+ if Hash === v1 && Hash === v2
74
+ v1.merge(v2, &merger)
75
+ elsif Hash === v2
76
+ v2
77
+ else
78
+ v1.to_i + v2.to_i
79
+ end
80
+ }
81
+ res = res.merge(h, &merger)
82
+ end
83
+ end
84
+
85
+ end
86
+ end
@@ -0,0 +1,34 @@
1
+ require 'nokogiri'
2
+
3
+ # sourced from https://github.com/charlotte-ruby/impressionist/blob/master/lib/impressionist/bots.rb
4
+
5
+ module MongoidTraffic
6
+ class Logger
7
+ class Bots
8
+
9
+ DATA_URL = "http://www.user-agents.org/allagents.xml"
10
+ FILE_PATH = "vendor/mongoid_traffic/allagents.xml"
11
+
12
+ class << self
13
+ def list
14
+ @list ||= begin
15
+ response = File.open(FILE_PATH).read
16
+ doc = Nokogiri::XML(response)
17
+ list = []
18
+ doc.xpath('//user-agent').each do |agent|
19
+ type = agent.xpath("Type").text
20
+ list << agent.xpath('String').text.gsub("&lt;","<") if %w(R S).include?(type)
21
+ end
22
+ list
23
+ end
24
+ end
25
+
26
+ def is_a_bot? referer
27
+ return false unless referer.present?
28
+ list.include?(referer)
29
+ end
30
+ end
31
+
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,31 @@
1
+ module MongoidTraffic
2
+ class Logger
3
+ class Browser
4
+
5
+ def initialize user_agent_string
6
+ @user_agent_string = user_agent_string
7
+ end
8
+
9
+ # ---------------------------------------------------------------------
10
+
11
+ def platform
12
+ user_agent.platform
13
+ end
14
+
15
+ def name
16
+ user_agent.browser
17
+ end
18
+
19
+ def version
20
+ user_agent.version.to_s.split('.')[0..1].join('.')
21
+ end
22
+
23
+ private # =============================================================
24
+
25
+ def user_agent
26
+ @user_agent ||= ::UserAgent.parse(@user_agent_string)
27
+ end
28
+
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,22 @@
1
+ require 'geoip'
2
+
3
+ module MongoidTraffic
4
+ class Logger
5
+ class GeoIp
6
+
7
+ DATA_URL = "http://geolite.maxmind.com/download/geoip/database/GeoLiteCountry/GeoIP.dat.gz"
8
+ FILE_URL = "vendor/mongoid_traffic/GeoIP.dat"
9
+
10
+ class << self
11
+ def geoip
12
+ @geoip ||= ::GeoIP.new(FILE_URL)
13
+ end
14
+
15
+ def country_code2 str
16
+ geoip.country(str).country_code2
17
+ end
18
+ end
19
+
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,27 @@
1
+ require 'uri'
2
+
3
+ module MongoidTraffic
4
+ class Logger
5
+ class Referer
6
+
7
+ def initialize referer_string
8
+ @referer_string = referer_string
9
+ end
10
+
11
+ def host
12
+ uri.host
13
+ end
14
+
15
+ def to_s
16
+ @referer_string
17
+ end
18
+
19
+ private # =============================================================
20
+
21
+ def uri
22
+ URI.parse(@referer_string)
23
+ end
24
+
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,125 @@
1
+ require 'geoip'
2
+ require 'uri'
3
+ require 'useragent'
4
+
5
+ require_relative './log'
6
+ require_relative './logger/bots'
7
+ require_relative './logger/browser'
8
+ require_relative './logger/geo_ip'
9
+ require_relative './logger/referer'
10
+
11
+ module MongoidTraffic
12
+ class Logger
13
+
14
+ TIME_SCOPE_OPTIONS = %i(year month week day)
15
+
16
+ # ---------------------------------------------------------------------
17
+
18
+ def self.log *args
19
+ new(*args).log
20
+ end
21
+
22
+ # ---------------------------------------------------------------------
23
+
24
+ def initialize ip_address: nil, referer: nil, scope: nil, time_scope: %i(month day), unique_id: nil, user_agent: nil
25
+ @ip_address = ip_address
26
+ @referer_string = referer
27
+ @scope = scope
28
+ @time_scope = time_scope
29
+ @unique_id = unique_id
30
+ @user_agent_string = user_agent
31
+ end
32
+
33
+ def log
34
+ return if Bots.is_a_bot?(@referer_string)
35
+ raise "Invalid time scope definition: #{@time_scope}" unless @time_scope.all?{ |ts| TIME_SCOPE_OPTIONS.include?(ts) }
36
+
37
+ @time_scope.each do |ts|
38
+ Log.collection.find( find_query(ts) ).upsert( upsert_query )
39
+ end
40
+ end
41
+
42
+ # ---------------------------------------------------------------------
43
+
44
+ def upsert_query
45
+ {
46
+ '$inc' => access_count_query.
47
+ merge(browser_query).
48
+ merge(country_query).
49
+ merge(referer_query).
50
+ merge(unique_id_query),
51
+ '$set' => { uat: Time.now }
52
+ }
53
+ end
54
+
55
+ # ---------------------------------------------------------------------
56
+
57
+ def access_count_query
58
+ { ac: 1 }
59
+ end
60
+
61
+ def browser_query
62
+ return {} unless browser.present?
63
+ browser_path = [browser.platform, browser.name, browser.version].map{ |s| escape_key(s) }.join('.')
64
+ { "b.#{browser_path}" => 1 }
65
+ end
66
+
67
+ def country_query
68
+ return {} unless @ip_address.present?
69
+ return {} unless country_code2 = GeoIp.country_code2(@ip_address)
70
+ country_code_key = escape_key(country_code2)
71
+ { "c.#{country_code_key}" => 1 }
72
+ end
73
+
74
+ def referer_query
75
+ return {} unless referer.present?
76
+ referer_key = escape_key(referer.to_s)
77
+ { "r.#{referer_key}" => 1 }
78
+ end
79
+
80
+ def unique_id_query
81
+ return {} unless @unique_id.present?
82
+ unique_id_key = escape_key(@unique_id.to_s)
83
+ { "u.#{unique_id_key}" => 1 }
84
+ end
85
+
86
+ # ---------------------------------------------------------------------
87
+
88
+ def escape_key key
89
+ CGI::escape(key).gsub('.', '%2E')
90
+ end
91
+
92
+ # ---------------------------------------------------------------------
93
+
94
+ def find_query ts
95
+ res = time_query(ts)
96
+ res = res.merge(scope_query) if @scope.present?
97
+ res
98
+ end
99
+
100
+ def scope_query
101
+ { s: @scope }
102
+ end
103
+
104
+ def time_query ts
105
+ date = Date.today
106
+ case ts
107
+ when :day then { df: date, dt: date }
108
+ else { df: date.send("at_beginning_of_#{ts}"), dt: date.send("at_end_of_#{ts}") }
109
+ end
110
+ end
111
+
112
+ private # =============================================================
113
+
114
+ def browser
115
+ return unless @user_agent_string.present?
116
+ @browser ||= Browser.new(@user_agent_string)
117
+ end
118
+
119
+ def referer
120
+ return unless @referer_string.present?
121
+ @referer ||= Referer.new(@referer_string)
122
+ end
123
+
124
+ end
125
+ end
@@ -0,0 +1,3 @@
1
+ module MongoidTraffic
2
+ VERSION = "0.0.1"
3
+ end
@@ -0,0 +1,3 @@
1
+ require "mongoid_traffic/version"
2
+
3
+ require "mongoid_traffic/controller_additions"
@@ -0,0 +1,33 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'mongoid_traffic/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "mongoid_traffic"
8
+ spec.version = MongoidTraffic::VERSION
9
+ spec.authors = ["Tomas Celizna"]
10
+ spec.email = ["tomas.celizna@gmail.com"]
11
+ spec.description = %q{Aggregated traffic logs stored in MongoDB.}
12
+ spec.summary = %q{Aggregated traffic logs stored in MongoDB.}
13
+ spec.homepage = "https://github.com/tomasc/mongoid_traffic"
14
+ spec.license = "MIT"
15
+
16
+ spec.files = `git ls-files`.split($/)
17
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
+ spec.require_paths = ["lib"]
20
+
21
+ spec.add_dependency "geoip"
22
+ spec.add_dependency "mongoid", "~> 4.0"
23
+ spec.add_dependency "nokogiri"
24
+ spec.add_dependency "useragent", "~> 0.10.0"
25
+
26
+ spec.add_development_dependency "bundler", "~> 1.3"
27
+ spec.add_development_dependency "coveralls"
28
+ spec.add_development_dependency "database_cleaner"
29
+ spec.add_development_dependency "guard"
30
+ spec.add_development_dependency "guard-minitest"
31
+ spec.add_development_dependency "minitest"
32
+ spec.add_development_dependency "rake"
33
+ end
@@ -0,0 +1,27 @@
1
+ require 'test_helper'
2
+
3
+ require_relative '../../lib/mongoid_traffic/controller_additions'
4
+
5
+ module MongoidTraffic
6
+ describe 'ControllerAdditions' do
7
+
8
+ before(:each) do
9
+ @controller_class = Class.new
10
+ @controller = @controller_class.new
11
+ def @controller_class.helper_method(name); end
12
+ @controller_class.send(:include, MongoidTraffic::ControllerAdditions)
13
+ end
14
+
15
+ describe '.log_traffic' do
16
+ it 'logs with :user_agent'
17
+ it 'logs with :referer'
18
+ it 'logs with :ip_address'
19
+ it 'logs with :unique_id'
20
+ end
21
+
22
+ describe '.log_scoped_traffic' do
23
+ it 'infers scope from request path'
24
+ end
25
+
26
+ end
27
+ end
@@ -0,0 +1,153 @@
1
+ require 'test_helper'
2
+
3
+ require_relative '../../lib/mongoid_traffic/log'
4
+
5
+ module MongoidTraffic
6
+ describe 'Log' do
7
+ subject { Log.new }
8
+
9
+ describe 'fields' do
10
+ it 'has :scope' do
11
+ subject.must_respond_to :scope
12
+ end
13
+ it 'has :access_count' do
14
+ subject.must_respond_to :access_count
15
+ end
16
+ it 'has :browsers' do
17
+ subject.must_respond_to :browsers
18
+ subject.browsers.must_be_kind_of Hash
19
+ end
20
+ it 'has :referers' do
21
+ subject.must_respond_to :referers
22
+ subject.referers.must_be_kind_of Hash
23
+ end
24
+ it 'has :countries' do
25
+ subject.must_respond_to :countries
26
+ subject.countries.must_be_kind_of Hash
27
+ end
28
+ it 'has :unique_ids' do
29
+ subject.must_respond_to :unique_ids
30
+ subject.unique_ids.must_be_kind_of Hash
31
+ end
32
+ it 'has :updated_at' do
33
+ subject.must_respond_to :updated_at
34
+ end
35
+ end
36
+
37
+ describe 'scopes' do
38
+ it 'has :default_scope that assumes no :scope' do
39
+ Log.criteria.selector.fetch('s').must_be_nil
40
+ end
41
+
42
+ it('has :for_dates') { Log.must_respond_to :for_dates }
43
+ it('has :yearly') { Log.must_respond_to :yearly }
44
+ it('has :monthly') { Log.must_respond_to :monthly }
45
+ it('has :weekly') { Log.must_respond_to :weekly }
46
+ it('has :daily') { Log.must_respond_to :daily }
47
+ it('has :scoped_to') { Log.must_respond_to :scoped_to }
48
+ end
49
+
50
+ describe '.aggregate_on' do
51
+ let(:log_1) { Log.new(date_from: Date.today, date_to: Date.today) }
52
+ let(:log_2) { Log.new(date_from: Date.tomorrow, date_to: Date.tomorrow) }
53
+
54
+ describe '.aggregate_on(:access_count)' do
55
+ before do
56
+ log_1.tap{ |l| l.access_count = 1 }.save
57
+ log_2.tap{ |l| l.access_count = 2 }.save
58
+ end
59
+
60
+ it 'sums the access_counts' do
61
+ Log.aggregate_on(:access_count).must_equal 3
62
+ end
63
+ end
64
+
65
+ describe '.aggregate_on(:browsers)' do
66
+ # not the key names have been abbreviated
67
+ before do
68
+ log_1.tap do |l|
69
+ l.browsers = {
70
+ "Mac" => {
71
+ "Saf" => { "8" => 1 }
72
+ },
73
+ "Win" => {
74
+ "Saf" => { "7" => 5 }
75
+ }
76
+ }
77
+ end.save
78
+
79
+ log_2.tap do |l|
80
+ l.browsers = {
81
+ "Mac" => {
82
+ "Saf" => { "8" => 10, "7" => 100 },
83
+ "Chr" => { "3" => 5 }
84
+ },
85
+ "Win" => {
86
+ "Saf" => { "7" => 100 },
87
+ "IE" => { "10" => 1 }
88
+ }
89
+ }
90
+ end.save
91
+ end
92
+
93
+ it 'sums the browsers' do
94
+ Log.aggregate_on(:browsers).must_equal({
95
+ "Mac" => {
96
+ "Saf" => { "8" => 11, "7" => 100 },
97
+ "Chr" => { "3" => 5 }
98
+ },
99
+ "Win" => {
100
+ "Saf" => { "7" => 105 },
101
+ "IE" => { "10" => 1 }
102
+ }
103
+ })
104
+ end
105
+ end
106
+
107
+ describe '.aggregate_on(:referers)' do
108
+ before do
109
+ log_1.tap{ |l| l.referers = { 'google' => 100, 'apple' => 1000 } }.save
110
+ log_2.tap{ |l| l.referers = { 'google' => 10, 'apple' => 100, 'ms' => 1 } }.save
111
+ end
112
+
113
+ it 'sums the referers' do
114
+ Log.aggregate_on(:referers).must_equal({ 'google' => 110, 'apple' => 1100, 'ms' => 1 })
115
+ end
116
+ end
117
+
118
+ describe '.aggregate_on(:countries)' do
119
+ before do
120
+ log_1.tap{ |l| l.countries = { 'CZ' => 100 } }.save
121
+ log_2.tap{ |l| l.countries = { 'DE' => 10 } }.save
122
+ end
123
+
124
+ it 'sums the countries' do
125
+ Log.aggregate_on(:countries).must_equal({ 'CZ' => 100, 'DE' => 10 })
126
+ end
127
+ end
128
+
129
+ describe '.aggregate_on(:unique_ids)' do
130
+ before do
131
+ log_1.tap{ |l| l.unique_ids = { '01234' => 100, '56789' => 100 } }.save
132
+ log_2.tap{ |l| l.unique_ids = { '56789' => 100 } }.save
133
+ end
134
+
135
+ it 'sums the unique_ids' do
136
+ Log.aggregate_on(:unique_ids).must_equal({ '01234' => 100, '56789' => 200 })
137
+ end
138
+ end
139
+
140
+ describe '.sum(:unique_ids)' do
141
+ before do
142
+ log_1.tap{ |l| l.unique_ids = { '01234' => 100, '56789' => 100 } }.save
143
+ log_2.tap{ |l| l.unique_ids = { '56789' => 100, 'ABCDE' => 1 } }.save
144
+ end
145
+
146
+ it 'sums the unique_ids' do
147
+ Log.sum(:unique_ids).must_equal 3
148
+ end
149
+ end
150
+ end
151
+
152
+ end
153
+ end