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