Record Page View
Public pageview endpoint. Atomic session-counter update, bounce-flag flip on the second view, automatic duration backfill on the previous view. Silently swallows errors.
POST /api/v1/analytics/pageview โ ๐ Public ยท Rate limit: 30 req / minute
Records one pageview against an existing analytics session. Each successful call does three things in the database โ creates the pageview row, atomically increments the parent session's counter (and flips the bounced flag from true โ false on the second view), and back-fills duration on the previous pageview. All three are best-effort: failures are swallowed and the response is 200 { id: null } so client instrumentation never errors out a page render.
Atomic counter update prevents the bounce-flag race. The session's pageViews increment and the bounced flag flip happen in a single SQL UPDATE ... SET pageViews = pageViews + 1, bounced = CASE WHEN pageViews + 1 >= 2 THEN false ELSE true END. Earlier read-then-write versions had a race where two concurrent pageviews could both observe pageViews = 1 and incorrectly set bounced = true.
Previous pageview's duration is back-filled here, not on pageLeave. When this endpoint runs, it looks up the user's most-recent prior pageview in the same session and sets its duration = (this.enteredAt - prev.enteredAt) / 1000, capped 0โ3600s (anything outside that range is silently dropped). The dedicated pageLeave endpoint is only needed to capture duration on the last view in a session (no follow-up pageview will ever back-fill it).
Silent failures. If the session doesn't exist (expired / cleaned up / wrong id), the page-view insert may fail or the session UPDATE will simply match zero rows. Either way the endpoint returns 200 { id: null } (or { id } from the new row even when the session update no-ops). Don't treat a non-null id as proof the session counter advanced โ the row itself can land while the session UPDATE matches nothing.
Request
Body โ RecordPageViewDto
| Field | Type | Required | Validation | Notes |
|---|---|---|---|---|
sessionId | string (UUID) | โ | IsString | The session id from POST /analytics/session. Server doesn't validate it exists โ non-matching ids land an orphan pageview row. |
url | string | โ | IsString | Full URL of the viewed page. Stored verbatim and used as the session's exitPage. |
path | string | โ | IsString | Normalized path (e.g. /creators/alice, /messages). |
title | string | optional | IsString | document.title. |
bioPageId | string (UUID) | optional | IsString | The bio page being viewed (if applicable). |
linkClicked | string (UUID) | optional | IsString | If this view was triggered by a bio link click, the id of that link. Use to reconstruct the click flow. |
Headers
| Header | Required | Notes |
|---|---|---|
Content-Type: application/json | โ | Standard. |
No auth โ endpoint is @Public().
Response
200 OK โ ApiResponseOf<PageViewCreatedDto>
{
"success": true,
"data": {
"id": "pv-uuid-...",
"enteredAt": "2026-04-30T10:00:01.123Z"
}
}| Field | Type | Notes |
|---|---|---|
id | string (UUID) | null | New pageview row id, or null when the row insert threw (caught + logged). |
enteredAt | string (ISO 8601) | When the row was created (DB now()). Only present when id is non-null. |
Errors
| HTTP | Reason |
|---|---|
400 | DTO validation (missing sessionId / url / path). |
429 | Over 30 req/min from this IP. |
DB / session-not-found failures are not errors โ they become 200 { id: null }.
Side effects
ThrottleGuard(30 req / 60s).analyticsDb.analyticsPageView.create({ data: { id: randomUUID(), sessionId, url, path, title, bioPageId, linkClicked } }).- Atomic session update via
analyticsDb.$executeRaw:UPDATE "AnalyticsSession" SET "pageViews" = "pageViews" + 1, "exitPage" = $url, "lastActivityAt" = NOW(), "bounced" = CASE WHEN "pageViews" + 1 >= 2 THEN false ELSE true END WHERE id = $sessionId::uuid.catch(() => {})โ failures swallowed. - Back-fill prev
duration:findFirst({ sessionId, id: NOT this.id, orderBy: enteredAt desc })โ if found,update prev.duration = (this.enteredAt - prev.enteredAt) / 1000when0 < duration < 3600. Out-of-range or query failure โ silently skipped. - Return
{ id, enteredAt }(or{ id: null }on row-create exception).
Code samples
curl -X POST https://api.bio.re/api/v1/analytics/pageview \
-H 'Content-Type: application/json' \
-d '{
"sessionId": "ses-uuid-from-create",
"url": "https://bio.re/alice",
"path": "/alice",
"title": "Alice on bio.re",
"bioPageId": "11111111-2222-3333-4444-555555555555"
}'type RecordPageViewInput = {
sessionId: string;
url: string;
path: string;
title?: string;
bioPageId?: string;
linkClicked?: string;
};
type PageViewResult = { id: string | null; enteredAt?: string };
async function recordPageView(input: RecordPageViewInput): Promise<PageViewResult> {
const res = await fetch('https://api.bio.re/api/v1/analytics/pageview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
const json = await res.json();
return json.data;
}// React Router / Next.js example โ fire one pageview per route change.
let lastPageViewId: string | null = null;
useEffect(() => {
if (!sessionId) return;
// Fire-and-forget; capture id so pageLeave can finalize duration when leaving.
recordPageView({
sessionId,
url: location.href,
path: location.pathname,
title: document.title,
}).then(({ id }) => { lastPageViewId = id; });
}, [pathname]);
// On unmount / before route change, send the leave event for accurate last-view duration:
useEffect(() => {
return () => {
if (lastPageViewId) {
const duration = Math.floor((Date.now() - mountedAt.current) / 1000);
pageLeave(lastPageViewId, { duration, scrollDepth: getScrollPercent() });
}
};
}, []);Try it
Request Body
application/json
TypeScript Definitions
Use the request body type in TypeScript.
Response Body
application/json
curl -X POST "https://loading/api/v1/analytics/pageview" \ -H "Content-Type: application/json" \ -d '{ "sessionId": "string", "url": "string", "path": "string" }'{
"success": true,
"data": {
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08"
}
}Source
| Source | Path | Lines |
|---|---|---|
| Controller | apps/api-core/src/modules/analytics/analytics.controller.ts | 146โ155 (recordPageView) |
| DTO (request) | apps/api-core/src/modules/analytics/dto/tracking.dto.ts | 29โ36 (RecordPageViewDto) |
| DTO (response) | apps/api-core/src/modules/analytics/dto/analytics-client-response.dto.ts | 24โ32 (PageViewCreatedDto) |
| Service | apps/api-core/src/modules/analytics/traffic-tracking.service.ts | 139โ193 (recordPageView โ atomic UPDATE, prev-duration back-fill) |
| Prisma model | packages/prisma-analytics/prisma/schema.prisma | AnalyticsPageView lines 102โ119, AnalyticsSession.pageViews / bounced / exitPage / lastActivityAt 46โ96 |
Create Analytics Session
Public session-start endpoint. GDPR consent gate, server-side UA + GeoIP, hashed visitorId, HMAC tracking-token requirement when bioPageId is present. Returns null when bot / no consent / forged token. Writes to the separate Analytics DB.
Session Heartbeat
Public keep-alive ping. Updates lastActivityAt and increments duration by a fixed 30 seconds. Designed to fire every 30s while the page is in the foreground.