/** * Field LCP attribution reporting (#5079). * * `reportLcpMetric` shapes one web-vitals LCP measurement (attribution build) * into a Sentry event and routes it through `enqueueSentryCall` so it survives * Sentry's deferred (~10s idle) init. Reporting the LCP element selector plus * the four LCP phase sub-parts lets field data show whether last-mile latency * is server response, resource discovery, resource download, or render delay. * * Good-rated events are trimmed (#4565), so captured-event p75 is conditioned * on the bad tail. Verify fixes with bad-event rate per formFactor plus weekly * page-level CrUX queryHistoryRecord, not p75 of captured Sentry events. * * The `onLCP` registration that calls this lives behind the `web-vitals` * dependency (see `registerLcpReporting` doc at the bottom). This module keeps * the reportable logic free of that import so it builds and is unit-tested * without the package present. */ import { enqueueSentryCall } from '@/bootstrap/sentry-defer'; import { getWebVitalsFormFactor, roundMs, sanitizeWebVitalUrl, shouldSampleWebVital, WEB_VITAL_SAMPLE_RATE, } from '@/bootstrap/web-vitals-utils'; const MAX_LCP_ELEMENT_TAG_LENGTH = 200; /** Structural subset of web-vitals' LCP attribution (kept local to avoid the dep). */ export interface LcpAttributionLike { target?: string; url?: string; timeToFirstByte?: number; resourceLoadDelay?: number; resourceLoadDuration?: number; elementRenderDelay?: number; } /** Structural subset of web-vitals' LCPMetricWithAttribution. */ export interface LcpMetricLike { value: number; rating?: 'good' | 'needs-improvement' | 'poor'; attribution?: LcpAttributionLike; } function normalizeLcpElementTag(target: string | undefined): string { const normalized = (target ?? '').replace(/\s+/g, ' ').trim(); return normalized ? normalized.slice(0, MAX_LCP_ELEMENT_TAG_LENGTH) : 'unknown'; } /** * Report one field LCP measurement to Sentry. `enqueue` is injectable for tests; * in production it defaults to the deferred-Sentry queue. */ export function reportLcpMetric( metric: LcpMetricLike, enqueue: typeof enqueueSentryCall = enqueueSentryCall, keep: () => boolean = shouldSampleWebVital, ): void { // Volume trim (#4565): skip 'good' (<=2500ms) LCP. Report // needs-improvement / poor / unknown only, so Sentry volume stays focused on // the bad tail while success is measured by bad-event rate per surface. if (metric.rating === 'good') return; // Uniform sample of the surviving bad tail to cut Sentry volume ~80% without // biasing the rating/formFactor/element-target distributions. if (!keep()) return; const a = metric.attribution ?? {}; const formFactor = getWebVitalsFormFactor(); const elementTag = normalizeLcpElementTag(a.target); enqueue((s) => { s.captureMessage('web-vital: LCP', { level: 'info', tags: { webvital: 'lcp', formFactor, sampleRate: String(WEB_VITAL_SAMPLE_RATE), 'lcp.rating': metric.rating ?? 'unknown', 'lcp.element': elementTag, }, extra: { value: Math.round(metric.value), elementTarget: a.target ?? 'unknown', url: sanitizeWebVitalUrl(a.url) || undefined, timeToFirstByte: roundMs(a.timeToFirstByte), resourceLoadDelay: roundMs(a.resourceLoadDelay), resourceLoadDuration: roundMs(a.resourceLoadDuration), elementRenderDelay: roundMs(a.elementRenderDelay), }, }); }); } /** * Register the field LCP listener. Browser-only. Uses a dynamic import so * `web-vitals` code-splits into its own chunk and so this module stays * node-loadable for unit tests. Uses web-vitals' default lifecycle cadence: * buffered LCP entries are reported when the value is ready, and bfcache restores * get their own metric instance. */ export function registerLcpReporting(): void { if (typeof window === 'undefined') return; void import('web-vitals/attribution') .then(({ onLCP }) => { onLCP((metric) => reportLcpMetric(metric as unknown as LcpMetricLike)); }) .catch(() => { /* web-vitals chunk failed to load (adblock/CDN) - non-fatal */ }); }