Record Page Leave
Public exit-time ping for the LAST pageview of a session — the one that won't get its duration back-filled by a subsequent pageview. Captures duration (capped 1h) and scroll depth (clamped 0-100). Use sendBeacon.
PATCH /api/v1/analytics/pageview/:id/leave — 🌐 Public · Rate limit: 10 req / minute
Finalizes the duration and scroll depth of a single pageview. Earlier pageviews in the same session get their duration back-filled automatically by the next POST /pageview, so this endpoint really only matters for the last pageview of a session — the one followed by a tab close, navigation away, or full app exit.
Use navigator.sendBeacon from pagehide / beforeunload. A regular fetch from those handlers usually gets cancelled by the browser. sendBeacon is queued by the browser and delivered after the page unloads. The body is JSON; the server will accept it.
duration is capped at 3600s server-side. Math.min(duration, 3600) — anything you pass over an hour gets clamped. scrollDepth is clamped to [0, 100] before write — pass percentages, not pixel values.
Silent failures. update.catch(() => {}) — if the pageview id is wrong or the row's been pruned, you still get 200 { ok: true } and no error.
Request
Path parameters
| Param | Type | Required | Notes |
|---|---|---|---|
id | string (UUID) | ✓ | The pageview row id from POST /analytics/pageview. Not validated server-side — bad ids no-op silently. |
Body — PageLeaveDto
| Field | Type | Required | Validation | Notes |
|---|---|---|---|---|
duration | number (seconds) | ✓ | IsNumber Min(0) Max(3600) | Whole or fractional seconds the page was visible. DTO validates [0, 3600]; service additionally Math.min(duration, 3600) as belt-and-braces. |
scrollDepth | number (percent) | optional | IsNumber Min(0) Max(100) | Furthest scroll position as a 0-100 percentage. DTO validates the range; service additionally Math.min(Math.max(value, 0), 100). |
Headers
| Header | Required | Notes |
|---|---|---|
Content-Type: application/json | ✓ | sendBeacon will send application/x-www-form-urlencoded or Blob mime-type by default — wrap your JSON in a typed Blob to keep it application/json. See the sample. |
No auth.
Response
200 OK — ApiResponseOf<PageLeaveResultDto>
{ "success": true, "data": { "ok": true } }Same caveats as heartbeat — ok: true is "request reached service," not "row was updated."
Errors
| HTTP | Reason |
|---|---|
400 | DTO validation (duration outside [0, 3600], scrollDepth outside [0, 100], missing duration). |
429 | Over 10 req/min from this IP. |
Side effects
ThrottleGuard(10 req / 60s).analyticsDb.analyticsPageView.update({ where: { id }, data: { duration: Math.min(data.duration, 3600), scrollDepth: clamped(0..100) ?? null } }).catch(() => {}). Single SQL UPDATE; mis-id silently swallowed.- Return
{ ok: true }.
Code samples
curl -X PATCH https://api.bio.re/api/v1/analytics/pageview/pv-uuid/leave \
-H 'Content-Type: application/json' \
-d '{ "duration": 47, "scrollDepth": 65 }'async function pageLeave(
pageViewId: string,
data: { duration: number; scrollDepth?: number },
): Promise<void> {
await fetch(`https://api.bio.re/api/v1/analytics/pageview/${pageViewId}/leave`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
}).catch(() => {});
}// Fires reliably during page unload — fetch usually gets cancelled in unload handlers.
function leaveBeacon(
pageViewId: string,
data: { duration: number; scrollDepth?: number },
): boolean {
const url = `/api/v1/analytics/pageview/${pageViewId}/leave`;
const blob = new Blob([JSON.stringify(data)], { type: 'application/json' });
return navigator.sendBeacon(url, blob);
}
// Wire it up — pagehide is more reliable than beforeunload on mobile Safari.
window.addEventListener('pagehide', () => {
if (!lastPageViewId) return;
const duration = Math.floor((Date.now() - mountedAt) / 1000);
const scrollDepth = Math.round(
(window.scrollY + window.innerHeight) / document.documentElement.scrollHeight * 100,
);
leaveBeacon(lastPageViewId, { duration, scrollDepth });
});Try it
Path Parameters
Request Body
application/json
TypeScript Definitions
Use the request body type in TypeScript.
Response Body
application/json
curl -X PATCH "https://loading/api/v1/analytics/pageview/string/leave" \ -H "Content-Type: application/json" \ -d '{ "duration": 0 }'{
"success": true,
"data": {
"ok": true
}
}Source
| Source | Path | Lines |
|---|---|---|
| Controller | apps/api-core/src/modules/analytics/analytics.controller.ts | 168–177 (pageLeave) |
| DTO (request) | apps/api-core/src/modules/analytics/dto/tracking.dto.ts | 38–41 (PageLeaveDto) |
| DTO (response) | apps/api-core/src/modules/analytics/dto/analytics-client-response.dto.ts | 43–46 (PageLeaveResultDto) |
| Service | apps/api-core/src/modules/analytics/traffic-tracking.service.ts | 205–213 (pageLeave — duration cap + scrollDepth clamp + silent fail) |
| Prisma model | packages/prisma-analytics/prisma/schema.prisma | AnalyticsPageView.duration / scrollDepth lines 112–113 |
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.
Identify Session After Login
Bind the current authenticated userId to a previously-anonymous session id. Idempotent. Lets analytics correlate pre-login traffic with the resulting account.