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.
Files changed (37) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +270 -0
  4. data/app/assets/javascripts/rails_analytics/tracker.js +136 -0
  5. data/app/assets/stylesheets/rails_analytics/dashboard.css +156 -0
  6. data/app/controllers/rails_analytics/analytics_controller.rb +91 -0
  7. data/app/controllers/rails_analytics/application_controller.rb +24 -0
  8. data/app/controllers/rails_analytics/dashboards_controller.rb +41 -0
  9. data/app/helpers/rails_analytics/application_helper.rb +12 -0
  10. data/app/jobs/rails_analytics/retention_job.rb +27 -0
  11. data/app/models/rails_analytics/chart_builder.rb +54 -0
  12. data/app/models/rails_analytics/daily_salt.rb +26 -0
  13. data/app/models/rails_analytics/event.rb +15 -0
  14. data/app/models/rails_analytics/visit.rb +45 -0
  15. data/app/views/layouts/rails_analytics.html.erb +26 -0
  16. data/app/views/rails_analytics/dashboards/events.html.erb +50 -0
  17. data/app/views/rails_analytics/dashboards/overview.html.erb +102 -0
  18. data/app/views/rails_analytics/dashboards/visits.html.erb +39 -0
  19. data/config/locales/rails_analytics.en.yml +37 -0
  20. data/config/locales/rails_analytics.pt-BR.yml +37 -0
  21. data/config/routes.rb +11 -0
  22. data/db/migrate/20260906000001_create_rails_analytics_daily_salts.rb +14 -0
  23. data/db/migrate/20260906000002_create_rails_analytics_visits.rb +27 -0
  24. data/db/migrate/20260906000003_create_rails_analytics_events.rb +25 -0
  25. data/docs/dashboard-2.png +0 -0
  26. data/docs/dashboard.png +0 -0
  27. data/lib/generators/rails_analytics/install/install_generator.rb +50 -0
  28. data/lib/generators/rails_analytics/install/templates/initializer.rb +13 -0
  29. data/lib/rails_analytics/configuration.rb +17 -0
  30. data/lib/rails_analytics/engine.rb +32 -0
  31. data/lib/rails_analytics/identity.rb +51 -0
  32. data/lib/rails_analytics/ip_mask.rb +24 -0
  33. data/lib/rails_analytics/rack_attack.rb +22 -0
  34. data/lib/rails_analytics/stats.rb +146 -0
  35. data/lib/rails_analytics/version.rb +5 -0
  36. data/lib/rails_analytics.rb +20 -0
  37. 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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAnalytics
4
+ VERSION = "1.0.0"
5
+ 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: []