rails_analytics 1.0.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 +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +270 -0
- data/app/assets/javascripts/rails_analytics/tracker.js +136 -0
- data/app/assets/stylesheets/rails_analytics/dashboard.css +156 -0
- data/app/controllers/rails_analytics/analytics_controller.rb +91 -0
- data/app/controllers/rails_analytics/application_controller.rb +24 -0
- data/app/controllers/rails_analytics/dashboards_controller.rb +41 -0
- data/app/helpers/rails_analytics/application_helper.rb +12 -0
- data/app/jobs/rails_analytics/retention_job.rb +27 -0
- data/app/models/rails_analytics/chart_builder.rb +54 -0
- data/app/models/rails_analytics/daily_salt.rb +26 -0
- data/app/models/rails_analytics/event.rb +15 -0
- data/app/models/rails_analytics/visit.rb +45 -0
- data/app/views/layouts/rails_analytics.html.erb +26 -0
- data/app/views/rails_analytics/dashboards/events.html.erb +50 -0
- data/app/views/rails_analytics/dashboards/overview.html.erb +102 -0
- data/app/views/rails_analytics/dashboards/visits.html.erb +39 -0
- data/config/locales/rails_analytics.en.yml +37 -0
- data/config/locales/rails_analytics.pt-BR.yml +37 -0
- data/config/routes.rb +11 -0
- data/db/migrate/20260906000001_create_rails_analytics_daily_salts.rb +14 -0
- data/db/migrate/20260906000002_create_rails_analytics_visits.rb +27 -0
- data/db/migrate/20260906000003_create_rails_analytics_events.rb +25 -0
- data/docs/dashboard-2.png +0 -0
- data/docs/dashboard.png +0 -0
- data/lib/generators/rails_analytics/install/install_generator.rb +50 -0
- data/lib/generators/rails_analytics/install/templates/initializer.rb +13 -0
- data/lib/rails_analytics/configuration.rb +17 -0
- data/lib/rails_analytics/engine.rb +32 -0
- data/lib/rails_analytics/identity.rb +51 -0
- data/lib/rails_analytics/ip_mask.rb +24 -0
- data/lib/rails_analytics/rack_attack.rb +22 -0
- data/lib/rails_analytics/stats.rb +146 -0
- data/lib/rails_analytics/version.rb +5 -0
- data/lib/rails_analytics.rb +20 -0
- metadata +95 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RailsAnalytics.configure do |config|
|
|
4
|
+
# Método de autenticação para o dashboard.
|
|
5
|
+
# Pode ser um Symbol (ex: :authenticate_admin!) ou um callable (ex: -> { current_user&.admin? }).
|
|
6
|
+
config.auth_callback = :authenticate_admin!
|
|
7
|
+
|
|
8
|
+
# Caminho onde o engine será montado no routes.rb.
|
|
9
|
+
config.mount_path = "/analytics"
|
|
10
|
+
|
|
11
|
+
# Período padrão para as consultas do dashboard (ex: 30.days).
|
|
12
|
+
config.since_default = 30.days
|
|
13
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAnalytics
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_accessor :auth_callback, :mount_path, :since_default
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@auth_callback = :authenticate_admin!
|
|
9
|
+
@mount_path = "/analytics"
|
|
10
|
+
@since_default = 30.days
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def auth_callback_callable?
|
|
14
|
+
auth_callback.respond_to?(:call)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAnalytics
|
|
4
|
+
class Engine < ::Rails::Engine
|
|
5
|
+
isolate_namespace RailsAnalytics
|
|
6
|
+
|
|
7
|
+
config.generators do |g|
|
|
8
|
+
g.test_framework :minitest
|
|
9
|
+
g.helper false
|
|
10
|
+
g.assets false
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
initializer "rails_analytics.assets" do |app|
|
|
14
|
+
if app.config.respond_to?(:assets)
|
|
15
|
+
app.config.assets.precompile += %w[rails_analytics/tracker.js rails_analytics/dashboard.css]
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
initializer "rails_analytics.helper" do
|
|
20
|
+
ActiveSupport.on_load(:action_view) do
|
|
21
|
+
include RailsAnalytics::ApplicationHelper
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
initializer "rails_analytics.rack_attack" do
|
|
26
|
+
if defined?(Rack::Attack)
|
|
27
|
+
require "rails_analytics/rack_attack"
|
|
28
|
+
RailsAnalytics::RackAttack.configure
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module RailsAnalytics
|
|
6
|
+
module Identity
|
|
7
|
+
# Coarse UA bucket: "<browser_family>:<os_family>"
|
|
8
|
+
# Never stores the full user agent string.
|
|
9
|
+
def self.coarse_ua_bucket(user_agent)
|
|
10
|
+
return "Other:Other" if user_agent.nil? || user_agent.empty?
|
|
11
|
+
|
|
12
|
+
ua = user_agent.to_s
|
|
13
|
+
|
|
14
|
+
os = if ua.match?(/Windows|Win64|Win32/i)
|
|
15
|
+
"Windows"
|
|
16
|
+
elsif ua.match?(/iPhone|iPad|iOS/i)
|
|
17
|
+
"iOS"
|
|
18
|
+
elsif ua.match?(/Macintosh|Mac OS X|macOS/i)
|
|
19
|
+
"macOS"
|
|
20
|
+
elsif ua.match?(/Android/i)
|
|
21
|
+
"Android"
|
|
22
|
+
elsif ua.match?(/Linux|X11/i)
|
|
23
|
+
"Linux"
|
|
24
|
+
else
|
|
25
|
+
"Other"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
browser = if ua.match?(/Edg\//i)
|
|
29
|
+
"Edge"
|
|
30
|
+
elsif ua.match?(/OPR|Opera/i)
|
|
31
|
+
"Opera"
|
|
32
|
+
elsif ua.match?(/Chrome/i)
|
|
33
|
+
"Chrome"
|
|
34
|
+
elsif ua.match?(/Safari/i)
|
|
35
|
+
"Safari"
|
|
36
|
+
elsif ua.match?(/Firefox/i)
|
|
37
|
+
"Firefox"
|
|
38
|
+
else
|
|
39
|
+
"Other"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
"#{browser}:#{os}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.key(masked_ip:, user_agent:, date:)
|
|
46
|
+
bucket = coarse_ua_bucket(user_agent)
|
|
47
|
+
salt = RailsAnalytics::DailySalt.for_date(date)
|
|
48
|
+
Digest::SHA256.hexdigest("#{masked_ip}:#{bucket}:#{salt}")
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ipaddr"
|
|
4
|
+
|
|
5
|
+
module RailsAnalytics
|
|
6
|
+
module IpMask
|
|
7
|
+
def self.mask(ip)
|
|
8
|
+
return nil if ip.blank?
|
|
9
|
+
|
|
10
|
+
addr = IPAddr.new(ip.to_s)
|
|
11
|
+
if addr.ipv4?
|
|
12
|
+
addr.mask(24).to_s
|
|
13
|
+
elsif addr.ipv6?
|
|
14
|
+
if addr.ipv4_mapped?
|
|
15
|
+
IPAddr.new("::ffff:#{addr.native.mask(24)}").to_s
|
|
16
|
+
else
|
|
17
|
+
addr.mask(48).to_s
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
rescue IPAddr::InvalidAddressError
|
|
21
|
+
nil
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAnalytics
|
|
4
|
+
module RackAttack
|
|
5
|
+
def self.configure
|
|
6
|
+
return unless defined?(::Rack::Attack)
|
|
7
|
+
|
|
8
|
+
masked_ip = ->(req) {
|
|
9
|
+
ip = req.env["HTTP_CF_CONNECTING_IP"] || req.ip
|
|
10
|
+
RailsAnalytics::IpMask.mask(ip) || "unknown"
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
# Throttle the collection endpoint: 20 req/min per masked IP.
|
|
14
|
+
# Path ends in /collect regardless of mount_path.
|
|
15
|
+
::Rack::Attack.throttle("rails_analytics_collect_by_ip", limit: 20, period: 60.seconds) do |req|
|
|
16
|
+
if req.post? && req.path.end_with?("/collect")
|
|
17
|
+
masked_ip.call(req)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAnalytics
|
|
4
|
+
module Stats
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def visits_by_day(since: default_since)
|
|
8
|
+
base_scope(since).group("date(started_at)")
|
|
9
|
+
.order("date(started_at)")
|
|
10
|
+
.count
|
|
11
|
+
.transform_keys { |k| k.is_a?(String) ? Date.parse(k) : k }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def unique_visitors_by_day(since: default_since)
|
|
15
|
+
base_scope(since).group("date(started_at)")
|
|
16
|
+
.order("date(started_at)")
|
|
17
|
+
.distinct
|
|
18
|
+
.count(:anonymity_key)
|
|
19
|
+
.transform_keys { |k| k.is_a?(String) ? Date.parse(k) : k }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def total_visits(since: default_since)
|
|
23
|
+
base_scope(since).count
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def total_unique_visitors(since: default_since)
|
|
27
|
+
base_scope(since).distinct.count(:anonymity_key)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def total_events(since: default_since)
|
|
31
|
+
Event.since(since).count
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def top_sources(limit: 10, since: default_since)
|
|
35
|
+
base_scope(since).where.not(referrer_domain: nil)
|
|
36
|
+
.group(:referrer_domain)
|
|
37
|
+
.order("count_all desc")
|
|
38
|
+
.limit(limit)
|
|
39
|
+
.count
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def top_events(limit: 10, since: default_since)
|
|
43
|
+
Event.since(since).group(:name)
|
|
44
|
+
.order("count_all desc")
|
|
45
|
+
.limit(limit)
|
|
46
|
+
.count
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def event_counts_by_name(since: default_since)
|
|
50
|
+
Event.since(since).group(:name).count
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def events_by_name(name, since: default_since)
|
|
54
|
+
Event.since(since).named(name)
|
|
55
|
+
.group("date(time)")
|
|
56
|
+
.order("date(time)")
|
|
57
|
+
.count
|
|
58
|
+
.transform_keys { |k| k.is_a?(String) ? Date.parse(k) : k }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def bounce_rate(since: default_since)
|
|
62
|
+
total = base_scope(since).count
|
|
63
|
+
return 0.0 if total.zero?
|
|
64
|
+
|
|
65
|
+
visits_with_events = base_scope(since)
|
|
66
|
+
.joins(:events)
|
|
67
|
+
.distinct
|
|
68
|
+
.count
|
|
69
|
+
bounced = total - visits_with_events
|
|
70
|
+
(bounced.to_f / total).round(4)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def devices(since: default_since)
|
|
74
|
+
base_scope(since).group(:device_type).count
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def utm_breakdown(since: default_since)
|
|
78
|
+
base_scope(since).where.not(utm_source: nil).or(Visit.where.not(utm_campaign: nil))
|
|
79
|
+
.group(:utm_source, :utm_campaign, :utm_medium, :utm_term, :utm_content)
|
|
80
|
+
.count
|
|
81
|
+
.map do |keys, count|
|
|
82
|
+
{ "utm_source" => keys[0], "utm_campaign" => keys[1], "utm_medium" => keys[2],
|
|
83
|
+
"utm_term" => keys[3], "utm_content" => keys[4], "count" => count }
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def visits_paginated(page: 1, per_page: 20, since: default_since)
|
|
88
|
+
base = base_scope(since)
|
|
89
|
+
total = base.count
|
|
90
|
+
|
|
91
|
+
day_rows = base.group("date(started_at)")
|
|
92
|
+
.order("date(started_at) desc")
|
|
93
|
+
.select(
|
|
94
|
+
"date(started_at) as day",
|
|
95
|
+
"count(*) as visits",
|
|
96
|
+
"count(distinct anonymity_key) as unique_visitors"
|
|
97
|
+
)
|
|
98
|
+
.offset((page - 1) * per_page)
|
|
99
|
+
.limit(per_page)
|
|
100
|
+
|
|
101
|
+
top_sources = base.where.not(referrer_domain: nil)
|
|
102
|
+
.group("date(started_at)", :referrer_domain)
|
|
103
|
+
.count
|
|
104
|
+
|
|
105
|
+
data = day_rows.map do |row|
|
|
106
|
+
day = row.day.to_date
|
|
107
|
+
sources = top_sources.select { |(d, _ref), _count| d.to_date == day }
|
|
108
|
+
.max_by { |(_d, _ref), count| count }
|
|
109
|
+
{
|
|
110
|
+
date: day,
|
|
111
|
+
visits: row.visits,
|
|
112
|
+
unique_visitors: row.unique_visitors,
|
|
113
|
+
top_source: sources&.first&.last
|
|
114
|
+
}
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
{ data: data, total: total, page: page, per_page: per_page }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def events_paginated(name: nil, page: 1, per_page: 20, since: default_since)
|
|
121
|
+
scope = Event.since(since)
|
|
122
|
+
scope = scope.named(name) if name.present?
|
|
123
|
+
|
|
124
|
+
total = scope.group("date(time)", :name).count.size
|
|
125
|
+
rows = scope.group("date(time)", :name)
|
|
126
|
+
.order("date(time) desc")
|
|
127
|
+
.select("date(time) as day, name, count(*) as count")
|
|
128
|
+
.offset((page - 1) * per_page)
|
|
129
|
+
.limit(per_page)
|
|
130
|
+
|
|
131
|
+
data = rows.map do |r|
|
|
132
|
+
{ date: r.day.to_date, name: r.name, count: r.count }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
{ data: data, total: total, page: page, per_page: per_page }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def base_scope(since)
|
|
139
|
+
Visit.since(since)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def default_since
|
|
143
|
+
RailsAnalytics.config.since_default.ago
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "rails_analytics/version"
|
|
4
|
+
require_relative "rails_analytics/configuration"
|
|
5
|
+
require_relative "rails_analytics/ip_mask"
|
|
6
|
+
require_relative "rails_analytics/identity"
|
|
7
|
+
require_relative "rails_analytics/stats"
|
|
8
|
+
require_relative "rails_analytics/engine" if defined?(Rails::Railtie)
|
|
9
|
+
|
|
10
|
+
module RailsAnalytics
|
|
11
|
+
class << self
|
|
12
|
+
def config
|
|
13
|
+
@config ||= Configuration.new
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def configure
|
|
17
|
+
yield config if block_given?
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: rails_analytics
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- br4zz4
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-09-07 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: rails
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '7.0'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - ">="
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '7.0'
|
|
27
|
+
description: Rails Engine with server-side tracker, aggregated dashboard, IP masking,
|
|
28
|
+
daily anonymity key rotation, UTM tracking, and 6-month retention. No third-party
|
|
29
|
+
analytics dependencies.
|
|
30
|
+
email:
|
|
31
|
+
- dev@br4zz4.com
|
|
32
|
+
executables: []
|
|
33
|
+
extensions: []
|
|
34
|
+
extra_rdoc_files: []
|
|
35
|
+
files:
|
|
36
|
+
- MIT-LICENSE
|
|
37
|
+
- README.md
|
|
38
|
+
- app/assets/javascripts/rails_analytics/tracker.js
|
|
39
|
+
- app/assets/stylesheets/rails_analytics/dashboard.css
|
|
40
|
+
- app/controllers/rails_analytics/analytics_controller.rb
|
|
41
|
+
- app/controllers/rails_analytics/application_controller.rb
|
|
42
|
+
- app/controllers/rails_analytics/dashboards_controller.rb
|
|
43
|
+
- app/helpers/rails_analytics/application_helper.rb
|
|
44
|
+
- app/jobs/rails_analytics/retention_job.rb
|
|
45
|
+
- app/models/rails_analytics/chart_builder.rb
|
|
46
|
+
- app/models/rails_analytics/daily_salt.rb
|
|
47
|
+
- app/models/rails_analytics/event.rb
|
|
48
|
+
- app/models/rails_analytics/visit.rb
|
|
49
|
+
- app/views/layouts/rails_analytics.html.erb
|
|
50
|
+
- app/views/rails_analytics/dashboards/events.html.erb
|
|
51
|
+
- app/views/rails_analytics/dashboards/overview.html.erb
|
|
52
|
+
- app/views/rails_analytics/dashboards/visits.html.erb
|
|
53
|
+
- config/locales/rails_analytics.en.yml
|
|
54
|
+
- config/locales/rails_analytics.pt-BR.yml
|
|
55
|
+
- config/routes.rb
|
|
56
|
+
- db/migrate/20260906000001_create_rails_analytics_daily_salts.rb
|
|
57
|
+
- db/migrate/20260906000002_create_rails_analytics_visits.rb
|
|
58
|
+
- db/migrate/20260906000003_create_rails_analytics_events.rb
|
|
59
|
+
- docs/dashboard-2.png
|
|
60
|
+
- docs/dashboard.png
|
|
61
|
+
- lib/generators/rails_analytics/install/install_generator.rb
|
|
62
|
+
- lib/generators/rails_analytics/install/templates/initializer.rb
|
|
63
|
+
- lib/rails_analytics.rb
|
|
64
|
+
- lib/rails_analytics/configuration.rb
|
|
65
|
+
- lib/rails_analytics/engine.rb
|
|
66
|
+
- lib/rails_analytics/identity.rb
|
|
67
|
+
- lib/rails_analytics/ip_mask.rb
|
|
68
|
+
- lib/rails_analytics/rack_attack.rb
|
|
69
|
+
- lib/rails_analytics/stats.rb
|
|
70
|
+
- lib/rails_analytics/version.rb
|
|
71
|
+
homepage: https://github.com/br4zz4/rails-analytics
|
|
72
|
+
licenses:
|
|
73
|
+
- MIT
|
|
74
|
+
metadata:
|
|
75
|
+
rubygems_mfa_required: 'true'
|
|
76
|
+
post_install_message:
|
|
77
|
+
rdoc_options: []
|
|
78
|
+
require_paths:
|
|
79
|
+
- lib
|
|
80
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
81
|
+
requirements:
|
|
82
|
+
- - ">="
|
|
83
|
+
- !ruby/object:Gem::Version
|
|
84
|
+
version: '3.0'
|
|
85
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
86
|
+
requirements:
|
|
87
|
+
- - ">="
|
|
88
|
+
- !ruby/object:Gem::Version
|
|
89
|
+
version: '0'
|
|
90
|
+
requirements: []
|
|
91
|
+
rubygems_version: 3.4.10
|
|
92
|
+
signing_key:
|
|
93
|
+
specification_version: 4
|
|
94
|
+
summary: Privacy-first analytics engine for Rails — cookie-less, LGPD-compliant.
|
|
95
|
+
test_files: []
|