Rails Analytics
A privacy-first analytics engine for Rails. Cookie-less, LGPD-compliant, and free of third-party analytics dependencies — it collects traffic server-side, stores it in your own database, and renders an aggregated dashboard with zero client-side JS dependencies.
The dashboard's chart shows CSS-only tooltips: hover (or keyboard-focus) any data point to see its date and visit count — labels on the x-axis mark first, last and month boundaries. No JavaScript anywhere.
The Journeys view lists recent sessions as cards: each card shows the user's ordered trajectory (landing page → clicks → scroll depth → forms) with arrows between steps, hover/focus details on every step, and origin, device and UTM in the card header. Filter by event name to see the journeys that contain a specific click/topic.
Screenshots
| Dashboard — aggregated overview | Dashboard — events & visits |
|---|---|
![]() |
![]() |
How it works
┌─────────────┐ tracker.js (injetado no layout)
│ Navegador │──────────────────────────────► POST /analytics/collect
└─────────────┘ │
▼
┌──────────────────────┐
│ AnalyticsController │ assinado com token,
│ (rails_analytics) │ IP mascarado antes
└──────────┬───────────┘ de persistir
▼
┌──────────────────────┐
│ rails_analytics_ │ visits + events
│ visits / events │ (6-month retention)
└──────────┬───────────┘
▼
┌──────────────────────┐
│ Dashboard em │ overview / events /
│ /analytics │ visits
└──────────────────────┘
- The tracker JS (injected by the generator into your layout
<head>) collects path, referrer domain, viewport, language, and a per-page nonce — no cookies, no localStorage. - On
load, the tracker POSTs a signed token to the engine's collect endpoint usingnavigator.sendBeacon(same-origin, no CORS). - The engine verifies the token, masks the IP (before anything hits disk),
derives a daily anonymity key, and stores a
Visitrow. Custom events are stored asEventrows linked to the visit. - The dashboard at
/analytics(defaultmount_path) shows aggregated metrics from pure SQL queries in three views: overview, events, and visits.
Requirements
- Ruby >= 3.0
- Rails >= 7.0 (tested on 8.1)
- Database: SQLite, PostgreSQL or MySQL (anything Active Record supports)
Installation
1. Add the gem
# Gemfile
gem "rails_analytics"bundle install2. Run the install generator
bin/rails generate rails_analytics:installThe generator does three things:
| Action | File | Detail |
|---|---|---|
| Copies the migrations | db/migrate/xxx_create_rails_analytics_*.rb |
creates the rails_analytics_daily_salts, rails_analytics_visits and rails_analytics_events tables |
| Mounts the engine | config/routes.rb |
adds mount RailsAnalytics::Engine => RailsAnalytics.config.mount_path (default /analytics) |
| Injects the tracker | app/views/layouts/*.html.erb |
adds <%= rails_analytics_tracker_tag %> to the <head> of the first layout |
3. Migrate the database
bin/rails db:migrateDone — collection is automatic and the dashboard lives at /analytics (or your configured mount_path).
Mounting
The engine's mount point is a single configuration — the mount line in
config/routes.rb. There is no separate mount_path to keep in sync: the tracker tag,
the dashboard CSS and the collect endpoint all derive their URLs from the engine's own
route helpers at runtime, so they follow whatever path you mount the engine at.
# config/routes.rb
mount RailsAnalytics::Engine => "/analytics" # default
# mount RailsAnalytics::Engine => "/admin/analytics" # any custom path works<!-- layout -->
<%= rails_analytics_tracker_tag %>No need to pass an
endpoint:to the helper — it resolves the mount automatically. The only exception is serving the tracker from a CDN:<%= rails_analytics_tracker_tag endpoint: "/cdn/path" %>.
Authentication
The dashboard is protected by an auth callback. By default it calls authenticate_admin!
on the host app's ApplicationController (the engine controller inherits from the
host ApplicationController, so Devise and friends keep working out of the box).
# config/initializers/rails_analytics.rb
RailsAnalytics.configure do |config|
# Symbol: sent to the host ApplicationController
config.auth_callback = :authenticate_admin!
# or a callable:
config.auth_callback = -> { current_user&.admin? }
endCompliance
What is collected
- Path (landing page)
- Referrer domain
- Device type (desktop / mobile / tablet)
- Viewport size
- Browser language
- Timestamp (UTC)
- Per-page nonce (one-time signed token)
- UTM parameters (
utm_source,utm_medium,utm_campaign,utm_term,utm_content)
What is NEVER collected
- Cookies
- localStorage
- Browser fingerprinting
- Raw IP address
- Full
User-Agentstring - Any PII (names, emails, form input, clicks)
Why cookie-less
Brazil's ANPD cookie guide classifies analytics cookies as "non-essential", which require prior opt-in consent (LGPD). Rails Analytics sidesteps this entirely: it uses no cookies, no localStorage, and no fingerprinting — so there is nothing to consent to and no cookie banner needed for analytics.
IP masking
IPs are masked before any storage:
- IPv4: last octet zeroed (
203.0.113.45→203.0.113.0) - IPv6: last 80 bits zeroed (
2001:db8::/48) - IPv4-mapped IPv6 is normalized back to the masked IPv4 form
Anonymity key
Unique-visitor counting uses an anonymity key derived from:
SHA256(masked_ip + coarse_ua_bucket + daily_salt)
- The "coarse UA bucket" is just
"<browser>:<os>"(e.g.Chrome:macOS) — never the full user-agent string. - A salt rotates daily, so a key cannot be correlated across days. A visitor returning tomorrow gets a brand-new identity.
Retention
Data is kept for 6 months per the Brazilian Civil Rights Framework for the Internet
(Marco Civil, art. 15). A RetentionJob purges records older than that; schedule it in
your app, e.g.:
# config/schedule.rb (or cron)
# Daily purge of records older than 6 monthsTracker API
The tracker exposes a small global for custom events:
window.RailsAnalytics.track(name, props)Examples:
// Donation click (button click)
document.querySelector("#donate-btn").addEventListener("click", () => {
window.RailsAnalytics.track("donation-click", { value: 50 });
});
// Scroll depth (once the reader passes 75%)
window.addEventListener("scroll", () => {
if (window.scrollY / document.body.scrollHeight > 0.75 && !tracked) {
tracked = true;
window.RailsAnalytics.track("scroll-depth", { threshold: 75 });
}
});Events appear in the events dashboard view, where they can be filtered by name and inspected with their properties.
Add a new metric
Metrics are plain SQL in the Stats module — add one method and one line in the view:
# lib/rails_analytics/stats.rb
def avg_visits_per_day(since: default_since)
total_visits(since: since) / 30.0
end<!-- app/views/rails_analytics/dashboards/overview.html.erb -->
<%= render "metric", label: "Avg visits/day", value: number_with_precision(@stats.avg_visits_per_day, precision: 1) %>Run the demo
The bundled test/dummy app is a full demo: a Brazilian campaign landing page with the
tracker wired up, plus an admin dashboard with seeded aggregate data.
# from the gem root
cd test/dummy
bin/rails serverThen open (the bundled dummy mounts the engine at /rails_analytics as an example of a
custom mount — the gem's default is /analytics):
| URL | What you'll see |
|---|---|
http://localhost:3000/ |
Campaign landing page — click the donate / social buttons and scroll to fire track() events |
http://localhost:3000/login |
Simulated admin login (session-only, no Devise needed for the demo) |
http://localhost:3000/rails_analytics/ |
Dashboard overview (KPIs, visit chart, top sources, top events, UTM, devices) |
http://localhost:3000/rails_analytics/events |
Events aggregated by day, filterable by event name |
http://localhost:3000/rails_analytics/visits |
Visits aggregated by day |
The dashboard reads seed data (idempotent, re-runnable):
cd test/dummy && bin/rails db:seedReal traffic from the landing page is collected through the same POST /collect endpoint
the tracker uses, so interacting with the demo adds live visits/events on top of the seeds
— watch the KPIs change as you click.
Tests
# gem root
bundle install
cd test/dummy && bin/rails db:migrate RAILS_ENV=test && cd ../..
bin/rails testLicense
MIT — see MIT-LICENSE.

