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 */ });
}