From 9c738b601bd5cb9cb45dfa37ad59e382a4bc8d65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 16 May 2026 17:13:12 +0200 Subject: [PATCH] fix(monitoring): sample web-vitals at 10% so the beacon stops self-throttling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web-vitals reporter posted every CLS/LCP/FCP/TTFB/INP measurement on every page-load, six beacons per navigation. In production this saturated the route-side per-IP rate-limit (60/min) inside seconds — the runtime audit captured POST /api/internal/web-vitals 429 for almost every metric after the first navigation, so most samples never reached the wide-event sink. Telemetry was self-throttling. Decide once per mount (memoised) whether the current page-load reports its vitals, sample at 10%, and either send every metric for that load or send none. Per-load all-or-none keeps each session's vitals coherent (an LCP from one navigation stays comparable to the CLS from the same navigation) while cutting traffic by an order of magnitude. The route-side rate-limit stays in place as a backstop. Refs: .planning/round-v1433-audit-runtime.md F19 --- .../monitoring/web-vitals-reporter.tsx | 40 ++++++++++++++++--- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/src/components/monitoring/web-vitals-reporter.tsx b/src/components/monitoring/web-vitals-reporter.tsx index 4f90ad5b5..093a64b94 100644 --- a/src/components/monitoring/web-vitals-reporter.tsx +++ b/src/components/monitoring/web-vitals-reporter.tsx @@ -1,24 +1,54 @@ "use client"; +import { useMemo } from "react"; import { useReportWebVitals } from "next/web-vitals"; /** - * v1.4.28 R3d — client-side mount that pipes every Web Vitals - * measurement to `/api/internal/web-vitals`. + * v1.4.28 R3d — client-side mount that pipes Web Vitals measurements + * to `/api/internal/web-vitals`. * * Next 16 ships `useReportWebVitals` as the supported hook for * capturing CLS / FCP / FID / LCP / TTFB / INP from the live - * navigation. The hook fires once per metric per page load, so even a - * heavy navigation produces at most six beacons. + * navigation. The hook fires once per metric per page load, so a + * heavy navigation can produce up to six beacons per route change. + * + * v1.4.33 F19 — the reporter previously posted EVERY measurement on + * EVERY page-load. In production the runtime audit captured + * `POST /api/internal/web-vitals 429` for almost every metric after + * the first navigation: six beacons × N navigations burned through + * the per-IP 60/min route-side rate-limit in seconds, so most samples + * were dropped before they reached the wide-event sink. Telemetry + * was self-throttled. + * + * Switch to client-side sampling at SAMPLE_RATE: each page-load + * decides ONCE (memoised via `useMemo`) whether to report and either + * sends every metric for that load or sends none. Per-load all-or- + * none keeps a sampled session's vitals coherent (the LCP from one + * page must be comparable to the CLS from the same page) while + * cutting traffic by an order of magnitude. The route-side rate-limit + * stays in place as a backstop against runaway sampling. * * Delivery transport: `navigator.sendBeacon` when available (queues * the POST without blocking the page lifecycle, survives the * `pagehide` event) with a `fetch({ keepalive: true })` fallback. - * The route accepts the payload best-effort and returns 200 even on + * The route accepts the payload best-effort and returns 204 even on * malformed bodies — beacons must never delay or fail the navigation. */ +const SAMPLE_RATE = 0.1; + export function WebVitalsReporter() { + // Decide once per mount whether this page-load reports its vitals. + // `useMemo` with an empty dep array keeps the decision stable across + // every metric callback inside the same navigation — partial sampling + // would mix LCP-without-CLS noise into the wide-event aggregates. + // `SSR` evaluates Math.random() to a stable false branch (Node), but + // the hook only fires post-hydration so the decision uses the + // browser's PRNG. + const sampled = useMemo(() => Math.random() < SAMPLE_RATE, []); + useReportWebVitals((metric) => { + if (!sampled) return; + const body = JSON.stringify({ id: metric.id, name: metric.name,