Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import * as Sentry from '@sentry/browser';

window.Sentry = Sentry;

Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
integrations: [
Sentry.browserTracingIntegration({
idleTimeout: 1000,
}),
],
tracesSampleRate: 1,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>
<div>Rendered</div>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { expect } from '@playwright/test';
import type { Event } from '@sentry/core';
import { sentryTest } from '../../../../utils/fixtures';
import { getFirstSentryEnvelopeRequest, shouldSkipTracingTest } from '../../../../utils/helpers';

sentryTest.beforeEach(({ browserName }) => {
if (shouldSkipTracingTest() || browserName !== 'chromium') {
sentryTest.skip();
}
});

// `connection.rtt` is recorded as a measurement, which is only flushed on the pageload
// transaction. It must not leak onto navigation transactions.
sentryTest(
'records `connection.rtt` as a measurement on pageload but not on navigation transactions',
async ({ getLocalTestUrl, page }) => {
const url = await getLocalTestUrl({ testDir: __dirname });

const pageloadRequest = await getFirstSentryEnvelopeRequest<Event>(page, url);
const navigationRequest = await getFirstSentryEnvelopeRequest<Event>(page, `${url}#foo`);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

E2E test uses unreliable getFirstSentryEnvelopeRequest helper

Medium Severity

The new E2E test uses getFirstSentryEnvelopeRequest which is flagged as unreliable by the project rules. The sequential pattern of awaiting the pageload request and then immediately navigating and awaiting the navigation request is prone to race conditions — stale or unrelated envelopes (e.g. session updates) could be captured instead of the expected transaction. Helpers like waitForTransaction with appropriate filtering would be more reliable.

Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit 98a76d7. Configure here.


expect(pageloadRequest.contexts?.trace?.op).toBe('pageload');
expect(navigationRequest.contexts?.trace?.op).toBe('navigation');

expect(pageloadRequest.measurements?.['connection.rtt']?.value).toBeDefined();
expect(navigationRequest.measurements?.['connection.rtt']).toBeUndefined();
},
);
1 change: 1 addition & 0 deletions packages/browser-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export {

export {
addPerformanceEntries,
addWebVitalsToSpan,
startTrackingInteractions,
startTrackingLongTasks,
startTrackingLongAnimationFrames,
Expand Down
106 changes: 68 additions & 38 deletions packages/browser-utils/src/metrics/browserMetrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,9 +123,11 @@ export function startTrackingWebVitals({
: undefined;

const ttfbCleanupCallback = _trackTtfb();
const fpFcpCleanupCallback = _trackFpFcp();

return (): void => {
ttfbCleanupCallback();
fpFcpCleanupCallback();
lcpCleanupCallback?.();
clsCleanupCallback?.();
};
Expand Down Expand Up @@ -303,7 +305,47 @@ function _trackTtfb(): () => void {
});
}

/** Starts tracking First Paint and First Contentful Paint on the current page. */
function _trackFpFcp(): () => void {
return addPerformanceInstrumentationHandler('paint', ({ entries }) => {
const firstHidden = getVisibilityWatcher();
for (const entry of entries) {
// Only report if the page wasn't hidden prior to the web vital.
const shouldRecord = entry.startTime < firstHidden.firstHiddenTime;
if (entry.name === 'first-paint' && shouldRecord) {
_measurements['fp'] = { value: entry.startTime, unit: 'millisecond' };
}
if (entry.name === 'first-contentful-paint' && shouldRecord) {
_measurements['fcp'] = { value: entry.startTime, unit: 'millisecond' };
}
}
});
}

interface AddPerformanceEntriesOptions {
/**
* Resource spans with `op`s matching strings in the array will not be emitted.
*
* Default: []
*/
ignoreResourceSpans: Array<'resouce.script' | 'resource.css' | 'resource.img' | 'resource.other' | string>;

/**
* Performance spans created from browser Performance APIs,
* `performance.mark(...)` nand `performance.measure(...)`
* with `name`s matching strings in the array will not be emitted.
*
* Default: []
*/
ignorePerformanceApiSpans: Array<string | RegExp>;

/**
* Whether span streaming is enabled.
*/
spanStreamingEnabled?: boolean;
}

interface AddWebVitalsToSpanOptions {
/**
* Flag to determine if CLS should be recorded as a measurement on the pageload span or
* sent as a standalone span instead.
Expand All @@ -322,22 +364,6 @@ interface AddPerformanceEntriesOptions {
*/
recordLcpOnPageloadSpan: boolean;

/**
* Resource spans with `op`s matching strings in the array will not be emitted.
*
* Default: []
*/
ignoreResourceSpans: Array<'resouce.script' | 'resource.css' | 'resource.img' | 'resource.other' | string>;

/**
* Performance spans created from browser Performance APIs,
* `performance.mark(...)` nand `performance.measure(...)`
* with `name`s matching strings in the array will not be emitted.
*
* Default: []
*/
ignorePerformanceApiSpans: Array<string | RegExp>;

/**
* Whether span streaming is enabled.
*/
Expand All @@ -353,13 +379,7 @@ export function addPerformanceEntries(span: Span, options: AddPerformanceEntries
return;
}

const {
spanStreamingEnabled,
ignorePerformanceApiSpans,
ignoreResourceSpans,
recordClsOnPageloadSpan,
recordLcpOnPageloadSpan,
} = options;
const { spanStreamingEnabled, ignorePerformanceApiSpans, ignoreResourceSpans } = options;

const timeOrigin = msToSec(origin);

Expand Down Expand Up @@ -390,18 +410,6 @@ export function addPerformanceEntries(span: Span, options: AddPerformanceEntries
case 'paint':
case 'measure': {
_addMeasureSpans(span, entry, startTime, duration, timeOrigin, ignorePerformanceApiSpans);

// capture web vitals
const firstHidden = getVisibilityWatcher();
// Only report if the page wasn't hidden prior to the web vital.
const shouldRecord = entry.startTime < firstHidden.firstHiddenTime;

if (entry.name === 'first-paint' && shouldRecord) {
_measurements['fp'] = { value: entry.startTime, unit: 'millisecond' };
}
if (entry.name === 'first-contentful-paint' && shouldRecord) {
_measurements['fcp'] = { value: entry.startTime, unit: 'millisecond' };
}
break;
}
case 'resource': {
Expand All @@ -423,9 +431,28 @@ export function addPerformanceEntries(span: Span, options: AddPerformanceEntries
_performanceCursor = Math.max(performanceEntries.length - 1, 0);

_trackNavigator(span, spanStreamingEnabled);
}

/**
* Writes the collected web vitals (LCP, CLS, INP, TTFB, FP, FCP) onto the pageload span,
* either as measurements/attributes (v1) or as web vital attributes (span streaming).
*
* This should be called when the pageload span ends, after the web vitals have been finalized.
* It is a no-op for non-pageload spans, but always resets the collected web vital state so it
* doesn't leak into a subsequent navigation.
*/
export function addWebVitalsToSpan(span: Span, options: AddWebVitalsToSpanOptions): void {
const origin = browserPerformanceTimeOrigin();
if (!getBrowserPerformanceAPI()?.getEntries || !origin) {
// Gatekeeper if performance API not available
return;
}

const { spanStreamingEnabled, recordClsOnPageloadSpan, recordLcpOnPageloadSpan } = options;
const timeOrigin = msToSec(origin);

// Measurements are only available for pageload transactions
if (op === 'pageload') {
if (spanToJSON(span).op === 'pageload') {
_addTtfbRequestTimeToMeasurements(_measurements);

if (spanStreamingEnabled) {
Expand Down Expand Up @@ -794,9 +821,12 @@ function _trackNavigator(span: Span, spanStreamingEnabled: boolean | undefined):
}

if (isMeasurementValue(connection.rtt)) {
_measurements['connection.rtt'] = { value: connection.rtt, unit: 'millisecond' };
if (spanStreamingEnabled) {
span.setAttribute('network.connection.rtt', connection.rtt);
} else if (spanToJSON(span).op === 'pageload') {
// Measurements are only recorded on the pageload span, matching the historical
// behavior where `connection.rtt` was only flushed for pageload transactions.
setMeasurement('connection.rtt', connection.rtt, 'millisecond');
Comment thread
cursor[bot] marked this conversation as resolved.
}
}
}
Expand All @@ -819,7 +849,7 @@ function _trackNavigator(span: Span, spanStreamingEnabled: boolean | undefined):
}

/** Add LCP / CLS data to span to allow debugging */
function _setWebVitalAttributes(span: Span, options: AddPerformanceEntriesOptions): void {
function _setWebVitalAttributes(span: Span, options: AddWebVitalsToSpanOptions): void {
// Only add LCP attributes if LCP is being recorded on the pageload span
if (_lcpEntry && options.recordLcpOnPageloadSpan) {
// Capture Properties of the LCP element that contributes to the LCP.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export { setActiveSpanInBrowser } from './tracing/setActiveSpan';

export { spanStreamingIntegration } from './integrations/spanstreaming';
export { fetchStreamPerformanceIntegration } from './integrations/fetchStreamPerformance';
export { webVitalsIntegration } from './integrations/webVitals';

export {
feedbackIntegrationShim as feedbackAsyncIntegration,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export { setActiveSpanInBrowser } from './tracing/setActiveSpan';

export { spanStreamingIntegration } from './integrations/spanstreaming';
export { fetchStreamPerformanceIntegration } from './integrations/fetchStreamPerformance';
export { webVitalsIntegration } from './integrations/webVitals';

export { getFeedback, sendFeedback } from '@sentry-internal/feedback';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export { reportPageLoaded } from './tracing/reportPageLoaded';

export { spanStreamingIntegration } from './integrations/spanstreaming';
export { fetchStreamPerformanceIntegration } from './integrations/fetchStreamPerformance';
export { webVitalsIntegration } from './integrations/webVitals';

export { getFeedback, sendFeedback } from '@sentry-internal/feedback';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export { setActiveSpanInBrowser } from './tracing/setActiveSpan';

export { spanStreamingIntegration } from './integrations/spanstreaming';
export { fetchStreamPerformanceIntegration } from './integrations/fetchStreamPerformance';
export { webVitalsIntegration } from './integrations/webVitals';

export { feedbackIntegrationShim as feedbackAsyncIntegration, feedbackIntegrationShim as feedbackIntegration };

Expand Down
1 change: 1 addition & 0 deletions packages/browser/src/index.bundle.tracing.replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export { setActiveSpanInBrowser } from './tracing/setActiveSpan';

export { spanStreamingIntegration } from './integrations/spanstreaming';
export { fetchStreamPerformanceIntegration } from './integrations/fetchStreamPerformance';
export { webVitalsIntegration } from './integrations/webVitals';

export { feedbackIntegrationShim as feedbackAsyncIntegration, feedbackIntegrationShim as feedbackIntegration };

Expand Down
1 change: 1 addition & 0 deletions packages/browser/src/index.bundle.tracing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export { reportPageLoaded } from './tracing/reportPageLoaded';

export { spanStreamingIntegration } from './integrations/spanstreaming';
export { fetchStreamPerformanceIntegration } from './integrations/fetchStreamPerformance';
export { webVitalsIntegration } from './integrations/webVitals';

export {
feedbackIntegrationShim as feedbackAsyncIntegration,
Expand Down
1 change: 1 addition & 0 deletions packages/browser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export { reportPageLoaded } from './tracing/reportPageLoaded';
export { setActiveSpanInBrowser } from './tracing/setActiveSpan';
export { spanStreamingIntegration } from './integrations/spanstreaming';
export { fetchStreamPerformanceIntegration } from './integrations/fetchStreamPerformance';
export { webVitalsIntegration } from './integrations/webVitals';

export type { RequestInstrumentationOptions } from './tracing/request';
export {
Expand Down
100 changes: 100 additions & 0 deletions packages/browser/src/integrations/webVitals.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import type { Client, IntegrationFn, Span } from '@sentry/core/browser';
import { defineIntegration, hasSpanStreamingEnabled } from '@sentry/core/browser';
import {
addWebVitalsToSpan,
registerInpInteractionListener,
startTrackingINP,
startTrackingWebVitals,
trackClsAsSpan,
trackInpAsSpan,
trackLcpAsSpan,
} from '@sentry-internal/browser-utils';

export const WEB_VITALS_INTEGRATION_NAME = 'WebVitals';

export type WebVitalName = 'cls' | 'inp' | 'lcp';

export interface WebVitalsOptions {
/**
* Web vitals to skip.
*/
ignore?: WebVitalName[];

/**
* @experimental
*/
_experiments?: Partial<{
enableStandaloneClsSpans: boolean;
enableStandaloneLcpSpans: boolean;
}>;
}

const collectWebVitalsCallbacks = new WeakMap<Client, (span: Span) => void>();

/**
* Finalizes the collected web vitals and writes them onto the given (pageload) span.
* Called by `browserTracingIntegration` when the pageload/navigation span ends.
*/
export function collectWebVitalsForClient(client: Client, span: Span): void {
collectWebVitalsCallbacks.get(client)?.(span);
}

/**
* Captures Core Web Vitals (LCP, CLS, INP) and related pageload vitals.
*
* `browserTracingIntegration` auto-registers this integration if no
* `webVitalsIntegration` is already present, so explicit registration is only
* needed to customize options or to use it without `browserTracingIntegration`.
*/
export const webVitalsIntegration = defineIntegration((options: WebVitalsOptions = {}) => {
const ignored = new Set(options.ignore ?? []);

return {
name: WEB_VITALS_INTEGRATION_NAME,
setup(client) {
const spanStreamingEnabled = hasSpanStreamingEnabled(client);
const { enableStandaloneClsSpans, enableStandaloneLcpSpans } = options._experiments ?? {};

const recordClsStandaloneSpans =
spanStreamingEnabled || ignored.has('cls') ? undefined : enableStandaloneClsSpans || false;
const recordLcpStandaloneSpans =
spanStreamingEnabled || ignored.has('lcp') ? undefined : enableStandaloneLcpSpans || false;

const finalizeWebVitals = startTrackingWebVitals({
recordClsStandaloneSpans,
recordLcpStandaloneSpans,
client,
});

collectWebVitalsCallbacks.set(client, span => {
finalizeWebVitals();
addWebVitalsToSpan(span, {
// CLS/LCP are recorded as pageload span measurements only when they're neither
// tracked as standalone spans nor handled by span streaming (and not ignored).
recordClsOnPageloadSpan: recordClsStandaloneSpans === false,
recordLcpOnPageloadSpan: recordLcpStandaloneSpans === false,
spanStreamingEnabled,
});
});

if (spanStreamingEnabled) {
if (!ignored.has('lcp')) {
trackLcpAsSpan(client);
}
if (!ignored.has('cls')) {
trackClsAsSpan(client);
}
if (!ignored.has('inp')) {
trackInpAsSpan();
}
} else if (!ignored.has('inp')) {
startTrackingINP();
}
},
afterAllSetup() {
if (!ignored.has('inp')) {
registerInpInteractionListener();
}
},
};
}) satisfies IntegrationFn;
Loading
Loading