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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 711b134069617de5fff010d1d76026dec3df173a33ccd3508d2d3178c1f2d435
4
+ data.tar.gz: 78cf6eb1292a2d68391680af4253192cee7ff352e5cf14bd2478324c461a0e3a
5
+ SHA512:
6
+ metadata.gz: 6902a540dff474d1ecbcc47d3d35fc786e2a032a6c7c9fc35166beb1c0c52097456b4668ed30e0453f740afef5bc15200b0aacd84afed1f5361d5eed1ffa0381
7
+ data.tar.gz: 63c965c562b1d28df62f982fb9a053c671686a2e83b5c9d3ba2169721ef246ed7aa2487e9370896ba883767f6405ac059a5c234ed8a9d4b020d83abd1a0812a3
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2026 br4zz4
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,270 @@
1
+ # Rails Analytics
2
+
3
+ A privacy-first analytics engine for Rails. Cookie-less, LGPD-compliant, and free of
4
+ third-party analytics dependencies — it collects traffic **server-side**, stores it in
5
+ your own database, and renders an aggregated dashboard with zero client-side JS
6
+ dependencies.
7
+
8
+ ## Screenshots
9
+
10
+ | Dashboard — aggregated overview | Dashboard — events & visits |
11
+ |:---:|:---:|
12
+ | <img src="docs/dashboard.png" width="480" alt="Rails Analytics dashboard"> | <img src="docs/dashboard-2.png" width="480" alt="Rails Analytics dashboard detail"> |
13
+
14
+ ## How it works
15
+
16
+ ```
17
+ ┌─────────────┐ tracker.js (injetado no layout)
18
+ │ Navegador │──────────────────────────────► POST /analytics/collect
19
+ └─────────────┘ │
20
+
21
+ ┌──────────────────────┐
22
+ │ AnalyticsController │ assinado com token,
23
+ │ (rails_analytics) │ IP mascarado antes
24
+ └──────────┬───────────┘ de persistir
25
+
26
+ ┌──────────────────────┐
27
+ │ rails_analytics_ │ visits + events
28
+ │ visits / events │ (6-month retention)
29
+ └──────────┬───────────┘
30
+
31
+ ┌──────────────────────┐
32
+ │ Dashboard em │ overview / events /
33
+ │ /analytics │ visits
34
+ └──────────────────────┘
35
+ ```
36
+
37
+ 1. The **tracker JS** (injected by the generator into your layout `<head>`) collects
38
+ path, referrer domain, viewport, language, and a per-page nonce — no cookies, no
39
+ localStorage.
40
+ 2. On `load`, the tracker POSTs a **signed token** to the engine's collect endpoint
41
+ using `navigator.sendBeacon` (same-origin, no CORS).
42
+ 3. The engine verifies the token, **masks the IP** (before anything hits disk),
43
+ derives a **daily anonymity key**, and stores a `Visit` row. Custom events are
44
+ stored as `Event` rows linked to the visit.
45
+ 4. The **dashboard** in `/rails_analytics` shows aggregated metrics from pure SQL
46
+ queries in three views: overview, events, and visits.
47
+
48
+ ## Requirements
49
+
50
+ - Ruby >= 3.0
51
+ - Rails >= 7.0 (tested on 8.1)
52
+ - Database: SQLite, PostgreSQL or MySQL (anything Active Record supports)
53
+
54
+ ## Installation
55
+
56
+ ### 1. Add the gem
57
+
58
+ ```ruby
59
+ # Gemfile
60
+ gem "rails_analytics"
61
+ ```
62
+
63
+ ```bash
64
+ bundle install
65
+ ```
66
+
67
+ ### 2. Run the install generator
68
+
69
+ ```bash
70
+ bin/rails generate rails_analytics:install
71
+ ```
72
+
73
+ The generator does three things:
74
+
75
+ | Action | File | Detail |
76
+ |--------|------|--------|
77
+ | Copies the migrations | `db/migrate/xxx_create_rails_analytics_*.rb` | creates the `rails_analytics_daily_salts`, `rails_analytics_visits` and `rails_analytics_events` tables |
78
+ | Mounts the engine | `config/routes.rb` | adds `mount RailsAnalytics::Engine => RailsAnalytics.config.mount_path` (default `/analytics`) |
79
+ | Injects the tracker | `app/views/layouts/*.html.erb` | adds `<%= rails_analytics_tracker_tag %>` to the `<head>` of the first layout |
80
+
81
+ ### 3. Migrate the database
82
+
83
+ ```bash
84
+ bin/rails db:migrate
85
+ ```
86
+
87
+ Done — collection is automatic and the dashboard lives at **`/analytics`** (or your configured `mount_path`).
88
+
89
+ ## Mounting
90
+
91
+ You can mount the engine at any path:
92
+
93
+ ```ruby
94
+ # config/routes.rb
95
+ mount RailsAnalytics::Engine => "/analytics"
96
+ ```
97
+
98
+ ```erb
99
+ <!-- layout -->
100
+ <%= rails_analytics_tracker_tag endpoint: "/analytics" %>
101
+ ```
102
+
103
+ > The `endpoint:` passed to the helper must match the path the engine is mounted at.
104
+ > You can also set a global default via `RailsAnalytics.configure { |c| c.mount_path = "/analytics" }`.
105
+
106
+ ## Authentication
107
+
108
+ The dashboard is protected by an auth callback. By default it calls `authenticate_admin!`
109
+ on the host app's `ApplicationController` (the engine controller **inherits from the
110
+ host** `ApplicationController`, so Devise and friends keep working out of the box).
111
+
112
+ ```ruby
113
+ # config/initializers/rails_analytics.rb
114
+ RailsAnalytics.configure do |config|
115
+ # Symbol: sent to the host ApplicationController
116
+ config.auth_callback = :authenticate_admin!
117
+
118
+ # or a callable:
119
+ config.auth_callback = -> { current_user&.admin? }
120
+ end
121
+ ```
122
+
123
+ ## Compliance
124
+
125
+ ### What is collected
126
+
127
+ - Path (landing page)
128
+ - Referrer domain
129
+ - Device type (desktop / mobile / tablet)
130
+ - Viewport size
131
+ - Browser language
132
+ - Timestamp (UTC)
133
+ - Per-page nonce (one-time signed token)
134
+ - UTM parameters (`utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content`)
135
+
136
+ ### What is NEVER collected
137
+
138
+ - Cookies
139
+ - localStorage
140
+ - Browser fingerprinting
141
+ - Raw IP address
142
+ - Full `User-Agent` string
143
+ - Any PII (names, emails, form input, clicks)
144
+
145
+ ### Why cookie-less
146
+
147
+ Brazil's ANPD cookie guide classifies analytics cookies as **"non-essential"**, which
148
+ require prior opt-in consent (LGPD). Rails Analytics sidesteps this entirely: it uses no
149
+ cookies, no localStorage, and no fingerprinting — so there is nothing to consent to and
150
+ no cookie banner needed for analytics.
151
+
152
+ ### IP masking
153
+
154
+ IPs are masked **before any storage**:
155
+
156
+ - IPv4: last octet zeroed (`203.0.113.45` → `203.0.113.0`)
157
+ - IPv6: last 80 bits zeroed (`2001:db8::/48`)
158
+ - IPv4-mapped IPv6 is normalized back to the masked IPv4 form
159
+
160
+ ### Anonymity key
161
+
162
+ Unique-visitor counting uses an **anonymity key** derived from:
163
+
164
+ ```
165
+ SHA256(masked_ip + coarse_ua_bucket + daily_salt)
166
+ ```
167
+
168
+ - The "coarse UA bucket" is just `"<browser>:<os>"` (e.g. `Chrome:macOS`) — never the
169
+ full user-agent string.
170
+ - A **salt rotates daily**, so a key cannot be correlated across days. A visitor
171
+ returning tomorrow gets a brand-new identity.
172
+
173
+ ### Retention
174
+
175
+ Data is kept for **6 months** per the Brazilian Civil Rights Framework for the Internet
176
+ (Marco Civil, art. 15). A `RetentionJob` purges records older than that; schedule it in
177
+ your app, e.g.:
178
+
179
+ ```ruby
180
+ # config/schedule.rb (or cron)
181
+ # Daily purge of records older than 6 months
182
+ ```
183
+
184
+ ## Tracker API
185
+
186
+ The tracker exposes a small global for custom events:
187
+
188
+ ```js
189
+ window.RailsAnalytics.track(name, props)
190
+ ```
191
+
192
+ Examples:
193
+
194
+ ```js
195
+ // Donation click (button click)
196
+ document.querySelector("#donate-btn").addEventListener("click", () => {
197
+ window.RailsAnalytics.track("donation-click", { value: 50 });
198
+ });
199
+
200
+ // Scroll depth (once the reader passes 75%)
201
+ window.addEventListener("scroll", () => {
202
+ if (window.scrollY / document.body.scrollHeight > 0.75 && !tracked) {
203
+ tracked = true;
204
+ window.RailsAnalytics.track("scroll-depth", { threshold: 75 });
205
+ }
206
+ });
207
+ ```
208
+
209
+ Events appear in the **events** dashboard view, where they can be filtered by name and
210
+ inspected with their properties.
211
+
212
+ ## Add a new metric
213
+
214
+ Metrics are plain SQL in the `Stats` module — add one method and one line in the view:
215
+
216
+ ```ruby
217
+ # lib/rails_analytics/stats.rb
218
+ def avg_visits_per_day(since: default_since)
219
+ total_visits(since: since) / 30.0
220
+ end
221
+ ```
222
+
223
+ ```erb
224
+ <!-- app/views/rails_analytics/dashboards/overview.html.erb -->
225
+ <%= render "metric", label: "Avg visits/day", value: number_with_precision(@stats.avg_visits_per_day, precision: 1) %>
226
+ ```
227
+
228
+ ## Run the demo
229
+
230
+ The bundled `test/dummy` app is a full demo: a Brazilian campaign landing page with the
231
+ tracker wired up, plus an admin dashboard with seeded aggregate data.
232
+
233
+ ```bash
234
+ # from the gem root
235
+ cd test/dummy
236
+ bin/rails server
237
+ ```
238
+
239
+ Then open:
240
+
241
+ | URL | What you'll see |
242
+ |-----|-----------------|
243
+ | `http://localhost:3000/` | Campaign landing page — click the donate / social buttons and scroll to fire `track()` events |
244
+ | `http://localhost:3000/login` | Simulated admin login (session-only, no Devise needed for the demo) |
245
+ | `http://localhost:3000/rails_analytics/` | Dashboard overview (KPIs, visit chart, top sources, top events, UTM, devices) |
246
+ | `http://localhost:3000/rails_analytics/events` | Events aggregated by day, filterable by event name |
247
+ | `http://localhost:3000/rails_analytics/visits` | Visits aggregated by day |
248
+
249
+ The dashboard reads seed data (idempotent, re-runnable):
250
+
251
+ ```bash
252
+ cd test/dummy && bin/rails db:seed
253
+ ```
254
+
255
+ Real traffic from the landing page is collected through the same `POST /collect` endpoint
256
+ the tracker uses, so interacting with the demo adds live visits/events on top of the seeds
257
+ — watch the KPIs change as you click.
258
+
259
+ ## Tests
260
+
261
+ ```bash
262
+ # gem root
263
+ bundle install
264
+ cd test/dummy && bin/rails db:migrate RAILS_ENV=test && cd ../..
265
+ bin/rails test
266
+ ```
267
+
268
+ ## License
269
+
270
+ MIT — see [MIT-LICENSE](MIT-LICENSE).
@@ -0,0 +1,136 @@
1
+ // Rails Analytics — Cookie-less tracker (~3 KB)
2
+ // No cookies, no localStorage, no fingerprinting. LGPD-compliant by design.
3
+
4
+ (function () {
5
+ "use strict";
6
+
7
+ var FLUSH_INTERVAL = 10000; // 10s
8
+ var MAX_BATCH = 20;
9
+
10
+ // Reads the endpoint from the script tag (helper injects data-endpoint)
11
+ var script = document.currentScript || document.querySelector("script[data-rails-analytics]");
12
+ var ENDPOINT = (script && script.getAttribute("data-endpoint")) || "/rails_analytics";
13
+
14
+ var queue = [];
15
+ var token = null;
16
+
17
+ function randomHex(len) {
18
+ var arr = new Uint8Array(len);
19
+ crypto.getRandomValues(arr);
20
+ return Array.from(arr, function (b) { return b.toString(16).padStart(2, "0"); }).join("");
21
+ }
22
+
23
+ var nonce = randomHex(16);
24
+
25
+ function deviceType() {
26
+ var w = screen.width || window.innerWidth || 0;
27
+ if (w >= 1024) return "desktop";
28
+ if (w >= 768) return "tablet";
29
+ return "mobile";
30
+ }
31
+
32
+ function referrerDomain() {
33
+ try {
34
+ var ref = document.referrer;
35
+ if (!ref) return "direct";
36
+ var u = new URL(ref);
37
+ return u.hostname;
38
+ } catch (e) {
39
+ return "direct";
40
+ }
41
+ }
42
+
43
+ function payload() {
44
+ return {
45
+ path: location.pathname + location.search,
46
+ referrer_domain: referrerDomain(),
47
+ device_type: deviceType(),
48
+ viewport: screen.width + "x" + screen.height,
49
+ language: (navigator.language || "").slice(0, 10),
50
+ nonce: nonce,
51
+ events: queue
52
+ };
53
+ }
54
+
55
+ function fetchToken() {
56
+ return fetch(ENDPOINT + "/token", { credentials: "same-origin" })
57
+ .then(function (r) { return r.json(); })
58
+ .then(function (data) { token = data.token; })
59
+ .catch(function () { /* silently fail */ });
60
+ }
61
+
62
+ function flush() {
63
+ if (queue.length === 0 || !token) return;
64
+
65
+ var batch = queue.splice(0, MAX_BATCH);
66
+ var body = payload();
67
+ body.events = batch;
68
+
69
+ // sendBeacon does not allow custom headers — token goes in the body.
70
+ if (navigator.sendBeacon) {
71
+ body.token = token;
72
+ navigator.sendBeacon(ENDPOINT + "/collect", new Blob([JSON.stringify(body)], { type: "application/json" }));
73
+ } else {
74
+ fetch(ENDPOINT + "/collect", {
75
+ method: "POST",
76
+ headers: {
77
+ "Content-Type": "application/json",
78
+ "Authorization": "Bearer " + token
79
+ },
80
+ body: JSON.stringify(body),
81
+ keepalive: true
82
+ }).catch(function () {});
83
+ }
84
+
85
+ nonce = randomHex(16); // rotate nonce after each batch
86
+ }
87
+
88
+ function track(name, properties) {
89
+ queue.push({
90
+ name: name,
91
+ time: new Date().toISOString(),
92
+ properties: properties || {}
93
+ });
94
+ if (queue.length >= MAX_BATCH) flush();
95
+ }
96
+
97
+ function scheduleFlush() {
98
+ fetchToken().then(function () {
99
+ flush(); // send initial pageview as event
100
+ setInterval(flush, FLUSH_INTERVAL);
101
+ });
102
+ }
103
+
104
+ // Send on page exit (survives tab close)
105
+ function onPageHide() {
106
+ if (queue.length > 0 && token) {
107
+ var body = payload();
108
+ body.token = token;
109
+ if (navigator.sendBeacon) {
110
+ navigator.sendBeacon(ENDPOINT + "/collect", new Blob([JSON.stringify(body)], { type: "application/json" }));
111
+ } else {
112
+ fetch(ENDPOINT + "/collect", {
113
+ method: "POST",
114
+ headers: { "Content-Type": "application/json", "Authorization": "Bearer " + token },
115
+ body: JSON.stringify(body),
116
+ keepalive: true
117
+ }).catch(function () {});
118
+ }
119
+ queue = [];
120
+ }
121
+ }
122
+
123
+ // Expose public API
124
+ window.RailsAnalytics = { track: track };
125
+
126
+ // Init
127
+ if (document.readyState === "complete") {
128
+ scheduleFlush();
129
+ } else {
130
+ window.addEventListener("load", scheduleFlush);
131
+ }
132
+ window.addEventListener("pagehide", onPageHide);
133
+ document.addEventListener("visibilitychange", function () {
134
+ if (document.visibilityState === "hidden") onPageHide();
135
+ });
136
+ })();
@@ -0,0 +1,156 @@
1
+ /* Rails Analytics dashboard — CSS puro, sem dependências */
2
+ :root {
3
+ --ra-bg: #f6f7f9;
4
+ --ra-card: #ffffff;
5
+ --ra-border: #e4e7ec;
6
+ --ra-text: #1a1d21;
7
+ --ra-muted: #667085;
8
+ --ra-accent: #4f46e5;
9
+ --ra-accent-soft: #eef2ff;
10
+ --ra-radius: 12px;
11
+ }
12
+
13
+ * { box-sizing: border-box; }
14
+
15
+ body {
16
+ margin: 0;
17
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
18
+ background: var(--ra-bg);
19
+ color: var(--ra-text);
20
+ line-height: 1.5;
21
+ }
22
+
23
+ .ra-header {
24
+ display: flex;
25
+ align-items: center;
26
+ justify-content: space-between;
27
+ padding: 16px 32px;
28
+ background: var(--ra-card);
29
+ border-bottom: 1px solid var(--ra-border);
30
+ }
31
+
32
+ .ra-brand { font-size: 18px; font-weight: 700; margin: 0; letter-spacing: -0.02em; }
33
+
34
+ .ra-nav a {
35
+ color: var(--ra-accent);
36
+ text-decoration: none;
37
+ font-weight: 500;
38
+ font-size: 14px;
39
+ }
40
+
41
+ .ra-main { max-width: 1080px; margin: 0 auto; padding: 32px 24px 64px; }
42
+
43
+ .ra-kpis {
44
+ display: grid;
45
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
46
+ gap: 16px;
47
+ margin-bottom: 24px;
48
+ }
49
+
50
+ .ra-card {
51
+ background: var(--ra-card);
52
+ border: 1px solid var(--ra-border);
53
+ border-radius: var(--ra-radius);
54
+ padding: 20px;
55
+ }
56
+
57
+ .ra-card-title { font-size: 14px; font-weight: 600; color: var(--ra-muted); margin: 0 0 16px; }
58
+
59
+ .ra-kpi-label { display: block; font-size: 13px; color: var(--ra-muted); margin-bottom: 6px; }
60
+
61
+ .ra-kpi-value { font-size: 32px; font-weight: 700; letter-spacing: -0.03em; }
62
+
63
+ .ra-chart-card { margin-bottom: 24px; }
64
+
65
+ .ra-chart svg { width: 100%; height: auto; }
66
+ .ra-chart rect { fill: var(--ra-accent); opacity: 0.85; }
67
+ .ra-chart rect:hover { opacity: 1; }
68
+
69
+ .ra-grid {
70
+ display: grid;
71
+ grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
72
+ gap: 16px;
73
+ margin-bottom: 24px;
74
+ }
75
+
76
+ .ra-table { width: 100%; border-collapse: collapse; font-size: 14px; }
77
+ .ra-table th {
78
+ text-align: left;
79
+ font-size: 12px;
80
+ text-transform: uppercase;
81
+ letter-spacing: 0.04em;
82
+ color: var(--ra-muted);
83
+ padding: 8px 10px;
84
+ border-bottom: 1px solid var(--ra-border);
85
+ }
86
+ .ra-table td { padding: 8px 10px; border-bottom: 1px solid var(--ra-border); }
87
+ .ra-table tr:last-child td { border-bottom: none; }
88
+
89
+ .ra-num { text-align: right; font-variant-numeric: tabular-nums; }
90
+ .ra-mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; }
91
+
92
+ .ra-empty { color: var(--ra-muted); font-size: 14px; }
93
+
94
+ .ra-devices { display: flex; gap: 32px; }
95
+
96
+ .ra-footer {
97
+ text-align: center;
98
+ color: var(--ra-muted);
99
+ font-size: 12px;
100
+ padding: 24px;
101
+ }
102
+
103
+ @media (prefers-color-scheme: dark) {
104
+ :root {
105
+ --ra-bg: #0f1115;
106
+ --ra-card: #171a21;
107
+ --ra-border: #262b36;
108
+ --ra-text: #e6e8eb;
109
+ --ra-muted: #98a2b3;
110
+ --ra-accent: #818cf8;
111
+ --ra-accent-soft: #1e2140;
112
+ }
113
+ }
114
+
115
+ /* Pagination */
116
+ .ra-pagination {
117
+ display: flex;
118
+ gap: 0.5rem;
119
+ justify-content: center;
120
+ margin-top: 1rem;
121
+ }
122
+ .ra-pagination a, .ra-pagination span {
123
+ padding: 0.25rem 0.75rem;
124
+ border: 1px solid var(--ra-border);
125
+ border-radius: 4px;
126
+ text-decoration: none;
127
+ color: var(--ra-text);
128
+ }
129
+ .ra-pagination .current {
130
+ background: var(--ra-accent);
131
+ color: #fff;
132
+ border-color: var(--ra-accent);
133
+ }
134
+
135
+ /* Filter bar */
136
+ .ra-filter {
137
+ display: flex;
138
+ gap: 0.5rem;
139
+ margin-bottom: 1rem;
140
+ flex-wrap: wrap;
141
+ }
142
+ .ra-filter select, .ra-filter input {
143
+ padding: 0.25rem 0.5rem;
144
+ border: 1px solid var(--ra-border);
145
+ border-radius: 4px;
146
+ background: var(--ra-bg-card);
147
+ color: var(--ra-text);
148
+ }
149
+
150
+ /* UTM table */
151
+ .ra-utm-table th {
152
+ font-size: 0.75rem;
153
+ }
154
+ .ra-utm-table td {
155
+ font-size: 0.85rem;
156
+ }
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAnalytics
4
+ class AnalyticsController < ApplicationController
5
+ skip_before_action :authorize_admin!
6
+ skip_before_action :verify_authenticity_token
7
+
8
+ TOKEN_PURPOSE = :tracker
9
+ VERIFIER_NAME = "rails_analytics_tracker"
10
+
11
+ def collect
12
+ json = JSON.parse(request.body.read)
13
+ token = extract_token(request, json)
14
+
15
+ unless valid_token?(token)
16
+ render json: { error: "Unauthorized" }, status: :unauthorized and return
17
+ end
18
+
19
+ client_ip = request.env["HTTP_CF_CONNECTING_IP"] || request.remote_ip
20
+ masked = IpMask.mask(client_ip)
21
+ ua = request.user_agent.to_s
22
+ path = json["path"].to_s[0, 2000]
23
+
24
+ anonymity_key = Identity.key(
25
+ masked_ip: masked,
26
+ user_agent: ua,
27
+ date: Date.today
28
+ )
29
+
30
+ utm = Visit.parse_utm_params(path)
31
+
32
+ visit = Visit.find_or_create_for(anonymity_key, {
33
+ masked_ip: masked,
34
+ referrer_domain: json["referrer_domain"].to_s[0, 500].presence,
35
+ landing_page_path: path,
36
+ device_type: json["device_type"].to_s[0, 50].presence || "unknown",
37
+ viewport: json["viewport"].to_s[0, 20].presence,
38
+ language: json["language"].to_s[0, 10].presence,
39
+ started_at: Time.current,
40
+ **utm
41
+ })
42
+
43
+ Array(json["events"]).each do |ev|
44
+ next unless ev.is_a?(Hash)
45
+ visit.events.create!(
46
+ name: ev["name"].to_s[0, 100],
47
+ time: (Time.parse(ev["time"].to_s) rescue Time.current),
48
+ properties: (ev["properties"].is_a?(Hash) ? ev["properties"] : {})
49
+ )
50
+ end
51
+
52
+ head :no_content
53
+ rescue JSON::ParserError, ActiveRecord::RecordInvalid, ArgumentError
54
+ head :no_content
55
+ end
56
+
57
+ def token
58
+ verifier = Rails.application.message_verifier(VERIFIER_NAME)
59
+ signed = verifier.generate({ exp: 5.minutes.from_now.to_i, nonce: SecureRandom.hex(8) }, purpose: TOKEN_PURPOSE)
60
+ render json: { token: signed }
61
+ end
62
+
63
+ def tracker
64
+ js = File.read(RailsAnalytics::Engine.root.join("app/assets/javascripts/rails_analytics/tracker.js"))
65
+ render plain: js, content_type: "application/javascript", layout: false
66
+ end
67
+
68
+ def dashboard_css
69
+ css = File.read(RailsAnalytics::Engine.root.join("app/assets/stylesheets/rails_analytics/dashboard.css"))
70
+ render plain: css, content_type: "text/css", layout: false
71
+ end
72
+
73
+ private
74
+
75
+ # sendBeacon does not allow custom headers — accept token in header OR body.
76
+ def extract_token(request, json)
77
+ header = request.headers["Authorization"]
78
+ return header.gsub(/^Bearer /, "") if header.present?
79
+ json["token"]
80
+ end
81
+
82
+ def valid_token?(token)
83
+ return false if token.blank?
84
+ verifier = Rails.application.message_verifier(VERIFIER_NAME)
85
+ payload = verifier.verified(token, purpose: TOKEN_PURPOSE)
86
+ payload.present? && payload["exp"].to_i > Time.current.to_i
87
+ rescue ActiveSupport::MessageVerifier::InvalidSignature
88
+ false
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAnalytics
4
+ # Inherits from the host ApplicationController (RailsAdmin pattern) so the
5
+ # auth callback (e.g. Devise authenticate_admin!) stays reachable via send.
6
+ class ApplicationController < (defined?(::ApplicationController) ? ::ApplicationController : ActionController::Base)
7
+ layout "rails_analytics"
8
+
9
+ before_action :authorize_admin!
10
+
11
+ private
12
+
13
+ def authorize_admin!
14
+ callback = RailsAnalytics.config.auth_callback
15
+
16
+ if callback.respond_to?(:call)
17
+ redirect_to main_app.root_path unless instance_exec(&callback)
18
+ else
19
+ # Symbol: delegated to the host (Devise decides redirect/raise).
20
+ send(callback)
21
+ end
22
+ end
23
+ end
24
+ end