Analytics API
First-party pageview collection and the aggregates it feeds
The analytics API is one write-only endpoint. It exists so the program office can answer "which parts of the site are used" without a third-party analytics service, an advertising network, or a tracking cookie. Nothing in this area reads data back; the only reader is the server-rendered traffic page, which queries AuditLog directly and is restricted to GOV_SYSTEM_ADMIN.
POST /api/analytics/pageview
Authentication: None required. A session, if present, is attributed. Success: 201.
| Field | Type | Required? | Constraints |
|---|---|---|---|
path | string | Yes | 1–2048 characters matching ^\/[^?#]*$ — a leading slash, and no query string or fragment. |
referrer | string | null | No | Same pattern as path, or null. |
curl -X POST http://localhost:3000/api/analytics/pageview \
-H 'content-type: application/json' \
-d '{"path":"/portal/calls","referrer":"/portal"}'Success:
{"ok":true}| Status | Meaning |
|---|---|
| 201 | The pageview row was written. |
| 400 | The body is not JSON, or path/referrer is absent, over-long, or carries a query or fragment. |
| 429 | More than 60 pageviews from this client IP in the last 60 seconds. |
| 500 | An unexpected server error; details stay in the operational log. |
The pattern constraint is the privacy control, not a formatting preference: rejecting ? and # means search terms, tokens, and filter values in a URL can never reach the analytics record even if a caller sends them. Callers must strip them client-side; the endpoint will not do it for them.
Each accepted request writes one AuditLog row with event: "pageview", the session email when a session exists (otherwise NULL), the trusted client IP, the user-agent truncated to 512 characters, and the submitted path and referrer. See Notifications and audit for the column semantics.
Rate limiting is per client IP, 60 per minute. When the IP cannot be trusted, all such requests share a single unknown bucket — a deliberate fail-closed choice that throttles unattributable traffic together rather than exempting it.
The browser side
src/app/analytics-tracker.tsx mounts once in the root layout and fires the request from a usePathname() effect, so every App Router navigation is counted, not just full page loads. It sends the referrer's pathname on the first load of a session and null afterwards, sets keepalive so a view still records when the visitor navigates away immediately, and swallows every failure.
There are no cookies and no localStorage involved, so there is no cross-session visitor identity — "distinct visitors" on the traffic page is a count of distinct IP values, which is an approximation, not a unique-user metric. Because collection is best-effort, counts are a floor: an ad blocker, an offline browser, or a tripped rate limit produces silence rather than an error.
Consequences for callers
An internal tool replaying navigation should not post pageviews on a user's behalf: rows would be attributed to the tool's IP and to whatever session cookie it carries. Scripted browser runs (including screenshot automation) do write rows, which is why traffic figures from a non-production environment reflect automation as much as people.
