BIO.RE
Analytics

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

FieldTypeRequiredValidationNotes
sessionIdstring (UUID)โœ“IsStringThe session id from POST /analytics/session. Server doesn't validate it exists โ€” non-matching ids land an orphan pageview row.
urlstringโœ“IsStringFull URL of the viewed page. Stored verbatim and used as the session's exitPage.
pathstringโœ“IsStringNormalized path (e.g. /creators/alice, /messages).
titlestringoptionalIsStringdocument.title.
bioPageIdstring (UUID)optionalIsStringThe bio page being viewed (if applicable).
linkClickedstring (UUID)optionalIsStringIf this view was triggered by a bio link click, the id of that link. Use to reconstruct the click flow.

Headers

HeaderRequiredNotes
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"
  }
}
FieldTypeNotes
idstring (UUID) | nullNew pageview row id, or null when the row insert threw (caught + logged).
enteredAtstring (ISO 8601)When the row was created (DB now()). Only present when id is non-null.

Errors

HTTPReason
400DTO validation (missing sessionId / url / path).
429Over 30 req/min from this IP.

DB / session-not-found failures are not errors โ€” they become 200 { id: null }.

Side effects

  1. ThrottleGuard (30 req / 60s).
  2. analyticsDb.analyticsPageView.create({ data: { id: randomUUID(), sessionId, url, path, title, bioPageId, linkClicked } }).
  3. 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.
  4. Back-fill prev duration: findFirst({ sessionId, id: NOT this.id, orderBy: enteredAt desc }) โ†’ if found, update prev.duration = (this.enteredAt - prev.enteredAt) / 1000 when 0 < duration < 3600. Out-of-range or query failure โ†’ silently skipped.
  5. 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

POST
/api/v1/analytics/pageview

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

SourcePathLines
Controllerapps/api-core/src/modules/analytics/analytics.controller.ts146โ€“155 (recordPageView)
DTO (request)apps/api-core/src/modules/analytics/dto/tracking.dto.ts29โ€“36 (RecordPageViewDto)
DTO (response)apps/api-core/src/modules/analytics/dto/analytics-client-response.dto.ts24โ€“32 (PageViewCreatedDto)
Serviceapps/api-core/src/modules/analytics/traffic-tracking.service.ts139โ€“193 (recordPageView โ€” atomic UPDATE, prev-duration back-fill)
Prisma modelpackages/prisma-analytics/prisma/schema.prismaAnalyticsPageView lines 102โ€“119, AnalyticsSession.pageViews / bounced / exitPage / lastActivityAt 46โ€“96

On this page