File size: 6,471 Bytes
fa9c65f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 | /**
* 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 */ });
}
|