@harshankur/viewcounter 3.1.0 → 3.2.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.
- package/.env.example +34 -10
- package/README.md +306 -136
- package/admin/css/admin.css +891 -198
- package/admin/index.html +13 -7
- package/admin/js/api.js +52 -6
- package/admin/js/appTabs.js +100 -0
- package/admin/js/charts.js +529 -189
- package/admin/js/constants.js +98 -9
- package/admin/js/dataTable.js +478 -0
- package/admin/js/format.js +58 -7
- package/admin/js/icons.js +168 -0
- package/admin/js/listbox.js +2 -1
- package/admin/js/logs.js +211 -60
- package/admin/js/main.js +201 -37
- package/admin/js/overview.js +905 -0
- package/admin/js/passwordPrompt.js +75 -0
- package/admin/js/table.js +12 -52
- package/admin/js/viewDialogs.js +30 -14
- package/admin/js/views.js +273 -207
- package/admin/locales/en.json +352 -63
- package/config/index.js +40 -5
- package/constants.js +126 -8
- package/db/AdminRepository.js +85 -159
- package/db/DatabaseManager.js +55 -7
- package/db/LogRepository.js +172 -35
- package/db/adminSchema.js +89 -4
- package/db/adminSessionStore.js +104 -0
- package/db/analysis.js +479 -0
- package/db/rejectionCounter.js +117 -0
- package/index.js +53 -19
- package/middleware/adminAuth.js +83 -43
- package/middleware/adminValidation.js +69 -3
- package/middleware/auth.js +2 -2
- package/middleware/security.js +26 -2
- package/middleware/validation.js +50 -2
- package/package.json +5 -2
- package/routes/admin.js +130 -22
- package/routes/analytics.js +197 -18
- package/tracker/tracker.js +191 -0
- package/utils/appIdUtils.js +1 -1
- package/utils/durationUtils.js +33 -0
- package/utils/errorUtils.js +4 -1
- package/utils/geoCity.js +87 -0
- package/utils/ipUtils.js +1 -1
- package/utils/privacyUtils.js +2 -2
- package/utils/referrerParser.js +23 -5
- package/utils/secretStore.js +1 -1
- package/utils/userAgentParser.js +52 -3
- package/utils/visitorContext.js +70 -0
- package/admin/js/insights.js +0 -192
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
[](https://viewcounter.harshankur.com)
|
|
4
4
|
[](LICENSE)
|
|
5
5
|
[](https://github.com/harshankur/viewcounter/actions/workflows/test.yml)
|
|
6
|
-
[](TEST_REPORT.md)
|
|
7
7
|
[](https://www.npmjs.com/package/@harshankur/viewcounter)
|
|
8
8
|
[](https://www.npmjs.com/package/@harshankur/viewcounter#provenance)
|
|
9
9
|
|
|
@@ -14,9 +14,10 @@ Visit our [Interactive Documentation](https://viewcounter.harshankur.com) for de
|
|
|
14
14
|
|
|
15
15
|
## 🛡️ GDPR Compliant & Privacy-First
|
|
16
16
|
**100% GDPR Compliant By Design.** This project is built from the ground up to respect user privacy and adhere to modern ethical standards:
|
|
17
|
-
- **
|
|
17
|
+
- **Nothing on the visitor's device**: no cookie, no localStorage, no sessionStorage, and no identifier sent with a view, so the tracker needs no consent banner under the ePrivacy rules on device storage. (The optional admin UI signs its operator in with a session cookie; tracking never sets one.)
|
|
18
18
|
- **Data Sovereignty**: You own your data. Analytics never leave your private infrastructure.
|
|
19
|
-
- **Minimal Collection**:
|
|
19
|
+
- **Minimal Collection**: records what analytics needs, each in a form that does not identify a person. [What Gets Tracked?](#what-gets-tracked) lists every field, where it comes from, and how it is stored; the raw IP address, the user agent, and the query string are never stored.
|
|
20
|
+
- **Bots left out**: crawlers, link previewers, and automated browsers are recognised and never stored, only counted per minute by name.
|
|
20
21
|
|
|
21
22
|
### 🔄 Data Privacy Lifecycle
|
|
22
23
|
```mermaid
|
|
@@ -51,17 +52,21 @@ We believe in total transparency regarding your visitors' data:
|
|
|
51
52
|
- 🧑💼 **Admin UI**: Browse, search, edit, annotate, and soft-delete recorded views, with batch actions, a trash, and audit logs ([details](#admin-ui))
|
|
52
53
|
|
|
53
54
|
### Advanced Tracking
|
|
54
|
-
-
|
|
55
|
-
-
|
|
56
|
-
-
|
|
57
|
-
-
|
|
58
|
-
-
|
|
59
|
-
-
|
|
55
|
+
- 🧩 **Tracker script**: one `<script>` tag, served by your ViewCounter server ([details](#client-side-integration)): page views, including page changes in single-page apps; how long each page was visible and how far it was scrolled; clicks on links to other sites and on downloads; campaign tags. It stores nothing on the device.
|
|
56
|
+
- 📍 **Pages and sites**: the page path, its title, and which of your sites (hostname) it was on
|
|
57
|
+
- 🔗 **Referrer Analysis**: automatic source categorization (search, social, email, campaign, referral, internal, direct)
|
|
58
|
+
- 🏷️ **Campaigns**: the five `utm_*` tags of the landing URL, and nothing else from it
|
|
59
|
+
- 🌍 **Location**: country built in; region and city with an optional [city database](#location-data)
|
|
60
|
+
- 🗣️ **Language**: the visitor's preferred language (its primary subtag only, such as `de`)
|
|
61
|
+
- 🖥️ **User Agent Parsing**: browser, OS, and device type, with their versions
|
|
62
|
+
- ⏱️ **Engagement**: time on page and scroll depth, measured by the tracker script
|
|
63
|
+
- 🎯 **Custom Events**: track button clicks, form submissions, etc., with properties
|
|
64
|
+
- 📊 **Analysis**: visitors, visits, bounce rate, visit duration, entry and exit pages, page flow, a weekday-by-hour heatmap, and comparisons with the period before, in the [admin UI](#admin-ui)
|
|
60
65
|
|
|
61
66
|
## Quick Start
|
|
62
67
|
|
|
63
68
|
**Requires Node 24 or newer** and a reachable MySQL 8 (or MariaDB 11) instance.
|
|
64
|
-
Only the current Node LTS is supported
|
|
69
|
+
Only the current Node LTS is supported, with no matrix of older runtimes to
|
|
65
70
|
maintain.
|
|
66
71
|
|
|
67
72
|
### 1. Install Dependencies
|
|
@@ -135,7 +140,7 @@ GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX ON viewcounterdb.* TO
|
|
|
135
140
|
### Environment Variables
|
|
136
141
|
See [`.env.example`](.env.example) for the full surface with prose on each one.
|
|
137
142
|
|
|
138
|
-
**Required in production
|
|
143
|
+
**Required in production**: the server refuses to start without these rather
|
|
139
144
|
than running on a guessable default:
|
|
140
145
|
- `DB_USER` / `DB_PASSWORD`: refuses to boot while still `root` with an empty password
|
|
141
146
|
- `DB_NAME`: database to write into
|
|
@@ -144,13 +149,44 @@ than running on a guessable default:
|
|
|
144
149
|
|
|
145
150
|
**Recommended**:
|
|
146
151
|
- `READ_API_KEYS`: comma-separated keys for the analytics read endpoints. Unset means the read API is disabled.
|
|
147
|
-
- `TRUST_PROXY`: hop count or CIDR list. **Never set this to `true
|
|
152
|
+
- `TRUST_PROXY`: hop count or CIDR list. **Never set this to `true`**: trusting every hop lets any caller forge their own IP via `X-Forwarded-For`, which fakes geolocation, inflates unique-visitor counts, and bypasses rate limiting. `true` and `*` are downgraded to one hop with a warning. Your proxy must set `X-Forwarded-For`; `X-Real-IP` alone is not read.
|
|
148
153
|
- `VISITOR_SECRET_PATH` / `VISITOR_SECRET`: where the visitor-hash secret lives, or the value itself.
|
|
149
154
|
- `ADMIN_PASSWORD`: turns on the [admin UI](#admin-ui) at `/admin`. At least 16 characters. Unset means the admin UI does not exist.
|
|
150
155
|
|
|
151
156
|
**Optional**: `DB_MODE`, `PORT`, `LOG_LEVEL`, `RATE_LIMIT_WINDOW_MS`,
|
|
152
157
|
`RATE_LIMIT_MAX`, `UNIQUE_VISITOR_WINDOW_HOURS`, `ALLOWED_DEVICE_SIZES`,
|
|
153
|
-
`TRASH_RETENTION_DAYS`, `VIEW_LOG_RETENTION_DAYS
|
|
158
|
+
`TRASH_RETENTION_DAYS`, `VIEW_LOG_RETENTION_DAYS`, `ADMIN_SESSION_IDLE_TIMEOUT`,
|
|
159
|
+
`ADMIN_SESSION_MAX_AGE`, `GEOIP_CITY_DB`.
|
|
160
|
+
|
|
161
|
+
### Time zones
|
|
162
|
+
|
|
163
|
+
Views are timestamped by the database (`NOW()`), and the admin analysis groups
|
|
164
|
+
them into UTC days and hours whatever the database's time zone. Run ViewCounter
|
|
165
|
+
and the database in the same time zone, so times read back unchanged; in
|
|
166
|
+
containers both default to UTC.
|
|
167
|
+
|
|
168
|
+
### Location data
|
|
169
|
+
|
|
170
|
+
**Country** comes from the GeoLite2 country database bundled in the
|
|
171
|
+
`geoip-country` package: nothing to configure. MaxMind updates it monthly and
|
|
172
|
+
the package follows, so update the package (or rebuild your image) now and
|
|
173
|
+
then. GeoLite2's licence asks for credit, which the admin UI shows: *This
|
|
174
|
+
product includes GeoLite2 data created by MaxMind, available from
|
|
175
|
+
https://www.maxmind.com.*
|
|
176
|
+
|
|
177
|
+
**Region and city** need a city database. Set `GEOIP_CITY_DB` to a
|
|
178
|
+
MaxMind-format `.mmdb` file:
|
|
179
|
+
|
|
180
|
+
- [DB-IP IP to City Lite](https://db-ip.com/db/download/ip-to-city-lite),
|
|
181
|
+
free under CC BY 4.0 and updated monthly; the admin UI credits it as
|
|
182
|
+
*IP geolocation by DB-IP*, as its licence asks;
|
|
183
|
+
- or MaxMind GeoLite2 City, which needs a free MaxMind account.
|
|
184
|
+
|
|
185
|
+
The file is read again whenever it changes, so a monthly job that downloads a
|
|
186
|
+
new one over it needs no restart. Without one, region and city stay empty and
|
|
187
|
+
everything else works. A configured path that cannot be opened stops the
|
|
188
|
+
server from starting, rather than running silently without cities. The IP is
|
|
189
|
+
looked up in memory only, like the country, and is never stored.
|
|
154
190
|
|
|
155
191
|
## Admin UI
|
|
156
192
|
|
|
@@ -161,8 +197,10 @@ there, with no separate deployment and no build step.
|
|
|
161
197
|
```bash
|
|
162
198
|
# .env (or the environment of your container)
|
|
163
199
|
ADMIN_PASSWORD=<at least 16 characters, e.g. from: openssl rand -base64 24>
|
|
164
|
-
TRASH_RETENTION_DAYS=30
|
|
165
|
-
VIEW_LOG_RETENTION_DAYS=90
|
|
200
|
+
TRASH_RETENTION_DAYS=30 # optional; 0 keeps trash until emptied by hand
|
|
201
|
+
VIEW_LOG_RETENTION_DAYS=90 # optional; 0 keeps the tracking log forever
|
|
202
|
+
ADMIN_SESSION_IDLE_TIMEOUT=7d # optional; a session ends after this long unused
|
|
203
|
+
ADMIN_SESSION_MAX_AGE=30d # optional; and this long after signing in
|
|
166
204
|
```
|
|
167
205
|
|
|
168
206
|
Then open `https://<your-server>/admin/` and sign in.
|
|
@@ -173,30 +211,62 @@ at http://localhost:4173/admin/ over several thousand fake views (password
|
|
|
173
211
|
|
|
174
212
|
### What you can do
|
|
175
213
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
214
|
+
The admin has five sections. Every one but the two logs shows one app, or
|
|
215
|
+
every app together under **All apps**; the choice follows you between them.
|
|
216
|
+
|
|
217
|
+
- **Overview** (where it opens) is the analysis, for a period (24 hours, 7,
|
|
218
|
+
30, or 90 days, a year, or all time) and an event type:
|
|
219
|
+
- nine headline numbers (visitors, visits, page views, views and events,
|
|
220
|
+
bounce rate, visit duration, pages per visit, time on page, scroll depth),
|
|
221
|
+
each against the period before, with a sparkline; choose one to chart it
|
|
222
|
+
over time, or read every number per period as a table;
|
|
223
|
+
- **Right now**: visitors in the last few minutes, views per minute over
|
|
224
|
+
the last half hour, and the pages open, refreshed while you look;
|
|
225
|
+
- where visits come from (channels, referrers, referring pages, and every
|
|
226
|
+
campaign tag), pages (top, entry with bounce rate, exit, titles, sites),
|
|
227
|
+
locations (a world map, countries, regions, cities, languages), devices,
|
|
228
|
+
browsers and systems with their versions, custom events and their
|
|
229
|
+
properties, time-on-page and scroll-depth distributions, page flow (which
|
|
230
|
+
page led to which), and a weekday-by-hour heatmap in your time zone;
|
|
231
|
+
- click any row to narrow everything to it (a chip above takes it off
|
|
232
|
+
again), and **Show these views** to open exactly those rows in Views.
|
|
233
|
+
"How these numbers are counted", at the bottom, defines each number.
|
|
234
|
+
- **Views** is the data itself: every view and event recorded, one row each,
|
|
235
|
+
the rows every Overview number is computed from. Filter by period, event
|
|
236
|
+
type, and whether an admin changed a row; search by page, title, site,
|
|
237
|
+
source, campaign, note, event, or view ID; sort by any column. **Columns**
|
|
238
|
+
chooses which columns the table has, from everything a view stores, and their
|
|
239
|
+
order; drag a column's edge to resize it (arrow keys work too). The table
|
|
240
|
+
shows as many of your columns as fit its width, in your order, and keeps the
|
|
241
|
+
rest of each row one tap away under it, so a wide screen shows more and
|
|
242
|
+
nothing scrolls sideways. On a phone each view is a card of your first few
|
|
243
|
+
columns. The two logs choose their columns the same way, and the choices are
|
|
244
|
+
remembered in your browser. From here you can:
|
|
245
|
+
- **select several views**, across pages and apps, and act on all at once;
|
|
246
|
+
- **edit content fields**: page path, page title, referrer (the source is
|
|
247
|
+
recalculated from it), device size, event type, and event data. What was
|
|
248
|
+
*observed* about the visitor (time, masked IP, location, language,
|
|
249
|
+
browser, OS, device type, engagement) is never editable, so an edit can
|
|
250
|
+
correct what was viewed but never fabricate who viewed it or when;
|
|
251
|
+
- **add a note** to any view, as a private annotation;
|
|
252
|
+
- **see every stored field** of a view, grouped, in its details;
|
|
253
|
+
- **move views to the trash**, where they stop counting in every statistic
|
|
254
|
+
at once.
|
|
255
|
+
- **Trash** holds what was moved there, to **restore** or **erase
|
|
256
|
+
permanently**.
|
|
257
|
+
- **Tracking log** lists every tracking request that reached the server and
|
|
258
|
+
what became of it: recorded, a repeat visit, a bot, or refused (and why:
|
|
259
|
+
an unregistered site, an unknown app, a malformed request, a rate limit).
|
|
260
|
+
Views holds only what was recorded; this log also shows what never was, so
|
|
261
|
+
it is where to check that a site is sending views, or find out why some are
|
|
262
|
+
not counted. It sums up the last day, filters by app, request type, and
|
|
263
|
+
outcome, can refresh itself, and opens any entry's view in Views. It is not
|
|
264
|
+
a statistic, and editing or deleting a view never changes it.
|
|
265
|
+
- **Admin log** lists every sign-in and every change made here.
|
|
266
|
+
|
|
267
|
+
The header links to this project's website and names the running version; the
|
|
268
|
+
footer links to the documentation, changelog, source, and package, carries the
|
|
269
|
+
copyright notice, and credits the location data.
|
|
200
270
|
|
|
201
271
|
### How the data is kept
|
|
202
272
|
|
|
@@ -207,15 +277,23 @@ at http://localhost:4173/admin/ over several thousand fake views (password
|
|
|
207
277
|
| `note` | The admin's annotation, if any. |
|
|
208
278
|
| `deleted_at` | Empty for live rows; set when the row went to the trash. |
|
|
209
279
|
|
|
210
|
-
These columns,
|
|
211
|
-
|
|
280
|
+
These columns, the tracking columns in [What Gets Tracked?](#what-gets-tracked),
|
|
281
|
+
and the `_admin_log`, `_view_log`, `_tracking_rejections`, and
|
|
282
|
+
`_admin_sessions` tables are added automatically when the server starts, in both database modes, whether or not
|
|
212
283
|
the admin UI is enabled. The upgrade is additive: nothing is dropped, and
|
|
213
284
|
existing rows get their `public_id` on the first start, in batches that each
|
|
214
285
|
resume where the last stopped, so a large table is read once. The database user
|
|
215
286
|
therefore needs `CREATE`, `ALTER`, and `INDEX` as well as the usual privileges
|
|
216
287
|
(see [Database Modes](#database-modes)).
|
|
217
288
|
|
|
218
|
-
|
|
289
|
+
| Table | Holds |
|
|
290
|
+
|---|---|
|
|
291
|
+
| `_view_log` | The tracking log's accepted half: one row per view or event recorded, with its app, site, request type, view ID, and whether it was unique. |
|
|
292
|
+
| `_tracking_rejections` | The tracking log's other half: per minute, how many requests were bots or refused, by app, request type, reason, site, and a short detail (a bot's name, the invalid field). Nothing about who sent them. |
|
|
293
|
+
| `_admin_log` | Every sign-in and every change made in the admin. |
|
|
294
|
+
| `_admin_sessions` | Signed-in admin sessions: a SHA-256 of the session token (never the token), when it was created and last used, and when the password was last entered. |
|
|
295
|
+
|
|
296
|
+
The tracking log grows with traffic, so entries older than
|
|
219
297
|
`VIEW_LOG_RETENTION_DAYS` (default 90) are removed hourly, in batches, and each
|
|
220
298
|
run that removes anything is recorded in the admin log. It holds no personal
|
|
221
299
|
data, so this only bounds its size; the views themselves are untouched. The
|
|
@@ -234,8 +312,9 @@ Neither log copies personal data, so erasing a row really erases it:
|
|
|
234
312
|
|
|
235
313
|
- the admin log records who acted (a session ID and a masked IP), what they did,
|
|
236
314
|
when, to which view IDs, and *which* fields changed, but never the values;
|
|
237
|
-
- the
|
|
238
|
-
through which endpoint, with no IP, visitor hash, or user agent
|
|
315
|
+
- the tracking log records that a view was accepted, when, for which app and
|
|
316
|
+
site, and through which endpoint, with no IP, visitor hash, or user agent;
|
|
317
|
+
refused requests and bots are only counted, per minute.
|
|
239
318
|
|
|
240
319
|
### Security
|
|
241
320
|
|
|
@@ -244,8 +323,15 @@ Neither log copies personal data, so erasing a row really erases it:
|
|
|
244
323
|
and the server warns if you reuse an API key as the password.
|
|
245
324
|
- Signing in issues an `HttpOnly`, `SameSite=Strict` session cookie scoped to
|
|
246
325
|
the admin path (`/admin`, or wherever an embedding app mounts it), marked `Secure` whenever the request arrived over HTTPS (through
|
|
247
|
-
`TRUST_PROXY` behind a proxy).
|
|
248
|
-
|
|
326
|
+
`TRUST_PROXY` behind a proxy).
|
|
327
|
+
- Sessions are kept in the database as a hash of their token, so a restart
|
|
328
|
+
does not sign anyone out. One ends after 7 days without use or 30 days after
|
|
329
|
+
signing in (`ADMIN_SESSION_IDLE_TIMEOUT`, `ADMIN_SESSION_MAX_AGE`). Using the
|
|
330
|
+
UI keeps it alive, reading included. If it ends mid-use, a sign-in dialog
|
|
331
|
+
opens over the page and whatever you were doing carries on after it.
|
|
332
|
+
- Erasing permanently asks for the password again unless it was entered in the
|
|
333
|
+
last 15 minutes, whatever the session's age; a wrong one counts toward the
|
|
334
|
+
sign-in limit.
|
|
249
335
|
- Every change also needs a per-session CSRF token and a matching `Origin`.
|
|
250
336
|
- Wrong passwords are rate limited per IP (5 per 15 minutes) and recorded in
|
|
251
337
|
the admin log. Requests refused before the password is checked, such as a
|
|
@@ -274,20 +360,51 @@ GET /registerView?appId=blog&deviceSize=medium
|
|
|
274
360
|
# Enhanced with page tracking
|
|
275
361
|
GET /registerView?appId=blog&deviceSize=medium&page=/blog/my-post&title=My%20Post
|
|
276
362
|
|
|
277
|
-
# With referrer and
|
|
278
|
-
GET /registerView?appId=blog&deviceSize=medium&page=/blog/my-post&referrer=https://google.com&
|
|
363
|
+
# With referrer and campaign tags
|
|
364
|
+
GET /registerView?appId=blog&deviceSize=medium&page=/blog/my-post&referrer=https://google.com&utm_source=newsletter&utm_campaign=launch
|
|
279
365
|
```
|
|
280
366
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
367
|
+
`utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, and `utm_content` are
|
|
368
|
+
the only parts of a URL's query that are kept (100 characters each); a landing
|
|
369
|
+
that carries one counts as a `campaign`. `sessionId` is optional and yours to
|
|
370
|
+
define; the tracker script never sends one.
|
|
371
|
+
|
|
372
|
+
**Recorded by the server:**
|
|
373
|
+
- ✅ Country (and region and city with a [city database](#location-data)), from the IP, which is then masked
|
|
374
|
+
- ✅ Browser, OS, device type, and their versions (from the User-Agent, which is not kept)
|
|
375
|
+
- ✅ The site visited (the hostname of the request's `Origin`) and the visitor's language (the primary subtag of `Accept-Language`)
|
|
376
|
+
- ✅ Referrer domain and source type; a referrer on the same site is `internal`
|
|
285
377
|
- ✅ Duplicate prevention (configurable window)
|
|
378
|
+
- ✅ Bots are answered but never stored
|
|
286
379
|
|
|
287
|
-
**Response**:
|
|
380
|
+
**Response**:
|
|
288
381
|
```json
|
|
289
|
-
{"message": "Success!", "duplicate": false}
|
|
382
|
+
{"message": "Success!", "duplicate": false, "recorded": true, "id": "0b8c3c1e-3b1c-4f2c-9d4e-1a2b3c4d5e6f"}
|
|
383
|
+
```
|
|
384
|
+
`id` is the view's public ID, which `/engage` takes. A bot gets
|
|
385
|
+
`{"recorded": false}` and a `200`, so it has no reason to retry.
|
|
386
|
+
|
|
387
|
+
#### Report Engagement
|
|
388
|
+
```bash
|
|
389
|
+
POST /engage
|
|
390
|
+
Content-Type: text/plain # or application/json
|
|
391
|
+
|
|
392
|
+
{"appId": "blog", "id": "<the id /registerView returned>", "ms": 42000, "scroll": 80}
|
|
393
|
+
```
|
|
394
|
+
How long the page was visible (`ms`, up to 6 hours) and how much of it had been
|
|
395
|
+
on screen (`scroll`, 0 to 100). A later report can only raise either. A report
|
|
396
|
+
for a view that is unknown, trashed, or older than a day changes nothing and is
|
|
397
|
+
counted in the tracking log as refused. `text/plain` is accepted so
|
|
398
|
+
`navigator.sendBeacon` can deliver it as the page closes, without a CORS
|
|
399
|
+
preflight. Answers `204`.
|
|
400
|
+
|
|
401
|
+
#### The Tracker Script
|
|
402
|
+
```bash
|
|
403
|
+
GET /tracker.js
|
|
290
404
|
```
|
|
405
|
+
The script in [Client-Side Integration](#client-side-integration), served by
|
|
406
|
+
the server it reports to, so the two never drift apart. Other sites may load
|
|
407
|
+
it (`Cross-Origin-Resource-Policy: cross-origin`), and it is cached for an hour.
|
|
291
408
|
|
|
292
409
|
#### Track Custom Event
|
|
293
410
|
```bash
|
|
@@ -298,17 +415,20 @@ Content-Type: application/json
|
|
|
298
415
|
"appId": "blog",
|
|
299
416
|
"eventType": "button_click",
|
|
300
417
|
"eventData": {"button": "subscribe", "location": "header"},
|
|
301
|
-
"
|
|
302
|
-
"
|
|
418
|
+
"page": "/blog/my-post",
|
|
419
|
+
"title": "My post"
|
|
303
420
|
}
|
|
304
421
|
```
|
|
422
|
+
**Response**: `{"message": "Event tracked successfully", "recorded": true, "id": "<public ID>", "insertId": 42}`.
|
|
423
|
+
`insertId`, the internal row number, is deprecated and will be removed in 4.0;
|
|
424
|
+
use `id`. Custom events are never deduplicated.
|
|
305
425
|
|
|
306
426
|
### 📈 Analytics
|
|
307
427
|
|
|
308
428
|
> **These endpoints require authentication.** They return your analytics data,
|
|
309
429
|
> so every one of them expects a valid key in the `x-api-key` header. Configure
|
|
310
430
|
> keys via `READ_API_KEYS` (comma-separated, minimum 32 characters each). With
|
|
311
|
-
> none configured the read API returns `503
|
|
431
|
+
> none configured the read API returns `503`: it fails closed rather than
|
|
312
432
|
> serving your data to anyone who asks.
|
|
313
433
|
>
|
|
314
434
|
> ```bash
|
|
@@ -489,79 +609,95 @@ Set `NODE_ENV=production` to hide error details in API responses.
|
|
|
489
609
|
|
|
490
610
|
## What Gets Tracked?
|
|
491
611
|
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
|
498
|
-
|
|
499
|
-
| **
|
|
500
|
-
| **
|
|
501
|
-
| **
|
|
502
|
-
| **
|
|
503
|
-
| **
|
|
504
|
-
| **
|
|
505
|
-
| **
|
|
506
|
-
| **
|
|
507
|
-
| **
|
|
508
|
-
| **
|
|
509
|
-
| **
|
|
510
|
-
| **
|
|
511
|
-
| **
|
|
512
|
-
| **
|
|
612
|
+
Every field of a view, where it comes from, and the form it is stored in.
|
|
613
|
+
Nothing here identifies a person: the one pseudonymous value, the visitor
|
|
614
|
+
hash, changes every `UNIQUE_VISITOR_WINDOW_HOURS` and is never shown or
|
|
615
|
+
returned by any API.
|
|
616
|
+
|
|
617
|
+
| Field | Comes from | Stored as | Why |
|
|
618
|
+
|-------|-----------|-----------|-----|
|
|
619
|
+
| **Timestamp** | Server | When the view was recorded | Everything over time |
|
|
620
|
+
| **Masked IP** | Request | IPv4 with the last octet zeroed, IPv6 with the interface identifier zeroed | Abuse investigation at network level, never a person |
|
|
621
|
+
| **Visitor hash** | IP and User-Agent, with a secret | HMAC-SHA-256, keyed with a server secret, rotating every window | Unique views, visitors, and visits; never returned |
|
|
622
|
+
| **Country** | IP, looked up in memory | Two-letter code | Where visitors are |
|
|
623
|
+
| **Region, City** | IP, with an optional [city database](#location-data) | Names, such as Bavaria and Munich | Where visitors are, more finely |
|
|
624
|
+
| **Language** | `Accept-Language` | Primary subtag only, such as `de` (never `de-CH`, never a list) | Which languages to write in |
|
|
625
|
+
| **Site** | `Origin` of the request | Hostname only, such as `blog.example.com` | Several sites or subdomains on one app |
|
|
626
|
+
| **Page Path** | `page` | Path only, such as `/blog/my-post` (the tracker never sends a query string) | Which pages are read |
|
|
627
|
+
| **Page Title** | `title` | Text, up to 200 characters | Readable page names |
|
|
628
|
+
| **Referrer** | `referrer` (the page's `document.referrer`) | Origin and path only: the query string and fragment are dropped (the tracker never sends them); absent or empty means direct | Where visits come from |
|
|
629
|
+
| **Referrer Domain, Source Type** | Derived from the referrer | Hostname; search, social, email, campaign, referral, internal, or direct | Grouping sources |
|
|
630
|
+
| **Campaign** | `utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content` | Up to 100 characters each; no other query key is ever kept | Which campaigns work |
|
|
631
|
+
| **Device Size** | `deviceSize` | small, medium, large | Layout decisions |
|
|
632
|
+
| **Browser, OS, and versions** | User-Agent, parsed in memory | Names and versions, such as Chrome 140 on macOS 15 | Compatibility |
|
|
633
|
+
| **Device Type** | User-Agent | desktop, mobile, tablet, tv, console, wearable | Compatibility |
|
|
634
|
+
| **Time on page, Scroll depth** | The tracker script's `/engage` report | Milliseconds visible (at most 6 hours); percent of the page seen | Whether pages are read |
|
|
635
|
+
| **Event Type, Event Data** | `/event` | Type name; JSON up to 4 kB, as your site sends it | Custom events |
|
|
636
|
+
| **Session ID** | `sessionId` (optional) | As your site sends it | Your own grouping; the tracker never sends one |
|
|
637
|
+
|
|
638
|
+
**Never stored**: the raw IP address, the User-Agent string, cookies or any
|
|
639
|
+
other identifier from the device, any query string or fragment (of the page or
|
|
640
|
+
of its referrer) apart from the page's campaign tags, and anything about bots
|
|
641
|
+
or refused requests beyond a per-minute count. Before 3.2, a referrer was stored
|
|
642
|
+
as sent, query string included; the changelog shows how to strip older rows.
|
|
643
|
+
|
|
644
|
+
**Visits** are read from the visitor hash the way privacy-first analytics does
|
|
645
|
+
it: a visitor's page views belong to one visit until they pause for 30
|
|
646
|
+
minutes. Because the hash rotates, the same person on two days is two
|
|
647
|
+
visitors, and nothing links them.
|
|
648
|
+
|
|
649
|
+
Your site's privacy notice should still say that you measure visits this way,
|
|
650
|
+
and why (legitimate interest in understanding how the site is used). What you
|
|
651
|
+
send in `eventData` and `sessionId` is yours to keep free of personal data.
|
|
513
652
|
|
|
514
653
|
## Understanding `UNIQUE_VISITOR_WINDOW_HOURS`
|
|
515
654
|
|
|
516
655
|
This setting prevents counting the same visitor multiple times within a time window.
|
|
517
656
|
|
|
518
657
|
**How it works:**
|
|
519
|
-
- When a view is registered, the system checks
|
|
520
|
-
|
|
521
|
-
- If
|
|
658
|
+
- When a view is registered, the system checks whether the same visitor hash
|
|
659
|
+
(the same IP and browser, within the current window) already viewed the app
|
|
660
|
+
- If yes: the view is stored as a repeat (`{duplicate: true}`), which counts as
|
|
661
|
+
a view but not as a unique view
|
|
662
|
+
- If no: it is stored as a unique view
|
|
663
|
+
|
|
664
|
+
The window is also how often the visitor hash rotates, so it bounds how long
|
|
665
|
+
the same person counts as one visitor.
|
|
522
666
|
|
|
523
667
|
**Examples:**
|
|
524
|
-
- `24` (default):
|
|
525
|
-
- `0`:
|
|
526
|
-
- `168`:
|
|
668
|
+
- `24` (default): the same visitor counts once per day
|
|
669
|
+
- `0`: disable duplicate prevention (the hash still rotates hourly)
|
|
670
|
+
- `168`: the same visitor counts once per week
|
|
527
671
|
|
|
528
672
|
**Note:** Only applies to `pageview` events, not custom events.
|
|
529
673
|
|
|
530
674
|
### 🛡️ Privacy Guardrails (Fail-Safe)
|
|
531
|
-
To guarantee that raw IPs never leak into the database, we've implemented an automated **Privacy Guard** suite ([privacyFailSafe.test.js](
|
|
675
|
+
To guarantee that raw IPs never leak into the database, we've implemented an automated **Privacy Guard** suite ([privacyFailSafe.test.js](tests/privacyFailSafe.test.js)):
|
|
532
676
|
- **Query Interception**: Every single SQL `INSERT` is intercepted during tests.
|
|
533
677
|
- **Regex Scanning**: We scan all query parameters against raw IP patterns (IPv4 and IPv6).
|
|
534
678
|
- **Hard Enforcement**: If the system ever attempts to save an unmasked IP, the test suite immediately fails, preventing accidental privacy regressions.
|
|
535
679
|
|
|
536
680
|
This makes ViewCounter not just "Privacy-First" by design, but **Privacy-Guaranteed** by automation.
|
|
537
681
|
|
|
538
|
-
- [x] Implement IP masking utility
|
|
539
|
-
- [x] Implement transient hashing for uniqueness
|
|
540
|
-
- [x] Update `DatabaseManager` to use hashes/masked IPs
|
|
541
|
-
- [x] Update `db/schema.sql` (column renaming/clarification)
|
|
542
|
-
- [x] Remove "IP Address" references from docs/README
|
|
543
|
-
- [x] Update documentation with "How it works" privacy section
|
|
544
|
-
- [x] Update and verify tests
|
|
545
|
-
|
|
546
682
|
## Security Features
|
|
547
683
|
|
|
548
684
|
**Trust model.** The two write endpoints (`/registerView`, `/event`) are public
|
|
549
685
|
because a browser on your site must be able to reach them. Everything that
|
|
550
686
|
*reads* analytics is authenticated.
|
|
551
687
|
|
|
552
|
-
- ✅ **Authenticated, scoped read API
|
|
553
|
-
- ✅ **Separate admin tier
|
|
554
|
-
- ✅ **Per-tenant rate limits
|
|
555
|
-
- ✅ **Keyed visitor hashing
|
|
556
|
-
- ✅ **SQL injection prevention
|
|
557
|
-
- ✅ **Explicit CORS allowlist
|
|
558
|
-
- ✅ **Proxy-aware IP derivation
|
|
559
|
-
- ✅ **Bounded input
|
|
560
|
-
- ✅ **Resource guards
|
|
561
|
-
- ✅ **No error leakage
|
|
688
|
+
- ✅ **Authenticated, scoped read API**: every analytics endpoint requires `x-api-key`, compared in constant time, and each key is authorized against the specific `appId` requested. Fails closed when unconfigured.
|
|
689
|
+
- ✅ **Separate admin tier**: provisioning apps uses its own credential; a read key cannot provision and an admin key cannot read.
|
|
690
|
+
- ✅ **Per-tenant rate limits**: an `appId`-keyed budget alongside the per-IP limit.
|
|
691
|
+
- ✅ **Keyed visitor hashing**: HMAC-SHA-256 with a persisted 32-byte server secret, rotating per window, so stored hashes are not reversible to an IP.
|
|
692
|
+
- ✅ **SQL injection prevention**: every value is a bound parameter; the only interpolated identifier is `appId`, gated by the allowlist.
|
|
693
|
+
- ✅ **Explicit CORS allowlist**: no wildcard, and writes can be bound to registered origins per app.
|
|
694
|
+
- ✅ **Proxy-aware IP derivation**: client-supplied forwarding headers are not trusted unless `TRUST_PROXY` says so.
|
|
695
|
+
- ✅ **Bounded input**: length caps matching every column width, integer ranges on `limit`/`days`/`offset`, a 16 kB body cap and a 4 kB `eventData` cap.
|
|
696
|
+
- ✅ **Resource guards**: finite pool queue, per-statement timeout, rate limiting.
|
|
697
|
+
- ✅ **No error leakage**: failures return a request id; the detail goes only to the server log.
|
|
562
698
|
- ✅ **Security headers** (Helmet.js) and `Cache-Control: no-store` on all analytics responses.
|
|
563
|
-
- ✅ **Fail-fast config validation
|
|
564
|
-
- ✅ **Adversarial regression suite
|
|
699
|
+
- ✅ **Fail-fast config validation**: insecure defaults stop the boot rather than being silently accepted.
|
|
700
|
+
- ✅ **Adversarial regression suite**: [`tests/security.test.js`](tests/security.test.js) covers header spoofing, auth bypass, injection-shaped input, oversized payloads, and prototype pollution.
|
|
565
701
|
|
|
566
702
|
Report a vulnerability through [private advisory reporting](https://github.com/harshankur/viewcounter/security/advisories/new), not a public issue. See [SECURITY.md](SECURITY.md).
|
|
567
703
|
|
|
@@ -617,7 +753,7 @@ router.afterEach(() => track()); // Vue / Nuxt
|
|
|
617
753
|
useEffect(() => track(), [pathname]); // Next.js app router
|
|
618
754
|
```
|
|
619
755
|
|
|
620
|
-
Run one instance for all your sites
|
|
756
|
+
Run one instance for all your sites: one `appId` each, each with its own table
|
|
621
757
|
and its own origin list.
|
|
622
758
|
|
|
623
759
|
## Multi-Tenancy
|
|
@@ -658,7 +794,7 @@ out-of-scope one, for the same reason.
|
|
|
658
794
|
### Provisioning a tenant at runtime
|
|
659
795
|
|
|
660
796
|
`POST /apps` creates the app's table, records it, and adds it to the live
|
|
661
|
-
allowlist
|
|
797
|
+
allowlist, with no restart and no config edit. It requires an **admin** key
|
|
662
798
|
(`ADMIN_API_KEYS`), which is a separate tier: a read key cannot provision, and
|
|
663
799
|
an admin key cannot read analytics.
|
|
664
800
|
|
|
@@ -680,15 +816,15 @@ of letters, digits, underscore, and hyphen, and may not start with an underscore
|
|
|
680
816
|
|
|
681
817
|
Two independent limits apply to writes:
|
|
682
818
|
|
|
683
|
-
- `RATE_LIMIT_MAX
|
|
684
|
-
- `APP_RATE_LIMIT_MAX
|
|
819
|
+
- `RATE_LIMIT_MAX`: per client IP. The single-abuser backstop.
|
|
820
|
+
- `APP_RATE_LIMIT_MAX`: per `appId`. Stops one tenant consuming the budget
|
|
685
821
|
everyone else on the instance depends on. Keyed on `appId` alone, so it cannot
|
|
686
822
|
be bypassed by rotating addresses. Set `0` to disable for single-tenant use.
|
|
687
823
|
|
|
688
824
|
### What is still yours to build
|
|
689
825
|
|
|
690
826
|
Tenancy here is data isolation and quota, not a billing system. There is no
|
|
691
|
-
usage metering, no plan enforcement, and no self-serve signup flow
|
|
827
|
+
usage metering, no plan enforcement, and no self-serve signup flow: `POST /apps`
|
|
692
828
|
is an admin action you would call from your own onboarding code.
|
|
693
829
|
|
|
694
830
|
## Deployment Modes
|
|
@@ -746,12 +882,12 @@ app.use('/analytics', createAnalyticsRouter({
|
|
|
746
882
|
}));
|
|
747
883
|
```
|
|
748
884
|
|
|
749
|
-
Endpoints then live under the prefix
|
|
885
|
+
Endpoints then live under the prefix: `POST /analytics/event`,
|
|
750
886
|
`GET /analytics/stats/blog`, and so on.
|
|
751
887
|
|
|
752
888
|
Two things the host application owns in this mode, because the router does not
|
|
753
889
|
install them itself: `helmet()` and the CORS allowlist, and `trust proxy`. Set
|
|
754
|
-
`app.set('trust proxy', <hop count>)
|
|
890
|
+
`app.set('trust proxy', <hop count>)`, never `true`, or callers can forge
|
|
755
891
|
their own IP through `X-Forwarded-For`.
|
|
756
892
|
|
|
757
893
|
#### Adding the admin UI
|
|
@@ -790,38 +926,73 @@ const stopRetention = startRetention({
|
|
|
790
926
|
|
|
791
927
|
### 3. Browser client
|
|
792
928
|
|
|
793
|
-
|
|
794
|
-
|
|
929
|
+
The server serves its own tracker script at `/tracker.js`. See
|
|
930
|
+
[Client-Side Integration](#client-side-integration).
|
|
795
931
|
|
|
796
932
|
## Client-Side Integration
|
|
797
933
|
|
|
798
|
-
###
|
|
934
|
+
### The tracker script
|
|
935
|
+
|
|
936
|
+
One tag, anywhere in the page:
|
|
937
|
+
|
|
799
938
|
```html
|
|
800
|
-
<script
|
|
801
|
-
|
|
802
|
-
fetch('https://your-server.com/registerView?appId=blog&deviceSize=medium');
|
|
803
|
-
</script>
|
|
939
|
+
<script defer src="https://your-server.com/tracker.js" data-app="blog"
|
|
940
|
+
data-hosts="blog.example.com"></script>
|
|
804
941
|
```
|
|
805
942
|
|
|
806
|
-
|
|
943
|
+
It records a view of each page, including page changes in single-page apps
|
|
944
|
+
(`history.pushState`, `replaceState`, and the back button, each referred by
|
|
945
|
+
the page it left); how long each page was visible and how far it was
|
|
946
|
+
scrolled; clicks on links to other sites (the other site's hostname only);
|
|
947
|
+
clicks on downloads (the file name only); and the landing URL's campaign tags.
|
|
948
|
+
It stores nothing on the device and sends no identifier, and it skips
|
|
949
|
+
automated browsers.
|
|
950
|
+
|
|
951
|
+
| Attribute | Default | Meaning |
|
|
952
|
+
|---|---|---|
|
|
953
|
+
| `data-app` | required | The app ID the views belong to |
|
|
954
|
+
| `data-hosts` | every host | Only track on these hostnames, comma-separated, so development servers and previews stay out of the data |
|
|
955
|
+
| `data-spa` | `true` | Treat history changes as page views |
|
|
956
|
+
| `data-outbound` | `true` | Record clicks on links to other sites, as `outbound` events |
|
|
957
|
+
| `data-downloads` | `true` | Record clicks on downloads (pdf, zip, dmg, docx, and so on), as `download` events |
|
|
958
|
+
| `data-respect-dnt` | `false` | Send nothing when the browser's Do Not Track is on |
|
|
959
|
+
|
|
960
|
+
Custom events: `window.viewcounter.track('signup', { plan: 'pro' })`.
|
|
961
|
+
|
|
962
|
+
For it to reach the server:
|
|
963
|
+
|
|
964
|
+
- the site's origin is in `CORS_ORIGINS`, and, if the app is bound to its
|
|
965
|
+
sites, registered for the app;
|
|
966
|
+
- with a Content Security Policy on the site, `script-src` and `connect-src`
|
|
967
|
+
allow the ViewCounter server.
|
|
968
|
+
|
|
969
|
+
Check the admin's **Tracking log** after adding it: every request shows up
|
|
970
|
+
there, recorded or not, with the reason when it was refused. A site missing
|
|
971
|
+
from `CORS_ORIGINS` shows up as "site not allowed: CORS_ORIGINS", counted from
|
|
972
|
+
the browser's preflight, since the request itself never arrives.
|
|
973
|
+
|
|
974
|
+
### Without the script
|
|
975
|
+
|
|
976
|
+
The same requests by hand. Keep it this way round: nothing stored on the
|
|
977
|
+
device, no identifier generated in the browser.
|
|
978
|
+
|
|
807
979
|
```javascript
|
|
808
|
-
|
|
809
|
-
// Use crypto.randomUUID(), not Math.random(): Math.random() is not a CSPRNG,
|
|
810
|
-
// its output is short and predictable, and collisions merge two visitors'
|
|
811
|
-
// sessions into one.
|
|
812
|
-
const sessionId = sessionStorage.getItem('sessionId') || crypto.randomUUID();
|
|
813
|
-
sessionStorage.setItem('sessionId', sessionId);
|
|
814
|
-
|
|
815
|
-
// Track page view with full context
|
|
816
|
-
fetch(`https://your-server.com/registerView?` + new URLSearchParams({
|
|
980
|
+
const params = new URLSearchParams({
|
|
817
981
|
appId: 'blog',
|
|
818
|
-
deviceSize:
|
|
819
|
-
|
|
820
|
-
page: window.location.pathname,
|
|
982
|
+
deviceSize: innerWidth < 768 ? 'small' : innerWidth < 1200 ? 'medium' : 'large',
|
|
983
|
+
page: location.pathname,
|
|
821
984
|
title: document.title,
|
|
822
985
|
referrer: document.referrer,
|
|
823
|
-
|
|
824
|
-
|
|
986
|
+
});
|
|
987
|
+
// Only the campaign tags from the query string.
|
|
988
|
+
for (const key of ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content']) {
|
|
989
|
+
const value = new URLSearchParams(location.search).get(key);
|
|
990
|
+
if (value) params.set(key, value);
|
|
991
|
+
}
|
|
992
|
+
const { id } = await fetch(`https://your-server.com/registerView?${params}`).then((r) => r.json());
|
|
993
|
+
// Later, when the page is hidden: how long it was visible, and how far scrolled.
|
|
994
|
+
navigator.sendBeacon('https://your-server.com/engage',
|
|
995
|
+
new Blob([JSON.stringify({ appId: 'blog', id, ms: 42000, scroll: 80 })], { type: 'text/plain' }));
|
|
825
996
|
```
|
|
826
997
|
|
|
827
998
|
### Track Custom Events
|
|
@@ -834,7 +1005,6 @@ async function trackEvent(eventType, eventData) {
|
|
|
834
1005
|
appId: 'blog',
|
|
835
1006
|
eventType,
|
|
836
1007
|
eventData,
|
|
837
|
-
sessionId: sessionStorage.getItem('sessionId'),
|
|
838
1008
|
page: window.location.pathname
|
|
839
1009
|
})
|
|
840
1010
|
});
|
|
@@ -940,7 +1110,7 @@ All endpoints are tested with:
|
|
|
940
1110
|
|
|
941
1111
|
## Releasing
|
|
942
1112
|
|
|
943
|
-
Publishing to npm is a manual, deliberate step
|
|
1113
|
+
Publishing to npm is a manual, deliberate step: an npm version number can never
|
|
944
1114
|
be reused, so it is not wired to run on merge.
|
|
945
1115
|
|
|
946
1116
|
```bash
|