analytic 0.2.0 → 0.5.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.
- 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
|
+

|
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
|