| /** | |
| * Field CLS attribution reporting (#4580). | |
| * | |
| * `reportClsMetric` shapes one web-vitals CLS measurement (attribution build) | |
| * into a Sentry event and routes it through `enqueueSentryCall` so it survives | |
| * Sentry's deferred (~10s idle) init. Reporting the largest shift target/value | |
| * lets field data name the real shifting element before we ship a layout fix. | |
| * 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 `onCLS` registration that calls this lives behind the `web-vitals` | |
| * dependency (see `registerClsReporting` 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 { getMoverRecordStrings, startClsMoverTracking } from '@/bootstrap/cls-mover-tracker'; | |
| import { enqueueSentryCall } from '@/bootstrap/sentry-defer'; | |
| import { | |
| getWebVitalsFormFactor, | |
| roundMs, | |
| shouldSampleWebVital, | |
| WEB_VITAL_SAMPLE_RATE, | |
| } from '@/bootstrap/web-vitals-utils'; | |
| /** Structural subset of web-vitals' CLS attribution (kept local to avoid the dep). */ | |
| export interface ClsAttributionLike { | |
| largestShiftTarget?: string; | |
| largestShiftValue?: number; | |
| largestShiftTime?: number; | |
| loadState?: string; | |
| } | |
| /** Structural subset of web-vitals' CLSMetricWithAttribution. */ | |
| export interface ClsMetricLike { | |
| value: number; | |
| rating?: 'good' | 'needs-improvement' | 'poor'; | |
| attribution?: ClsAttributionLike; | |
| } | |
| /** | |
| * Environment facts that split the field-only CLS classes (#4580): a | |
| * background-tab load revealed all at once, a top-of-page banner push, or a | |
| * below-fold panel swap all produce different (hiddenAtLoad, scrollY) pairs | |
| * that `largestShiftTarget` alone cannot distinguish. Injectable for tests. | |
| */ | |
| export interface ClsReportEnv { | |
| /** Document was hidden when CLS reporting registered (≈ background-tab load). */ | |
| hiddenAtLoad?: boolean; | |
| /** Document went hidden at least once before this report. */ | |
| hadHiddenPeriod?: boolean; | |
| /** document.visibilityState at report time. */ | |
| visibilityState?: string; | |
| /** window.scrollY at report time, rounded (0 ≈ top-of-page shift class). */ | |
| scrollY?: number; | |
| /** `${innerWidth}x${innerHeight}` at report time. */ | |
| viewport?: string; | |
| } | |
| // Set once by registerClsReporting(); module-scope so collectClsReportEnv() | |
| // can answer "was this a background-tab load" long after boot. | |
| let hiddenAtLoad: boolean | undefined; | |
| let hadHiddenPeriod = false; | |
| /** Snapshot the reporting environment. Safe in non-browser contexts. */ | |
| export function collectClsReportEnv(): ClsReportEnv { | |
| if (typeof document === 'undefined' || typeof window === 'undefined') return {}; | |
| return { | |
| hiddenAtLoad, | |
| hadHiddenPeriod, | |
| visibilityState: document.visibilityState, | |
| scrollY: Math.round(window.scrollY), | |
| viewport: `${window.innerWidth}x${window.innerHeight}`, | |
| }; | |
| } | |
| /** | |
| * Report one field CLS measurement to Sentry. `enqueue` is injectable for tests; | |
| * in production it defaults to the deferred-Sentry queue. | |
| */ | |
| export function reportClsMetric( | |
| metric: ClsMetricLike, | |
| enqueue: typeof enqueueSentryCall = enqueueSentryCall, | |
| env: ClsReportEnv = collectClsReportEnv(), | |
| keep: () => boolean = shouldSampleWebVital, | |
| movers: () => string[] = getMoverRecordStrings, | |
| ): void { | |
| // Volume trim: skip 'good' (<0.1) CLS and report needs-improvement / poor / | |
| // unknown only, so field attribution stays focused on actionable shifts. | |
| if (metric.rating === 'good') return; | |
| // Uniform sample of the surviving bad tail to cut Sentry volume ~80% without | |
| // biasing the rating/formFactor/shift-target distributions. | |
| if (!keep()) return; | |
| const a = metric.attribution ?? {}; | |
| const formFactor = getWebVitalsFormFactor(); | |
| // Snapshot at metric-report time. The Sentry closure may drain ~12s later, | |
| // after more shifts or a bfcache lifecycle reset have changed tracker state. | |
| const moverRecords = [...movers()]; | |
| enqueue((s) => { | |
| s.captureMessage('web-vital: CLS', { | |
| level: 'info', | |
| tags: { | |
| webvital: 'cls', | |
| formFactor, | |
| sampleRate: String(WEB_VITAL_SAMPLE_RATE), | |
| 'cls.rating': metric.rating ?? 'unknown', | |
| }, | |
| extra: { | |
| value: metric.value, | |
| largestShiftTarget: a.largestShiftTarget ?? 'unknown', | |
| largestShiftValue: a.largestShiftValue, | |
| largestShiftTime: roundMs(a.largestShiftTime), | |
| loadState: a.loadState, | |
| hiddenAtLoad: env.hiddenAtLoad, | |
| hadHiddenPeriod: env.hadHiddenPeriod, | |
| visibilityState: env.visibilityState, | |
| scrollY: env.scrollY, | |
| viewport: env.viewport, | |
| // #5332: shift-time mover attribution — which panels CHANGED HEIGHT | |
| // (movers) vs merely moved (victims) vs got inserted, captured by | |
| // cls-mover-tracker at the moment of each qualifying shift. The | |
| // victim-only largestShiftTarget cannot distinguish these. | |
| movers: moverRecords, | |
| }, | |
| }); | |
| }); | |
| } | |
| /** | |
| * Register the field CLS 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 | |
| * (including bfcache/visibility reports), matching the INP reporter. | |
| */ | |
| export function registerClsReporting(): void { | |
| if (typeof window === 'undefined') return; | |
| // Track visibility synchronously at boot: registration runs from main.ts, so | |
| // hidden-here ≈ the tab was opened in the background (cmd-click). The | |
| // listener stays for the page's life to catch later hide/reveal cycles. | |
| hiddenAtLoad = document.visibilityState === 'hidden'; | |
| hadHiddenPeriod = hiddenAtLoad; | |
| document.addEventListener('visibilitychange', () => { | |
| if (document.visibilityState === 'hidden') hadHiddenPeriod = true; | |
| }); | |
| // #5332: shift-time geometry tracking must start now — the CLS report fires | |
| // at hide time, long after the shifts, so movers are only nameable if the | |
| // per-panel cache diffs were captured when each shift happened. | |
| startClsMoverTracking(); | |
| void import('web-vitals/attribution') | |
| .then(({ onCLS }) => { | |
| onCLS((metric) => reportClsMetric(metric as unknown as ClsMetricLike)); | |
| }) | |
| .catch(() => { /* web-vitals chunk failed to load (adblock/CDN) - non-fatal */ }); | |
| } | |