analytic 0.2.0 → 0.5.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +98 -9
- data/Rakefile +5 -0
- data/app/assets/images/analytic/icon.svg +1 -0
- data/app/controllers/analytic/dashboard_controller.rb +2 -1
- data/app/controllers/concerns/analytic/trackable.rb +50 -9
- data/app/helpers/analytic/application_helper.rb +42 -0
- data/app/jobs/analytic/track_job.rb +4 -2
- data/app/models/analytic/application_record.rb +2 -0
- data/app/models/analytic/dashboard.rb +61 -29
- data/app/models/analytic/{view.rb → event.rb} +4 -3
- data/app/models/analytic/period.rb +41 -0
- data/app/models/analytic/stat.rb +30 -0
- data/app/packs/analytic/application/components/chart.tsx +148 -0
- data/app/packs/analytic/application/components/index.tsx +21 -0
- data/app/packs/analytic/application/index.ts +1 -0
- data/app/packs/analytic/application/initializers/fontawesome.ts +7 -1
- data/app/packs/analytic/entrypoints/application.tailwind.css +39 -3
- data/app/views/analytic/dashboard/show.html.erb +61 -14
- data/app/views/layouts/analytic/application.html.erb +1 -7
- data/bin/rails +9 -7
- data/db/migrate/{20240805210911_create_analytic_views.rb → 20240805210911_create_analytic_events.rb} +6 -2
- data/lib/analytic/config.rb +63 -3
- data/lib/analytic/engine.rb +4 -0
- data/lib/analytic/version.rb +1 -1
- metadata +13 -11
- data/app/assets/builds/analytic/application.css +0 -810
- data/app/assets/builds/analytic/application.js +0 -54498
- data/app/assets/builds/analytic/application.js.map +0 -7
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: e283c032e4bc20c3b16244bee1b0a4b65e236626bdd9d75c05ef44116c6aa679
|
4
|
+
data.tar.gz: 8f9e592c750579ed57665364b2b9f0fd5c7cf6455e68e134e1b7817eda7c2713
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: b88a20a9fcdebaa18d0702a0dc22ceaf02cf88d383d0f4d3de59558c6073b0d1b85993cc49ef1a19d39d98fed32c321aa82241df42a32440e7bdb8138eab4af6
|
7
|
+
data.tar.gz: 9c61764e8b904c336551099e1155c4bcfdcda88f32cd622bb7f38d57adc157bfd8c9a1583cbb9e09e7866a1103c127758239efd0431e00dd4b5aca25160be5f8
|
data/README.md
CHANGED
@@ -4,6 +4,8 @@
|
|
4
4
|
|
5
5
|
# Analytic
|
6
6
|
|
7
|
+
![Demo](https://raw.githubusercontent.com/ksylvest/analytic/refs/heads/main/demo.png)
|
8
|
+
|
7
9
|
Analytic provides visitor / session / view tracking without the need for any third-party service.
|
8
10
|
|
9
11
|
## Installation
|
@@ -21,13 +23,6 @@ Rails.application.routes.draw do
|
|
21
23
|
end
|
22
24
|
```
|
23
25
|
|
24
|
-
```ruby
|
25
|
-
# config/initializer/analytic.rb
|
26
|
-
Analytic.configure do |config|
|
27
|
-
config.timezone = Time.find_zone('Canada/Pacific') # default is `Time.zone`
|
28
|
-
end
|
29
|
-
```
|
30
|
-
|
31
26
|
## Usage
|
32
27
|
|
33
28
|
Default inline tracking is configured with:
|
@@ -36,7 +31,7 @@ Default inline tracking is configured with:
|
|
36
31
|
class ApplicationController
|
37
32
|
include Analytic::Trackable
|
38
33
|
|
39
|
-
before_action {
|
34
|
+
before_action :analytic_track!, if: -> { request.format.html? }
|
40
35
|
end
|
41
36
|
```
|
42
37
|
|
@@ -46,11 +41,105 @@ Alternative job tracking is configured with:
|
|
46
41
|
class ApplicationController
|
47
42
|
include Analytic::Trackable
|
48
43
|
|
49
|
-
before_action {
|
44
|
+
before_action :analytic_enqueue_track_job!, if: -> { request.format.html? }
|
45
|
+
end
|
50
46
|
```
|
51
47
|
|
52
48
|
_note: a queue such as sidekiq, rescue, etc must be running to see tracking_
|
53
49
|
|
50
|
+
## Configuration
|
51
|
+
|
52
|
+
### Authentication the Dashboard
|
53
|
+
|
54
|
+
By default **Analytic** is not authenticated. To authenticate using `Rack::Auth` generate a random username and password:
|
55
|
+
|
56
|
+
```ruby
|
57
|
+
$ bin/rails secret # generate a secret
|
58
|
+
$ bin/rails credentials:edit
|
59
|
+
```
|
60
|
+
|
61
|
+
```yaml
|
62
|
+
analytic:
|
63
|
+
username: abc...
|
64
|
+
password: def...
|
65
|
+
```
|
66
|
+
|
67
|
+
```ruby
|
68
|
+
# config/initializers/analytic.rb
|
69
|
+
|
70
|
+
def same?(src, dst)
|
71
|
+
# https://api.rubyonrails.org/classes/ActiveSupport/SecurityUtils.html
|
72
|
+
ActiveSupport::SecurityUtils.secure_compare(src, dst)
|
73
|
+
end
|
74
|
+
|
75
|
+
Analytic.configure do |config|
|
76
|
+
unless Rails.env.local?
|
77
|
+
config.use Rack::Auth::Basic do |username, password|
|
78
|
+
credentials = Rails.application.credentials.analytic
|
79
|
+
same?(username, credentials.username) && same?(password, credentials.password)
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
```
|
84
|
+
|
85
|
+
### Capturing Extra Parameters
|
86
|
+
|
87
|
+
By default **Analytic** tracks `utm_source`, `utm_medium`, `utm_campaign`, `utm_content`, `utm_term`, `ref` and `source` parameters for each request. This list can be customized using:
|
88
|
+
|
89
|
+
```ruby
|
90
|
+
# config/initializer/analytic.rb
|
91
|
+
Analytic.configure do |config|
|
92
|
+
config.params << :gclid # e.g. Google
|
93
|
+
config.params << :msclkid # e.g. Bing
|
94
|
+
end
|
95
|
+
```
|
96
|
+
|
97
|
+
### Changing the Time Zone
|
98
|
+
|
99
|
+
By default **Analytic** uses `Time.zone`. The time zone can be changed using:
|
100
|
+
|
101
|
+
```ruby
|
102
|
+
# config/application.rb
|
103
|
+
class Application < Rails::Application
|
104
|
+
config.time_zone = 'Canada/Pacific'
|
105
|
+
end
|
106
|
+
```
|
107
|
+
|
108
|
+
The time zone may also be specified for **Analytic** using any `ActiveSupport::TimeZone`:
|
109
|
+
|
110
|
+
```ruby
|
111
|
+
# config/initializer/analytic.rb
|
112
|
+
Analytic.configure do |config|
|
113
|
+
config.time_zone = Time.find_zone('Canada/Pacific')
|
114
|
+
end
|
115
|
+
```
|
116
|
+
|
117
|
+
### Changing the Database
|
118
|
+
|
119
|
+
By default **Analytic** uses your apps main database. The database can be changed using:
|
120
|
+
|
121
|
+
```ruby
|
122
|
+
# config/initializer/analytic.rb
|
123
|
+
Analytic.configure do |config|
|
124
|
+
config.connects_to = { database: { writing: :primary, reading: :replica } }
|
125
|
+
end
|
126
|
+
```
|
127
|
+
|
128
|
+
### Overriding IP Masking Rules
|
129
|
+
|
130
|
+
By default IP addresses are masked as follows:
|
131
|
+
|
132
|
+
- IPv4: limit to leading 24-bits (e.g. '255.255.255.255' becomes '255.255.255.0')
|
133
|
+
- IPv6: limit to leading 48-bits (e.g. 'ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff' becomes 'ffff:ffff:ffff:0000:0000:0000:0000:0000')
|
134
|
+
|
135
|
+
To override both an `ip_v4_mask` and `ip_v6_mask` are assignable:
|
136
|
+
|
137
|
+
```ruby
|
138
|
+
Analytic.configure do |config|
|
139
|
+
config.ip_v4_mask = 24 # nil skips masking
|
140
|
+
config.ip_v6_mask = 48 # nil skips masking
|
141
|
+
```
|
142
|
+
|
54
143
|
## License
|
55
144
|
|
56
145
|
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
CHANGED
@@ -0,0 +1 @@
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512"><defs><linearGradient id="a" x1="0%" x2="100%" y1="100%" y2="0%"><stop offset="0%" stop-color="#06B6D4"/><stop offset="49.9%" stop-color="#6366F1"/><stop offset="100%" stop-color="#D946EF"/></linearGradient></defs><g fill="none" fill-rule="evenodd"><rect width="512" height="512" fill="url(#a)" rx="256"/><circle cx="256" cy="256" r="184" stroke="#FFF" stroke-width="48"/><rect width="48" height="128" x="232" y="210" fill="#FFF" rx="16"/><rect width="48" height="96" x="168" y="242" fill="#FFF" rx="16"/><rect width="48" height="164" x="296" y="174" fill="#FFF" rx="16"/></g></svg>
|
@@ -3,25 +3,31 @@
|
|
3
3
|
module Analytic
|
4
4
|
module Trackable
|
5
5
|
# @param params [Hash]
|
6
|
-
def analytic_track!
|
7
|
-
Analytic::
|
6
|
+
def analytic_track!
|
7
|
+
Analytic::Event.create!(
|
8
8
|
timestamp: Time.current,
|
9
9
|
session_id: analytic_session_id,
|
10
10
|
visitor_id: analytic_visitor_id,
|
11
|
-
|
12
|
-
|
13
|
-
|
11
|
+
ip: analytic_ip,
|
12
|
+
host: analytic_host,
|
13
|
+
path: analytic_path,
|
14
|
+
referrer: analytic_referrer,
|
15
|
+
user_agent: request.user_agent,
|
16
|
+
params: analytic_params
|
14
17
|
)
|
15
18
|
end
|
16
19
|
|
17
20
|
# @param params [Hash]
|
18
|
-
def analytic_enqueue_track_job!
|
21
|
+
def analytic_enqueue_track_job!
|
19
22
|
Analytic::TrackJob.perform_later(
|
20
23
|
session_id: analytic_session_id,
|
21
24
|
visitor_id: analytic_visitor_id,
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
+
ip: analytic_ip,
|
26
|
+
host: analytic_host,
|
27
|
+
path: analytic_path,
|
28
|
+
referrer: analytic_referrer,
|
29
|
+
user_agent: request.user_agent,
|
30
|
+
params: analytic_params
|
25
31
|
)
|
26
32
|
end
|
27
33
|
|
@@ -34,5 +40,40 @@ module Analytic
|
|
34
40
|
def analytic_visitor_id
|
35
41
|
cookies.permanent[:analytic_visitor_id] ||= SecureRandom.uuid
|
36
42
|
end
|
43
|
+
|
44
|
+
# @return [IPAddr]
|
45
|
+
def analytic_ip
|
46
|
+
ip_addr = IPAddr.new(request.remote_ip)
|
47
|
+
|
48
|
+
return ip_addr.mask(Analytic.config.ip_v4_mask) if Analytic.config.ip_v4_mask? && ip_addr.ipv4?
|
49
|
+
return ip_addr.mask(Analytic.config.ip_v6_mask) if Analytic.config.ip_v6_mask? && ip_addr.ipv6?
|
50
|
+
|
51
|
+
ip_addr
|
52
|
+
end
|
53
|
+
|
54
|
+
# @return [String]
|
55
|
+
def analytic_host
|
56
|
+
request.host
|
57
|
+
end
|
58
|
+
|
59
|
+
# @return [String]
|
60
|
+
def analytic_path
|
61
|
+
request.path
|
62
|
+
end
|
63
|
+
|
64
|
+
# @return [String]
|
65
|
+
def analytic_referrer
|
66
|
+
request.referer
|
67
|
+
end
|
68
|
+
|
69
|
+
# @return [String]
|
70
|
+
def analytic_user_agent
|
71
|
+
request.user_agent
|
72
|
+
end
|
73
|
+
|
74
|
+
# @return [Hash]
|
75
|
+
def analytic_params
|
76
|
+
params.slice(*Analytic.config.params)
|
77
|
+
end
|
37
78
|
end
|
38
79
|
end
|
@@ -2,5 +2,47 @@
|
|
2
2
|
|
3
3
|
module Analytic
|
4
4
|
module ApplicationHelper
|
5
|
+
def react(component:)
|
6
|
+
tag.div(data: { react: component })
|
7
|
+
end
|
8
|
+
|
9
|
+
# @param icon [String] e.g. fa fa-user
|
10
|
+
def fa_icon_tag(icon)
|
11
|
+
tag.i(class: icon)
|
12
|
+
end
|
13
|
+
|
14
|
+
# @param delta [Float]
|
15
|
+
# @return [String]
|
16
|
+
def delta_icon_tag(delta)
|
17
|
+
if delta.zero?
|
18
|
+
fa_icon_tag('fa-solid fa-xmark')
|
19
|
+
elsif delta.positive?
|
20
|
+
fa_icon_tag('fa-solid fa-arrow-up')
|
21
|
+
elsif delta.negative?
|
22
|
+
fa_icon_tag('fa-solid fa-arrow-down')
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
# @param delta [Float]
|
27
|
+
# @return [String]
|
28
|
+
def delta_percentage_tag(delta)
|
29
|
+
tag.span(number_to_percentage(delta.abs * 100.0, precision: 2), class: 'delta__value')
|
30
|
+
end
|
31
|
+
|
32
|
+
# @param delta [Float, nil]
|
33
|
+
def delta_tag(delta)
|
34
|
+
return if delta.nil?
|
35
|
+
|
36
|
+
modifier =
|
37
|
+
if delta.zero? then 'delta--neutral'
|
38
|
+
elsif delta.positive? then 'delta--positive'
|
39
|
+
elsif delta.negative? then 'delta--negative'
|
40
|
+
end
|
41
|
+
|
42
|
+
tag.div(class: "delta #{modifier}") do
|
43
|
+
concat(delta_icon_tag(delta))
|
44
|
+
concat(delta_percentage_tag(delta))
|
45
|
+
end
|
46
|
+
end
|
5
47
|
end
|
6
48
|
end
|
@@ -10,12 +10,14 @@ module Analytic
|
|
10
10
|
# @param host [String]
|
11
11
|
# @param path [String]
|
12
12
|
# @param params [Hash]
|
13
|
-
def perform(session_id:, visitor_id:, host:, path:, timestamp: Time.current, params: {})
|
14
|
-
Analytic::
|
13
|
+
def perform(session_id:, visitor_id:, host:, path:, ip:, timestamp: Time.current, params: {})
|
14
|
+
Analytic::Event.create!(
|
15
|
+
timestamp:,
|
15
16
|
session_id:,
|
16
17
|
visitor_id:,
|
17
18
|
host:,
|
18
19
|
path:,
|
20
|
+
ip:,
|
19
21
|
params:
|
20
22
|
)
|
21
23
|
end
|
@@ -4,19 +4,40 @@ module Analytic
|
|
4
4
|
class Dashboard
|
5
5
|
PAGES_LIMIT = 8
|
6
6
|
|
7
|
+
# @return [String]
|
8
|
+
attr_reader :period
|
9
|
+
|
7
10
|
# @param period [String] today, yesterday, week, month, year
|
8
11
|
def initialize(period:)
|
9
12
|
@period = period
|
10
13
|
end
|
11
14
|
|
12
|
-
# @return [
|
13
|
-
|
15
|
+
# @return [Stat]
|
16
|
+
def views
|
17
|
+
Stat.new(
|
18
|
+
current: current.count,
|
19
|
+
prior: prior&.count,
|
20
|
+
name: 'Views'
|
21
|
+
)
|
22
|
+
end
|
14
23
|
|
15
|
-
# @return [
|
16
|
-
|
24
|
+
# @return [Stat]
|
25
|
+
def visitors
|
26
|
+
Stat.new(
|
27
|
+
current: current.distinct_visitors_count,
|
28
|
+
prior: prior&.distinct_visitors_count,
|
29
|
+
name: 'Visitors'
|
30
|
+
)
|
31
|
+
end
|
17
32
|
|
18
|
-
# @return [
|
19
|
-
|
33
|
+
# @return [Stat]
|
34
|
+
def sessions
|
35
|
+
Stat.new(
|
36
|
+
current: current.distinct_sessions_count,
|
37
|
+
prior: prior&.distinct_sessions_count,
|
38
|
+
name: 'Sessions'
|
39
|
+
)
|
40
|
+
end
|
20
41
|
|
21
42
|
# @return [String]
|
22
43
|
def name
|
@@ -27,39 +48,50 @@ module Analytic
|
|
27
48
|
end
|
28
49
|
end
|
29
50
|
|
30
|
-
# @return [
|
31
|
-
def
|
32
|
-
|
33
|
-
.group(:host)
|
34
|
-
.group(:path)
|
35
|
-
.order(count: :desc)
|
36
|
-
.limit(PAGES_LIMIT)
|
37
|
-
.pluck(:host, :path, Arel.sql('COUNT(*) AS count'))
|
51
|
+
# @return [Period]
|
52
|
+
def current
|
53
|
+
@current ||= Period.new(range:)
|
38
54
|
end
|
39
55
|
|
40
|
-
|
56
|
+
# @return [Period]
|
57
|
+
def prior
|
58
|
+
return unless range?
|
59
|
+
return @prior if defined?(@prior)
|
41
60
|
|
42
|
-
|
43
|
-
|
44
|
-
|
61
|
+
@prior ||= Period.new(range: ((range.min - duration)..(range.max - duration)))
|
62
|
+
end
|
63
|
+
|
64
|
+
# @return [Boolean]
|
65
|
+
def range?
|
66
|
+
@period.present?
|
45
67
|
end
|
46
68
|
|
47
69
|
# @return [Range<Time>]
|
48
70
|
def range
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
71
|
+
(now - duration)..now if range?
|
72
|
+
end
|
73
|
+
|
74
|
+
# @return [Interval]
|
75
|
+
def duration
|
76
|
+
@duration ||=
|
77
|
+
case @period
|
78
|
+
when '24h' then 24.hours
|
79
|
+
when '7d' then 7.days
|
80
|
+
when '4w' then 4.weeks
|
81
|
+
when '12m' then 12.months
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
protected
|
86
|
+
|
87
|
+
# @return [Time]
|
88
|
+
def now
|
89
|
+
@now ||= time_zone.now
|
58
90
|
end
|
59
91
|
|
60
92
|
# @return [ActiveSupport::TimeZone]
|
61
|
-
def
|
62
|
-
Analytic.config.
|
93
|
+
def time_zone
|
94
|
+
@time_zone ||= Analytic.config.time_zone || Time.zone
|
63
95
|
end
|
64
96
|
end
|
65
97
|
end
|
@@ -1,23 +1,24 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module Analytic
|
4
|
-
class
|
4
|
+
class Event < ApplicationRecord
|
5
5
|
validates :timestamp, presence: true
|
6
6
|
validates :visitor_id, presence: true
|
7
7
|
validates :session_id, presence: true
|
8
8
|
validates :path, presence: true
|
9
9
|
validates :host, presence: true
|
10
|
+
validates :ip, presence: true
|
10
11
|
|
11
12
|
scope :within, ->(range) { where(timestamp: range) if range }
|
12
13
|
|
13
14
|
# @return [Integer]
|
14
15
|
def self.distinct_visitors_count
|
15
|
-
count('DISTINCT "
|
16
|
+
count('DISTINCT "analytic_events"."visitor_id"')
|
16
17
|
end
|
17
18
|
|
18
19
|
# @return [Integer]
|
19
20
|
def self.distinct_sessions_count
|
20
|
-
count('DISTINCT "
|
21
|
+
count('DISTINCT "analytic_events"."session_id"')
|
21
22
|
end
|
22
23
|
end
|
23
24
|
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Analytic
|
4
|
+
class Period
|
5
|
+
PAGES_LIMIT = 8
|
6
|
+
|
7
|
+
# @return [Range <Time>]
|
8
|
+
attr_accessor :range
|
9
|
+
|
10
|
+
# @param dates [Range<Time>]
|
11
|
+
def initialize(range:)
|
12
|
+
@range = range
|
13
|
+
end
|
14
|
+
|
15
|
+
# @return [Integer]
|
16
|
+
delegate :count, to: :views
|
17
|
+
|
18
|
+
# @return [Integer]
|
19
|
+
delegate :distinct_visitors_count, to: :views
|
20
|
+
|
21
|
+
# @return [Integer]
|
22
|
+
delegate :distinct_sessions_count, to: :views
|
23
|
+
|
24
|
+
# @return [Array<Array(String, String, Integer)>]
|
25
|
+
def pages
|
26
|
+
views
|
27
|
+
.group(:host)
|
28
|
+
.group(:path)
|
29
|
+
.order(count: :desc)
|
30
|
+
.limit(PAGES_LIMIT)
|
31
|
+
.pluck(:host, :path, Arel.sql('COUNT(*) AS count'))
|
32
|
+
end
|
33
|
+
|
34
|
+
protected
|
35
|
+
|
36
|
+
# @return [Analytic::Event::ActiveRecord_Relation]
|
37
|
+
def views
|
38
|
+
@views ||= Analytic::Event.within(@range)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Analytic
|
4
|
+
class Stat
|
5
|
+
# @param current [Integer]
|
6
|
+
# @param prior [Integer]
|
7
|
+
# @param name [String]
|
8
|
+
def initialize(current:, prior:, name:)
|
9
|
+
@current = current
|
10
|
+
@prior = prior
|
11
|
+
@name = name
|
12
|
+
end
|
13
|
+
|
14
|
+
# @return [Integer]
|
15
|
+
def count
|
16
|
+
@current
|
17
|
+
end
|
18
|
+
|
19
|
+
# e.g. prior = 4 / current = 5 / delta = + 0.25
|
20
|
+
# e.g. prior = 4 / current = 3 / delta = - 0.25
|
21
|
+
#
|
22
|
+
# @return [Float, nil]
|
23
|
+
def delta
|
24
|
+
return if @prior.nil?
|
25
|
+
return if @prior.zero?
|
26
|
+
|
27
|
+
(@current - @prior).fdiv(@prior)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|