File size: 4,189 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
/**
 * 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 */ });
}