@harshankur/viewcounter 3.1.0 → 3.3.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 (50) hide show
  1. package/.env.example +37 -11
  2. package/README.md +330 -138
  3. package/admin/css/admin.css +891 -198
  4. package/admin/index.html +13 -7
  5. package/admin/js/api.js +52 -6
  6. package/admin/js/appTabs.js +100 -0
  7. package/admin/js/charts.js +529 -189
  8. package/admin/js/constants.js +98 -9
  9. package/admin/js/dataTable.js +478 -0
  10. package/admin/js/format.js +58 -7
  11. package/admin/js/icons.js +168 -0
  12. package/admin/js/listbox.js +2 -1
  13. package/admin/js/logs.js +211 -60
  14. package/admin/js/main.js +201 -37
  15. package/admin/js/overview.js +905 -0
  16. package/admin/js/passwordPrompt.js +75 -0
  17. package/admin/js/table.js +12 -52
  18. package/admin/js/viewDialogs.js +30 -14
  19. package/admin/js/views.js +273 -207
  20. package/admin/locales/en.json +352 -63
  21. package/config/index.js +40 -5
  22. package/constants.js +126 -8
  23. package/db/AdminRepository.js +85 -159
  24. package/db/DatabaseManager.js +57 -7
  25. package/db/LogRepository.js +172 -35
  26. package/db/adminSchema.js +93 -4
  27. package/db/adminSessionStore.js +104 -0
  28. package/db/analysis.js +484 -0
  29. package/db/rejectionCounter.js +117 -0
  30. package/index.js +49 -26
  31. package/middleware/adminAuth.js +83 -43
  32. package/middleware/adminValidation.js +69 -3
  33. package/middleware/auth.js +2 -2
  34. package/middleware/security.js +26 -2
  35. package/middleware/validation.js +50 -2
  36. package/package.json +5 -2
  37. package/routes/admin.js +130 -22
  38. package/routes/analytics.js +236 -18
  39. package/tracker/tracker.js +240 -0
  40. package/utils/appIdUtils.js +1 -1
  41. package/utils/durationUtils.js +33 -0
  42. package/utils/errorUtils.js +4 -1
  43. package/utils/geoCity.js +87 -0
  44. package/utils/ipUtils.js +1 -1
  45. package/utils/privacyUtils.js +2 -2
  46. package/utils/referrerParser.js +23 -5
  47. package/utils/secretStore.js +1 -1
  48. package/utils/userAgentParser.js +52 -3
  49. package/utils/visitorContext.js +70 -0
  50. package/admin/js/insights.js +0 -192
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  [![Documentation](https://img.shields.io/badge/docs-viewcounter.harshankur.com-blueviolet)](https://viewcounter.harshankur.com)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
5
5
  [![Test Suite](https://github.com/harshankur/viewcounter/actions/workflows/test.yml/badge.svg)](https://github.com/harshankur/viewcounter/actions/workflows/test.yml)
6
- [![Tests](https://img.shields.io/badge/tests-343%20passing-success)](TEST_REPORT.md)
6
+ [![Tests](https://img.shields.io/badge/tests-1109%20passing-success)](TEST_REPORT.md)
7
7
  [![npm](https://img.shields.io/npm/v/@harshankur/viewcounter?logo=npm)](https://www.npmjs.com/package/@harshankur/viewcounter)
8
8
  [![provenance](https://img.shields.io/badge/provenance-signed-brightgreen?logo=github)](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
- - **Zero Cookies**: No cookies, no local storage, and no consent banners required for visitors. (The optional admin UI signs its operator in with a session cookie; tracking never sets one.)
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**: Tracks only what is necessary (Country, Browser, OS, Page Path).
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
- - 📍 **Page Tracking**: Track specific pages/paths, not just app-level
55
- - 🔗 **Referrer Analysis**: Automatic source categorization (search, social, email, campaign, referral, direct)
56
- - 🖥️ **User Agent Parsing**: Browser, OS, and device type detection
57
- - 👤 **Session Tracking**: Group views by user session
58
- - 🎯 **Custom Events**: Track button clicks, form submissions, etc.
59
- - 📊 **Time-Based Analytics**: Hourly, daily, and weekly trends
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 — no matrix of older runtimes to
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** — the server refuses to start without these rather
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`** — 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.
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 # optional; 0 keeps trash until emptied by hand
165
- VIEW_LOG_RETENTION_DAYS=90 # optional; 0 keeps the view log forever
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,63 @@ at http://localhost:4173/admin/ over several thousand fake views (password
173
211
 
174
212
  ### What you can do
175
213
 
176
- - **Browse** one app's views, or every app's together under **All apps**:
177
- filter by date range (7, 30, or 90 days, a year, or all time), event type,
178
- and whether an admin changed them; search by page, title, source, note,
179
- event, or session; sort by any column; page through them. Under **All apps**
180
- each row names its app. On narrower screens the table drops its least useful
181
- columns first, and on a phone each view becomes a card.
182
- - **See the insights** above the table, for exactly the rows its filters
183
- select: views, visitors, unique share, countries, and admin edits; views over
184
- time (with a table view); a **world map** of where views come from, with the
185
- split by event type on hover and a ranked country list beside it; and
186
- breakdowns by source, device, browser, OS, event type, and app.
187
- - **Select several views**, across pages and across apps, and act on all of
188
- them at once.
189
- - **Edit content fields**: page path, page title, referrer (the source is
190
- recalculated from it), device size, event type, and event data. What was
191
- *observed* about the visitor (time, masked IP, country, browser, OS, device
192
- type) is never editable, so an edit can correct what was viewed but never
193
- fabricate who viewed it or when.
194
- - **Add a note** to any view, as a private annotation.
195
- - **Move views to the trash**. Trashed views stop counting in every statistic
196
- at once and come back if restored.
197
- - **Erase views permanently** from the trash.
198
- - **Read two logs**: the *admin log* of every sign-in and every change, and the
199
- *view log* of every view the server accepted.
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 (by a new view, or by the
224
+ tracker's report from a page still being read), views per minute over the
225
+ last half hour, and the pages open, refreshed while you look;
226
+ - where visits come from (channels, referrers, referring pages, and every
227
+ campaign tag), pages (top, entry with bounce rate, exit, titles, sites),
228
+ locations (a world map, countries, regions, cities, languages), devices,
229
+ browsers and systems with their versions, custom events and their
230
+ properties, time-on-page and scroll-depth distributions, page flow (which
231
+ page led to which), and a weekday-by-hour heatmap in your time zone;
232
+ - click any row to narrow everything to it (a chip above takes it off
233
+ again), and **Show these views** to open exactly those rows in Views.
234
+ "How these numbers are counted", at the bottom, defines each number.
235
+ - **Views** is the data itself: every view and event recorded, one row each,
236
+ the rows every Overview number is computed from. Filter by period, event
237
+ type, and whether an admin changed a row; search by page, title, site,
238
+ source, campaign, note, event, or view ID; sort by any column. **Columns**
239
+ chooses which columns the table has, from everything a view stores, and their
240
+ order; drag a column's edge to resize it (arrow keys work too). The table
241
+ shows as many of your columns as fit its width, in your order, and keeps the
242
+ rest of each row one tap away under it, so a wide screen shows more and
243
+ nothing scrolls sideways. On a phone each view is a card of your first few
244
+ columns. The two logs choose their columns the same way, and the choices are
245
+ remembered in your browser. From here you can:
246
+ - **select several views**, across pages and apps, and act on all at once;
247
+ - **edit content fields**: page path, page title, referrer (the source is
248
+ recalculated from it), device size, event type, and event data. What was
249
+ *observed* about the visitor (time, masked IP, location, language,
250
+ browser, OS, device type, engagement) is never editable, so an edit can
251
+ correct what was viewed but never fabricate who viewed it or when;
252
+ - **add a note** to any view, as a private annotation;
253
+ - **see every stored field** of a view, grouped, in its details;
254
+ - **move views to the trash**, where they stop counting in every statistic
255
+ at once.
256
+ - **Trash** holds what was moved there, to **restore** or **erase
257
+ permanently**.
258
+ - **Tracking log** lists every tracking request that reached the server and
259
+ what became of it: recorded, a repeat visit, a bot, or refused (and why:
260
+ an unregistered site, an unknown app, a malformed request, a rate limit).
261
+ Views holds only what was recorded; this log also shows what never was, so
262
+ it is where to check that a site is sending views, or find out why some are
263
+ not counted. It sums up the last day, filters by app, request type, and
264
+ outcome, can refresh itself, and opens any entry's view in Views. It is not
265
+ a statistic, and editing or deleting a view never changes it.
266
+ - **Admin log** lists every sign-in and every change made here.
267
+
268
+ The header links to this project's website and names the running version; the
269
+ footer links to the documentation, changelog, source, and package, carries the
270
+ copyright notice, and credits the location data.
200
271
 
201
272
  ### How the data is kept
202
273
 
@@ -207,15 +278,23 @@ at http://localhost:4173/admin/ over several thousand fake views (password
207
278
  | `note` | The admin's annotation, if any. |
208
279
  | `deleted_at` | Empty for live rows; set when the row went to the trash. |
209
280
 
210
- These columns, and the `_admin_log` and `_view_log` tables, are added
211
- automatically when the server starts, in both database modes, whether or not
281
+ These columns, the tracking columns in [What Gets Tracked?](#what-gets-tracked),
282
+ and the `_admin_log`, `_view_log`, `_tracking_rejections`, and
283
+ `_admin_sessions` tables are added automatically when the server starts, in both database modes, whether or not
212
284
  the admin UI is enabled. The upgrade is additive: nothing is dropped, and
213
285
  existing rows get their `public_id` on the first start, in batches that each
214
286
  resume where the last stopped, so a large table is read once. The database user
215
287
  therefore needs `CREATE`, `ALTER`, and `INDEX` as well as the usual privileges
216
288
  (see [Database Modes](#database-modes)).
217
289
 
218
- The view log gains one row per accepted view, so entries older than
290
+ | Table | Holds |
291
+ |---|---|
292
+ | `_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. |
293
+ | `_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. |
294
+ | `_admin_log` | Every sign-in and every change made in the admin. |
295
+ | `_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. |
296
+
297
+ The tracking log grows with traffic, so entries older than
219
298
  `VIEW_LOG_RETENTION_DAYS` (default 90) are removed hourly, in batches, and each
220
299
  run that removes anything is recorded in the admin log. It holds no personal
221
300
  data, so this only bounds its size; the views themselves are untouched. The
@@ -234,8 +313,9 @@ Neither log copies personal data, so erasing a row really erases it:
234
313
 
235
314
  - the admin log records who acted (a session ID and a masked IP), what they did,
236
315
  when, to which view IDs, and *which* fields changed, but never the values;
237
- - the view log records that a view was accepted, when, for which app, and
238
- through which endpoint, with no IP, visitor hash, or user agent.
316
+ - the tracking log records that a view was accepted, when, for which app and
317
+ site, and through which endpoint, with no IP, visitor hash, or user agent;
318
+ refused requests and bots are only counted, per minute.
239
319
 
240
320
  ### Security
241
321
 
@@ -244,8 +324,15 @@ Neither log copies personal data, so erasing a row really erases it:
244
324
  and the server warns if you reuse an API key as the password.
245
325
  - Signing in issues an `HttpOnly`, `SameSite=Strict` session cookie scoped to
246
326
  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). Sessions end after 30 idle minutes or 12
248
- hours, and on restart.
327
+ `TRUST_PROXY` behind a proxy).
328
+ - Sessions are kept in the database as a hash of their token, so a restart
329
+ does not sign anyone out. One ends after 7 days without use or 30 days after
330
+ signing in (`ADMIN_SESSION_IDLE_TIMEOUT`, `ADMIN_SESSION_MAX_AGE`). Using the
331
+ UI keeps it alive, reading included. If it ends mid-use, a sign-in dialog
332
+ opens over the page and whatever you were doing carries on after it.
333
+ - Erasing permanently asks for the password again unless it was entered in the
334
+ last 15 minutes, whatever the session's age; a wrong one counts toward the
335
+ sign-in limit.
249
336
  - Every change also needs a per-session CSRF token and a matching `Origin`.
250
337
  - Wrong passwords are rate limited per IP (5 per 15 minutes) and recorded in
251
338
  the admin log. Requests refused before the password is checked, such as a
@@ -274,20 +361,54 @@ GET /registerView?appId=blog&deviceSize=medium
274
361
  # Enhanced with page tracking
275
362
  GET /registerView?appId=blog&deviceSize=medium&page=/blog/my-post&title=My%20Post
276
363
 
277
- # With referrer and session
278
- GET /registerView?appId=blog&deviceSize=medium&page=/blog/my-post&referrer=https://google.com&sessionId=abc123
364
+ # With referrer and campaign tags
365
+ GET /registerView?appId=blog&deviceSize=medium&page=/blog/my-post&referrer=https://google.com&utm_source=newsletter&utm_campaign=launch
279
366
  ```
280
367
 
281
- **Automatic tracking:**
282
- - ✅ IP address and geolocation
283
- - ✅ Browser, OS, device type (from User-Agent)
284
- - ✅ Referrer domain and source type
368
+ `utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, and `utm_content` are
369
+ the only parts of a URL's query that are kept (100 characters each); a landing
370
+ that carries one counts as a `campaign`. `sessionId` is optional and yours to
371
+ define; the tracker script never sends one.
372
+
373
+ **Recorded by the server:**
374
+ - ✅ Country (and region and city with a [city database](#location-data)), from the IP, which is then masked
375
+ - ✅ Browser, OS, device type, and their versions (from the User-Agent, which is not kept)
376
+ - ✅ The site visited (the hostname of the request's `Origin`) and the visitor's language (the primary subtag of `Accept-Language`)
377
+ - ✅ Referrer domain and source type; a referrer on the same site is `internal`
285
378
  - ✅ Duplicate prevention (configurable window)
379
+ - ✅ Bots are answered but never stored
286
380
 
287
- **Response**:
381
+ **Response**:
288
382
  ```json
289
- {"message": "Success!", "duplicate": false}
383
+ {"message": "Success!", "duplicate": false, "recorded": true, "id": "0b8c3c1e-3b1c-4f2c-9d4e-1a2b3c4d5e6f"}
384
+ ```
385
+ `id` is the view's public ID, which `/engage` takes. A bot gets
386
+ `{"recorded": false}` and a `200`, so it has no reason to retry.
387
+
388
+ #### Report Engagement
389
+ ```bash
390
+ POST /engage
391
+ Content-Type: text/plain # or application/json
392
+
393
+ {"appId": "blog", "id": "<the id /registerView returned>", "ms": 42000, "scroll": 80}
394
+ ```
395
+ How long the page was visible (`ms`, up to 6 hours) and how much of it had been
396
+ on screen (`scroll`, 0 to 100). A later report can only raise either. Each
397
+ report also marks the view as seen just now, which keeps its visitor in the
398
+ admin's **Right now** for the next few minutes; the tracker script sends one
399
+ every half minute while the page is being read. A report
400
+ for a view that is unknown, trashed, or older than a day changes nothing and is
401
+ counted in the tracking log as refused. `text/plain` is accepted so
402
+ `navigator.sendBeacon` can deliver it as the page closes, without a CORS
403
+ preflight. Answers `204`.
404
+
405
+ #### The Tracker Script
406
+ ```bash
407
+ GET /tracker.js
290
408
  ```
409
+ The script in [Client-Side Integration](#client-side-integration), served by
410
+ the server it reports to, so the two never drift apart. Other sites may load
411
+ it (`Cross-Origin-Resource-Policy: cross-origin`), and it is cached for an hour.
291
412
 
292
413
  #### Track Custom Event
293
414
  ```bash
@@ -298,17 +419,20 @@ Content-Type: application/json
298
419
  "appId": "blog",
299
420
  "eventType": "button_click",
300
421
  "eventData": {"button": "subscribe", "location": "header"},
301
- "sessionId": "abc123",
302
- "page": "/blog/my-post"
422
+ "page": "/blog/my-post",
423
+ "title": "My post"
303
424
  }
304
425
  ```
426
+ **Response**: `{"message": "Event tracked successfully", "recorded": true, "id": "<public ID>", "insertId": 42}`.
427
+ `insertId`, the internal row number, is deprecated and will be removed in 4.0;
428
+ use `id`. Custom events are never deduplicated.
305
429
 
306
430
  ### 📈 Analytics
307
431
 
308
432
  > **These endpoints require authentication.** They return your analytics data,
309
433
  > so every one of them expects a valid key in the `x-api-key` header. Configure
310
434
  > keys via `READ_API_KEYS` (comma-separated, minimum 32 characters each). With
311
- > none configured the read API returns `503` — it fails closed rather than
435
+ > none configured the read API returns `503`: it fails closed rather than
312
436
  > serving your data to anyone who asks.
313
437
  >
314
438
  > ```bash
@@ -489,79 +613,95 @@ Set `NODE_ENV=production` to hide error details in API responses.
489
613
 
490
614
  ## What Gets Tracked?
491
615
 
492
- For each view/event, the system automatically captures:
493
-
494
- | Field | Source | Description |
495
- |-------|--------|-------------|
496
- | **IP Address** | Request | Visitor IP |
497
- | **Country** | GeoIP lookup | 2-letter country code |
498
- | **Timestamp** | Server | When the event occurred |
499
- | **Device Size** | Query param | small, medium, large |
500
- | **Page Path** | Query param (optional) | e.g., `/blog/my-post` |
501
- | **Page Title** | Query param (optional) | e.g., "My Blog Post" |
502
- | **Referrer** | Query param (optional) | Full referrer URL, normally `document.referrer`; absent or empty means direct |
503
- | **Referrer Domain** | Parsed | e.g., `google.com` |
504
- | **Source Type** | Parsed | search, social, email, campaign, referral, direct |
505
- | **Browser** | User-Agent | e.g., Chrome, Safari, Firefox |
506
- | **Browser Version** | User-Agent | e.g., 120.0 |
507
- | **OS** | User-Agent | e.g., Windows, Mac OS, Linux |
508
- | **OS Version** | User-Agent | e.g., 10, 14.2 |
509
- | **Device Type** | User-Agent | desktop, mobile, tablet, tv, console |
510
- | **Session ID** | Query param (optional) | Group events by session |
511
- | **Event Type** | Query param/body | pageview, click, submit, etc. |
512
- | **Event Data** | Body (optional) | Custom JSON data |
616
+ Every field of a view, where it comes from, and the form it is stored in.
617
+ Nothing here identifies a person: the one pseudonymous value, the visitor
618
+ hash, changes every `UNIQUE_VISITOR_WINDOW_HOURS` and is never shown or
619
+ returned by any API.
620
+
621
+ | Field | Comes from | Stored as | Why |
622
+ |-------|-----------|-----------|-----|
623
+ | **Timestamp** | Server | When the view was recorded | Everything over time |
624
+ | **Masked IP** | Request | IPv4 with the last octet zeroed, IPv6 with the interface identifier zeroed | Abuse investigation at network level, never a person |
625
+ | **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 |
626
+ | **Country** | IP, looked up in memory | Two-letter code | Where visitors are |
627
+ | **Region, City** | IP, with an optional [city database](#location-data) | Names, such as Bavaria and Munich | Where visitors are, more finely |
628
+ | **Language** | `Accept-Language` | Primary subtag only, such as `de` (never `de-CH`, never a list) | Which languages to write in |
629
+ | **Site** | `Origin` of the request | Hostname only, such as `blog.example.com` | Several sites or subdomains on one app |
630
+ | **Page Path** | `page` | Path only, such as `/blog/my-post` (the tracker never sends a query string) | Which pages are read |
631
+ | **Page Title** | `title` | Text, up to 200 characters | Readable page names |
632
+ | **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 |
633
+ | **Referrer Domain, Source Type** | Derived from the referrer | Hostname; search, social, email, campaign, referral, internal, or direct | Grouping sources |
634
+ | **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 |
635
+ | **Device Size** | `deviceSize` | small, medium, large | Layout decisions |
636
+ | **Browser, OS, and versions** | User-Agent, parsed in memory | Names and versions, such as Chrome 140 on macOS 15 | Compatibility |
637
+ | **Device Type** | User-Agent | desktop, mobile, tablet, tv, console, wearable | Compatibility |
638
+ | **Time on page, Scroll depth, Last seen** | The tracker script's `/engage` report | Milliseconds visible (at most 6 hours); percent of the page seen; when the page last reported | Whether pages are read |
639
+ | **Event Type, Event Data** | `/event` | Type name; JSON up to 4 kB, as your site sends it | Custom events |
640
+ | **Session ID** | `sessionId` (optional) | As your site sends it | Your own grouping; the tracker never sends one |
641
+
642
+ **Never stored**: the raw IP address, the User-Agent string, cookies or any
643
+ other identifier from the device, any query string or fragment (of the page or
644
+ of its referrer) apart from the page's campaign tags, and anything about bots
645
+ or refused requests beyond a per-minute count. Before 3.2, a referrer was stored
646
+ as sent, query string included; the changelog shows how to strip older rows.
647
+
648
+ **Visits** are read from the visitor hash the way privacy-first analytics does
649
+ it: a visitor's page views belong to one visit until they pause for 30
650
+ minutes. Because the hash rotates, the same person on two days is two
651
+ visitors, and nothing links them.
652
+
653
+ Your site's privacy notice should still say that you measure visits this way,
654
+ and why (legitimate interest in understanding how the site is used). What you
655
+ send in `eventData` and `sessionId` is yours to keep free of personal data.
513
656
 
514
657
  ## Understanding `UNIQUE_VISITOR_WINDOW_HOURS`
515
658
 
516
659
  This setting prevents counting the same visitor multiple times within a time window.
517
660
 
518
661
  **How it works:**
519
- - When a view is registered, the system checks if the same IP has visited within the last X hours
520
- - If yes: Returns `{duplicate: true}` (doesn't count again)
521
- - If no: Inserts new view
662
+ - When a view is registered, the system checks whether the same visitor hash
663
+ (the same IP and browser, within the current window) already viewed the app
664
+ - If yes: the view is stored as a repeat (`{duplicate: true}`), which counts as
665
+ a view but not as a unique view
666
+ - If no: it is stored as a unique view
667
+
668
+ The window is also how often the visitor hash rotates, so it bounds how long
669
+ the same person counts as one visitor.
522
670
 
523
671
  **Examples:**
524
- - `24` (default): Same IP counts as 1 view per day
525
- - `0`: Disable duplicate prevention (count every request)
526
- - `168`: Same IP counts as 1 view per week
672
+ - `24` (default): the same visitor counts once per day
673
+ - `0`: disable duplicate prevention (the hash still rotates hourly)
674
+ - `168`: the same visitor counts once per week
527
675
 
528
676
  **Note:** Only applies to `pageview` events, not custom events.
529
677
 
530
678
  ### 🛡️ 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](file:///Users/harshankur/Desktop/codes/viewcounter/tests/privacyFailSafe.test.js)):
679
+ 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
680
  - **Query Interception**: Every single SQL `INSERT` is intercepted during tests.
533
681
  - **Regex Scanning**: We scan all query parameters against raw IP patterns (IPv4 and IPv6).
534
682
  - **Hard Enforcement**: If the system ever attempts to save an unmasked IP, the test suite immediately fails, preventing accidental privacy regressions.
535
683
 
536
684
  This makes ViewCounter not just "Privacy-First" by design, but **Privacy-Guaranteed** by automation.
537
685
 
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
686
  ## Security Features
547
687
 
548
688
  **Trust model.** The two write endpoints (`/registerView`, `/event`) are public
549
689
  because a browser on your site must be able to reach them. Everything that
550
690
  *reads* analytics is authenticated.
551
691
 
552
- - ✅ **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.
553
- - ✅ **Separate admin tier** — provisioning apps uses its own credential; a read key cannot provision and an admin key cannot read.
554
- - ✅ **Per-tenant rate limits** — an `appId`-keyed budget alongside the per-IP limit.
555
- - ✅ **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.
556
- - ✅ **SQL injection prevention** — every value is a bound parameter; the only interpolated identifier is `appId`, gated by the allowlist.
557
- - ✅ **Explicit CORS allowlist** — no wildcard, and writes can be bound to registered origins per app.
558
- - ✅ **Proxy-aware IP derivation** — client-supplied forwarding headers are not trusted unless `TRUST_PROXY` says so.
559
- - ✅ **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.
560
- - ✅ **Resource guards** — finite pool queue, per-statement timeout, rate limiting.
561
- - ✅ **No error leakage** — failures return a request id; the detail goes only to the server log.
692
+ - ✅ **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.
693
+ - ✅ **Separate admin tier**: provisioning apps uses its own credential; a read key cannot provision and an admin key cannot read.
694
+ - ✅ **Per-tenant rate limits**: an `appId`-keyed budget alongside the per-IP limit.
695
+ - ✅ **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.
696
+ - ✅ **SQL injection prevention**: every value is a bound parameter; the only interpolated identifier is `appId`, gated by the allowlist.
697
+ - ✅ **Explicit CORS allowlist**: no wildcard, and writes can be bound to registered origins per app.
698
+ - ✅ **Proxy-aware IP derivation**: client-supplied forwarding headers are not trusted unless `TRUST_PROXY` says so.
699
+ - ✅ **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.
700
+ - ✅ **Resource guards**: finite pool queue, per-statement timeout, rate limiting.
701
+ - ✅ **No error leakage**: failures return a request id; the detail goes only to the server log.
562
702
  - ✅ **Security headers** (Helmet.js) and `Cache-Control: no-store` on all analytics responses.
563
- - ✅ **Fail-fast config validation** — insecure defaults stop the boot rather than being silently accepted.
564
- - ✅ **Adversarial regression suite** — [`tests/security.test.js`](tests/security.test.js) covers header spoofing, auth bypass, injection-shaped input, oversized payloads, and prototype pollution.
703
+ - ✅ **Fail-fast config validation**: insecure defaults stop the boot rather than being silently accepted.
704
+ - ✅ **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
705
 
566
706
  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
707
 
@@ -617,7 +757,7 @@ router.afterEach(() => track()); // Vue / Nuxt
617
757
  useEffect(() => track(), [pathname]); // Next.js app router
618
758
  ```
619
759
 
620
- Run one instance for all your sites — one `appId` each, each with its own table
760
+ Run one instance for all your sites: one `appId` each, each with its own table
621
761
  and its own origin list.
622
762
 
623
763
  ## Multi-Tenancy
@@ -658,7 +798,7 @@ out-of-scope one, for the same reason.
658
798
  ### Provisioning a tenant at runtime
659
799
 
660
800
  `POST /apps` creates the app's table, records it, and adds it to the live
661
- allowlist — no restart, no config edit. It requires an **admin** key
801
+ allowlist, with no restart and no config edit. It requires an **admin** key
662
802
  (`ADMIN_API_KEYS`), which is a separate tier: a read key cannot provision, and
663
803
  an admin key cannot read analytics.
664
804
 
@@ -680,15 +820,24 @@ of letters, digits, underscore, and hyphen, and may not start with an underscore
680
820
 
681
821
  Two independent limits apply to writes:
682
822
 
683
- - `RATE_LIMIT_MAX` — per client IP. The single-abuser backstop.
684
- - `APP_RATE_LIMIT_MAX` — per `appId`. Stops one tenant consuming the budget
823
+ - `RATE_LIMIT_MAX`: per client IP. The single-abuser backstop.
824
+ - `APP_RATE_LIMIT_MAX`: per `appId`. Stops one tenant consuming the budget
685
825
  everyone else on the instance depends on. Keyed on `appId` alone, so it cannot
686
826
  be bypassed by rotating addresses. Set `0` to disable for single-tenant use.
687
827
 
828
+ Each limit is applied twice, as two separate budgets of that size: one for
829
+ engagement reports (`/engage`) and one for everything else. A page being read
830
+ reports every half minute, so each open, active tab costs two reports a minute;
831
+ on a shared budget, the readers behind one office address could have used it up
832
+ and had their page views refused. Apart, reports can only crowd out other
833
+ reports. With the defaults that is room for about 50 readers at once per
834
+ address and 500 per app; raise the limits if you expect more, or a reader's
835
+ time on page is only updated when their page is hidden or left.
836
+
688
837
  ### What is still yours to build
689
838
 
690
839
  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 — `POST /apps`
840
+ usage metering, no plan enforcement, and no self-serve signup flow: `POST /apps`
692
841
  is an admin action you would call from your own onboarding code.
693
842
 
694
843
  ## Deployment Modes
@@ -746,12 +895,12 @@ app.use('/analytics', createAnalyticsRouter({
746
895
  }));
747
896
  ```
748
897
 
749
- Endpoints then live under the prefix — `POST /analytics/event`,
898
+ Endpoints then live under the prefix: `POST /analytics/event`,
750
899
  `GET /analytics/stats/blog`, and so on.
751
900
 
752
901
  Two things the host application owns in this mode, because the router does not
753
902
  install them itself: `helmet()` and the CORS allowlist, and `trust proxy`. Set
754
- `app.set('trust proxy', <hop count>)` — never `true`, or callers can forge
903
+ `app.set('trust proxy', <hop count>)`, never `true`, or callers can forge
755
904
  their own IP through `X-Forwarded-For`.
756
905
 
757
906
  #### Adding the admin UI
@@ -790,38 +939,76 @@ const stopRetention = startRetention({
790
939
 
791
940
  ### 3. Browser client
792
941
 
793
- There is no published client package yet; the snippets below are the
794
- integration surface. See [Client-Side Integration](#client-side-integration).
942
+ The server serves its own tracker script at `/tracker.js`. See
943
+ [Client-Side Integration](#client-side-integration).
795
944
 
796
945
  ## Client-Side Integration
797
946
 
798
- ### Basic Tracking
947
+ ### The tracker script
948
+
949
+ One tag, anywhere in the page:
950
+
799
951
  ```html
800
- <script>
801
- // Track page view
802
- fetch('https://your-server.com/registerView?appId=blog&deviceSize=medium');
803
- </script>
952
+ <script defer src="https://your-server.com/tracker.js" data-app="blog"
953
+ data-hosts="blog.example.com"></script>
804
954
  ```
805
955
 
806
- ### Enhanced Tracking
956
+ It records a view of each page, including page changes in single-page apps
957
+ (`history.pushState`, `replaceState`, and the back button, each referred by
958
+ the page it left); how long each page was visible and how far it was
959
+ scrolled, reported when the page is hidden or left and every half minute
960
+ while it is being read; clicks on links to other sites (the other site's hostname only);
961
+ clicks on downloads (the file name only); and the landing URL's campaign tags.
962
+ It stores nothing on the device and sends no identifier, and it skips
963
+ automated browsers.
964
+
965
+ | Attribute | Default | Meaning |
966
+ |---|---|---|
967
+ | `data-app` | required | The app ID the views belong to |
968
+ | `data-hosts` | every host | Only track on these hostnames, comma-separated, so development servers and previews stay out of the data |
969
+ | `data-spa` | `true` | Treat history changes as page views |
970
+ | `data-hash` | none | Fragment prefixes, comma-separated (`#docs/,#spec/`), that count as their own page, for pages that route by fragment. Any other fragment stays part of the same page. A matching fragment is stored whole as part of the page, so list only prefixes whose fragments carry nothing private. As a referrer, such a page is its path alone |
971
+ | `data-heartbeat` | `true` | Report time on page every half minute while the page is visible and in use, not only when it is hidden or left. It keeps the visitor in the admin's **Right now** while they read one page, and saves the time of a tab the browser closes without warning. It stops after half an hour without any input |
972
+ | `data-outbound` | `true` | Record clicks on links to other sites, as `outbound` events |
973
+ | `data-downloads` | `true` | Record clicks on downloads (pdf, zip, dmg, docx, and so on), as `download` events |
974
+ | `data-respect-dnt` | `false` | Send nothing when the browser's Do Not Track is on |
975
+
976
+ Custom events: `window.viewcounter.track('signup', { plan: 'pro' })`.
977
+
978
+ For it to reach the server:
979
+
980
+ - the site's origin is in `CORS_ORIGINS`, and, if the app is bound to its
981
+ sites, registered for the app;
982
+ - with a Content Security Policy on the site, `script-src` and `connect-src`
983
+ allow the ViewCounter server.
984
+
985
+ Check the admin's **Tracking log** after adding it: every request shows up
986
+ there, recorded or not, with the reason when it was refused. A site missing
987
+ from `CORS_ORIGINS` shows up as "site not allowed: CORS_ORIGINS", counted from
988
+ the browser's preflight, since the request itself never arrives.
989
+
990
+ ### Without the script
991
+
992
+ The same requests by hand. Keep it this way round: nothing stored on the
993
+ device, no identifier generated in the browser.
994
+
807
995
  ```javascript
808
- // Generate a session ID (store in sessionStorage).
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({
996
+ const params = new URLSearchParams({
817
997
  appId: 'blog',
818
- deviceSize: window.innerWidth < 768 ? 'small' :
819
- window.innerWidth < 1200 ? 'medium' : 'large',
820
- page: window.location.pathname,
998
+ deviceSize: innerWidth < 768 ? 'small' : innerWidth < 1200 ? 'medium' : 'large',
999
+ page: location.pathname,
821
1000
  title: document.title,
822
1001
  referrer: document.referrer,
823
- sessionId: sessionId
824
- }));
1002
+ });
1003
+ // Only the campaign tags from the query string.
1004
+ for (const key of ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content']) {
1005
+ const value = new URLSearchParams(location.search).get(key);
1006
+ if (value) params.set(key, value);
1007
+ }
1008
+ const { id } = await fetch(`https://your-server.com/registerView?${params}`).then((r) => r.json());
1009
+ // Later, when the page is hidden: how long it was visible, and how far scrolled.
1010
+ navigator.sendBeacon('https://your-server.com/engage',
1011
+ new Blob([JSON.stringify({ appId: 'blog', id, ms: 42000, scroll: 80 })], { type: 'text/plain' }));
825
1012
  ```
826
1013
 
827
1014
  ### Track Custom Events
@@ -834,7 +1021,6 @@ async function trackEvent(eventType, eventData) {
834
1021
  appId: 'blog',
835
1022
  eventType,
836
1023
  eventData,
837
- sessionId: sessionStorage.getItem('sessionId'),
838
1024
  page: window.location.pathname
839
1025
  })
840
1026
  });
@@ -860,10 +1046,10 @@ npm run test:watch
860
1046
  # Run tests and persist database for inspection
861
1047
  npm run test:persist
862
1048
 
863
- # Run tests for CI/CD (no report generation)
1049
+ # The fast gate CI runs: lint and Jest with coverage, no browser, no report
864
1050
  npm run test:ci
865
1051
 
866
- # Run only the admin UI tests in a real browser (Playwright)
1052
+ # Run only the admin UI and tracker tests in a real browser (Playwright)
867
1053
  npx playwright install chromium # once
868
1054
  npm run test:ui
869
1055
  ```
@@ -871,6 +1057,12 @@ npm run test:ui
871
1057
  `npm test` includes the Playwright suite, so run `npx playwright install
872
1058
  chromium` once before the first run.
873
1059
 
1060
+ CI runs everything except the browser tests: lint, Jest with its coverage
1061
+ floor, the dependency audit, the tarball check, and the end-to-end run against
1062
+ a real MySQL. It does not download a browser, to save CI time, so the browser
1063
+ tests are a local step: run `npm run test:ui` whenever you change anything
1064
+ under `admin/` or `tracker/`, and the full `npm test` before a release.
1065
+
874
1066
  ### Test Database
875
1067
 
876
1068
  **Automatic Management:**
@@ -940,7 +1132,7 @@ All endpoints are tested with:
940
1132
 
941
1133
  ## Releasing
942
1134
 
943
- Publishing to npm is a manual, deliberate step — an npm version number can never
1135
+ Publishing to npm is a manual, deliberate step: an npm version number can never
944
1136
  be reused, so it is not wired to run on merge.
945
1137
 
946
1138
  ```bash