An official website of the United States GovernmentUnclassified
Seal of the Department of the NavyDepartment of the NavyProject ROUNDTABLE Docs
API reference

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.

FieldTypeRequired?Constraints
pathstringYes1–2048 characters matching ^\/[^?#]*$ — a leading slash, and no query string or fragment.
referrerstring | nullNoSame 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}
StatusMeaning
201The pageview row was written.
400The body is not JSON, or path/referrer is absent, over-long, or carries a query or fragment.
429More than 60 pageviews from this client IP in the last 60 seconds.
500An 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.