Skip to content
Draft
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
Expand Up @@ -8,6 +8,9 @@ import {
BROWSER_WEB_VITAL_INP_TARGET,
SENTRY_EXCLUSIVE_TIME,
BROWSER_WEB_VITAL_INP_INTERACTION_TYPE,
BROWSER_WEB_VITAL_INP_INPUT_DELAY,
BROWSER_WEB_VITAL_INP_PROCESSING_DURATION,
BROWSER_WEB_VITAL_INP_PRESENTATION_DELAY,
BROWSER_NAVIGATION_TYPE,
SENTRY_TRANSACTION,
SENTRY_SEGMENT_NAME,
Expand Down Expand Up @@ -108,6 +111,18 @@ sentryTest(
[SENTRY_EXCLUSIVE_TIME]: { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) },
[BROWSER_WEB_VITAL_INP_VALUE]: { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) },
[BROWSER_WEB_VITAL_INP_INTERACTION_TYPE]: { value: 'click', type: 'string' },
[BROWSER_WEB_VITAL_INP_INPUT_DELAY]: {
value: expect.any(Number),
type: expect.stringMatching(/^(integer)|(double)$/),
},
[BROWSER_WEB_VITAL_INP_PROCESSING_DURATION]: {
value: expect.any(Number),
type: expect.stringMatching(/^(integer)|(double)$/),
},
[BROWSER_WEB_VITAL_INP_PRESENTATION_DELAY]: {
value: expect.any(Number),
type: expect.stringMatching(/^(integer)|(double)$/),
},
[BROWSER_NAVIGATION_TYPE]: { value: 'navigate', type: 'string' },
[SENTRY_TRANSACTION]: { value: 'test-url', type: 'string' },
[SENTRY_SEGMENT_NAME]: { value: 'test-url', type: 'string' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import {
BROWSER_WEB_VITAL_INP_TARGET,
SENTRY_EXCLUSIVE_TIME,
BROWSER_WEB_VITAL_INP_INTERACTION_TYPE,
BROWSER_WEB_VITAL_INP_INPUT_DELAY,
BROWSER_WEB_VITAL_INP_PROCESSING_DURATION,
BROWSER_WEB_VITAL_INP_PRESENTATION_DELAY,
BROWSER_NAVIGATION_TYPE,
SENTRY_TRANSACTION,
SENTRY_SEGMENT_NAME,
Expand Down Expand Up @@ -108,6 +111,18 @@ sentryTest(
[SENTRY_EXCLUSIVE_TIME]: { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) },
[BROWSER_WEB_VITAL_INP_VALUE]: { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) },
[BROWSER_WEB_VITAL_INP_INTERACTION_TYPE]: { value: 'click', type: 'string' },
[BROWSER_WEB_VITAL_INP_INPUT_DELAY]: {
value: expect.any(Number),
type: expect.stringMatching(/^(integer)|(double)$/),
},
[BROWSER_WEB_VITAL_INP_PROCESSING_DURATION]: {
value: expect.any(Number),
type: expect.stringMatching(/^(integer)|(double)$/),
},
[BROWSER_WEB_VITAL_INP_PRESENTATION_DELAY]: {
value: expect.any(Number),
type: expect.stringMatching(/^(integer)|(double)$/),
},
[BROWSER_NAVIGATION_TYPE]: { value: 'navigate', type: 'string' },
[SENTRY_TRANSACTION]: { value: 'test-url', type: 'string' },
[SENTRY_SEGMENT_NAME]: { value: 'test-url', type: 'string' },
Expand Down Expand Up @@ -198,6 +213,18 @@ sentryTest(
[SENTRY_EXCLUSIVE_TIME]: { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) },
[BROWSER_WEB_VITAL_INP_VALUE]: { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) },
[BROWSER_WEB_VITAL_INP_INTERACTION_TYPE]: { value: 'click', type: 'string' },
[BROWSER_WEB_VITAL_INP_INPUT_DELAY]: {
value: expect.any(Number),
type: expect.stringMatching(/^(integer)|(double)$/),
},
[BROWSER_WEB_VITAL_INP_PROCESSING_DURATION]: {
value: expect.any(Number),
type: expect.stringMatching(/^(integer)|(double)$/),
},
[BROWSER_WEB_VITAL_INP_PRESENTATION_DELAY]: {
value: expect.any(Number),
type: expect.stringMatching(/^(integer)|(double)$/),
},
[BROWSER_NAVIGATION_TYPE]: { value: 'navigate', type: 'string' },
[SENTRY_TRANSACTION]: { value: 'test-url', type: 'string' },
[SENTRY_SEGMENT_NAME]: { value: 'test-url', type: 'string' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import {
BROWSER_WEB_VITAL_INP_TARGET,
SENTRY_EXCLUSIVE_TIME,
BROWSER_WEB_VITAL_INP_INTERACTION_TYPE,
BROWSER_WEB_VITAL_INP_INPUT_DELAY,
BROWSER_WEB_VITAL_INP_PROCESSING_DURATION,
BROWSER_WEB_VITAL_INP_PRESENTATION_DELAY,
BROWSER_NAVIGATION_TYPE,
SENTRY_TRANSACTION,
SENTRY_SEGMENT_NAME,
Expand Down Expand Up @@ -109,6 +112,18 @@ sentryTest(
[SENTRY_EXCLUSIVE_TIME]: { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) },
[BROWSER_WEB_VITAL_INP_VALUE]: { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) },
[BROWSER_WEB_VITAL_INP_INTERACTION_TYPE]: { value: 'click', type: 'string' },
[BROWSER_WEB_VITAL_INP_INPUT_DELAY]: {
value: expect.any(Number),
type: expect.stringMatching(/^(integer)|(double)$/),
},
[BROWSER_WEB_VITAL_INP_PROCESSING_DURATION]: {
value: expect.any(Number),
type: expect.stringMatching(/^(integer)|(double)$/),
},
[BROWSER_WEB_VITAL_INP_PRESENTATION_DELAY]: {
value: expect.any(Number),
type: expect.stringMatching(/^(integer)|(double)$/),
},
[BROWSER_NAVIGATION_TYPE]: { value: 'navigate', type: 'string' },
// the parametrized route name flows onto the INP span
[SENTRY_TRANSACTION]: { value: 'test-route', type: 'string' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import {
BROWSER_WEB_VITAL_INP_TARGET,
SENTRY_EXCLUSIVE_TIME,
BROWSER_WEB_VITAL_INP_INTERACTION_TYPE,
BROWSER_WEB_VITAL_INP_INPUT_DELAY,
BROWSER_WEB_VITAL_INP_PROCESSING_DURATION,
BROWSER_WEB_VITAL_INP_PRESENTATION_DELAY,
BROWSER_NAVIGATION_TYPE,
SENTRY_TRANSACTION,
SENTRY_SEGMENT_NAME,
Expand Down Expand Up @@ -109,6 +112,18 @@ sentryTest(
[SENTRY_EXCLUSIVE_TIME]: { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) },
[BROWSER_WEB_VITAL_INP_VALUE]: { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) },
[BROWSER_WEB_VITAL_INP_INTERACTION_TYPE]: { value: 'click', type: 'string' },
[BROWSER_WEB_VITAL_INP_INPUT_DELAY]: {
value: expect.any(Number),
type: expect.stringMatching(/^(integer)|(double)$/),
},
[BROWSER_WEB_VITAL_INP_PROCESSING_DURATION]: {
value: expect.any(Number),
type: expect.stringMatching(/^(integer)|(double)$/),
},
[BROWSER_WEB_VITAL_INP_PRESENTATION_DELAY]: {
value: expect.any(Number),
type: expect.stringMatching(/^(integer)|(double)$/),
},
[BROWSER_NAVIGATION_TYPE]: { value: 'navigate', type: 'string' },
// the parametrized route name flows onto the INP span
[SENTRY_TRANSACTION]: { value: 'test-route', type: 'string' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import {
SENTRY_EXCLUSIVE_TIME,
UI_COMPONENT_NAME,
BROWSER_WEB_VITAL_INP_TARGET,
BROWSER_WEB_VITAL_INP_INPUT_DELAY,
BROWSER_WEB_VITAL_INP_PROCESSING_DURATION,
BROWSER_WEB_VITAL_INP_PRESENTATION_DELAY,
} from '@sentry/conventions/attributes';
import { expect } from '@playwright/test';
import { sentryTest } from '../../../../utils/fixtures';
Expand Down Expand Up @@ -97,4 +100,9 @@ sentryTest('captures the slowest interaction as streamed INP span', async ({ get

const inpValue = inpSpan.attributes[BROWSER_WEB_VITAL_INP_VALUE]?.value as number;
expect(inpValue).toBeGreaterThan(400);

// The slow button blocks inside its click handler, so the time lands in processing.
expect(inpSpan.attributes[BROWSER_WEB_VITAL_INP_PROCESSING_DURATION]?.value).toBeGreaterThan(400);
expect(inpSpan.attributes[BROWSER_WEB_VITAL_INP_INPUT_DELAY]?.value).toBeGreaterThanOrEqual(0);
expect(inpSpan.attributes[BROWSER_WEB_VITAL_INP_PRESENTATION_DELAY]?.value).toBeGreaterThanOrEqual(0);
});
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import {
BROWSER_WEB_VITAL_LCP_RENDER_TIME,
BROWSER_WEB_VITAL_LCP_LOAD_TIME,
SENTRY_PAGELOAD_SPAN_ID,
BROWSER_WEB_VITAL_LCP_RESOURCE_LOAD_DELAY,
BROWSER_WEB_VITAL_LCP_RESOURCE_LOAD_DURATION,
BROWSER_WEB_VITAL_LCP_ELEMENT_RENDER_DELAY,
} from '@sentry/conventions/attributes';
import type { Route } from '@playwright/test';
import { expect } from '@playwright/test';
Expand Down Expand Up @@ -73,6 +76,17 @@ sentryTest('captures LCP as a streamed span with element attributes', async ({ g
expect(lcpSpan.attributes[BROWSER_WEB_VITAL_LCP_LOAD_TIME]?.value).toBeGreaterThan(0);
expect(lcpSpan.attributes[BROWSER_WEB_VITAL_LCP_VALUE]?.value).toBeCloseTo(renderTime);

// Together with TTFB, the subparts add up to the LCP value.
const subparts = [
BROWSER_WEB_VITAL_LCP_RESOURCE_LOAD_DELAY,
BROWSER_WEB_VITAL_LCP_RESOURCE_LOAD_DURATION,
BROWSER_WEB_VITAL_LCP_ELEMENT_RENDER_DELAY,
].map(key => lcpSpan.attributes[key]?.value as number);
subparts.forEach(subpart => expect(subpart).toBeGreaterThanOrEqual(0));
expect(subparts.reduce((sum, subpart) => sum + subpart, 0)).toBeLessThanOrEqual(
lcpSpan.attributes[BROWSER_WEB_VITAL_LCP_VALUE]?.value as number,
);

// Check pageload span id is present
expect(lcpSpan.attributes[SENTRY_PAGELOAD_SPAN_ID]?.value).toBe(pageloadSpan.span_id);

Expand Down
46 changes: 43 additions & 3 deletions packages/browser-utils/src/web-vitals/inp.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,23 @@
import type { Span } from '@sentry/core';
import type { Span, SpanAttributes } from '@sentry/core';
import { getActiveSpan, getRootSpan, isBrowser } from '@sentry/core';
import {
BROWSER_WEB_VITAL_INP_INPUT_DELAY,
BROWSER_WEB_VITAL_INP_PRESENTATION_DELAY,
BROWSER_WEB_VITAL_INP_PROCESSING_DURATION,
} from '@sentry/conventions/attributes';
import { htmlTreeAsString } from '../htmlTreeAsString';
import { WINDOW } from '../types';
import { addPerformanceInstrumentationHandler, isPerformanceEventTiming } from '../instrumentation/performanceObserver';

interface InteractionContext {
span: Span | undefined;
elementName: string;
// Merged across every event of the interaction. web-vitals only keeps the entries that share the
// longest entry's start time, which drops e.g. a slow `click` that starts after its `pointerdown`.
startTime: number;
processingStart: number;
processingEnd: number;
nextPaintTime: number;
}

const LAST_INTERACTIONS: number[] = [];
Expand Down Expand Up @@ -148,8 +159,16 @@ export function registerInpInteractionListener(): void {
return;
}

// If the interaction was already recorded before, nothing more to do
if (INTERACTIONS_SPAN_MAP.has(interactionId)) {
const nextPaintTime = entry.startTime + entry.duration;
const context = INTERACTIONS_SPAN_MAP.get(interactionId);

// If the interaction was already recorded before, only its timings can change
if (context) {
context.startTime = Math.min(context.startTime, entry.startTime);
context.processingStart = Math.min(context.processingStart, entry.processingStart);
context.processingEnd = Math.max(context.processingEnd, entry.processingEnd);
context.nextPaintTime = Math.max(context.nextPaintTime, nextPaintTime);

return;
}

Expand All @@ -167,10 +186,31 @@ export function registerInpInteractionListener(): void {
INTERACTIONS_SPAN_MAP.set(interactionId, {
span: activeRootSpan,
elementName,
startTime: entry.startTime,
processingStart: entry.processingStart,
processingEnd: entry.processingEnd,
nextPaintTime,
});
});
};

addPerformanceInstrumentationHandler('event', handleEntries);
addPerformanceInstrumentationHandler('first-input', handleEntries);
}

/**
* The INP subparts of an interaction, which add up to the time from the input to the next paint.
*/
export function getInpSubpartAttributes(timing: InteractionContext): SpanAttributes {
const processingStart = Math.max(timing.processingStart, timing.startTime);
// `duration` is rounded to 8ms, so the next paint can appear to come before processing starts.
const nextPaintTime = Math.max(timing.nextPaintTime, processingStart);
// A sync modal like `alert()` keeps processing past the next paint (GoogleChrome/web-vitals#492).
const processingEnd = Math.min(Math.max(timing.processingEnd, processingStart), nextPaintTime);

return {
[BROWSER_WEB_VITAL_INP_INPUT_DELAY]: processingStart - timing.startTime,
[BROWSER_WEB_VITAL_INP_PROCESSING_DURATION]: processingEnd - processingStart,
[BROWSER_WEB_VITAL_INP_PRESENTATION_DELAY]: nextPaintTime - processingEnd,
};
}
52 changes: 52 additions & 0 deletions packages/browser-utils/src/web-vitals/lcp.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
import type { SpanAttributes } from '@sentry/core';
import {
BROWSER_WEB_VITAL_LCP_ELEMENT_RENDER_DELAY,
BROWSER_WEB_VITAL_LCP_RESOURCE_LOAD_DELAY,
BROWSER_WEB_VITAL_LCP_RESOURCE_LOAD_DURATION,
} from '@sentry/conventions/attributes';
import { WINDOW } from '../types';
import type { LargestContentfulPaint } from './emitSpan';
import { getNavigationEntry } from './utils';

/**
* 60 seconds is the maximum for a plausible LCP value.
*/
Expand All @@ -6,3 +16,45 @@ export const MAX_PLAUSIBLE_LCP_DURATION = 60_000;
export function isValidLcpMetric(lcpValue: number | undefined): lcpValue is number {
return lcpValue != null && lcpValue > 0 && lcpValue <= MAX_PLAUSIBLE_LCP_DURATION;
}

/**
* The LCP subparts that follow TTFB, which together with TTFB add up to the LCP value.
*
* Only meaningful for the navigation that loaded the document, since soft navigations and bfcache
* restores have no TTFB to split from. Returns nothing when the LCP image has no resource timing
* entry (e.g. the buffer was full), because guessing would report a load delay that never happened.
*/
export function getLcpSubpartAttributes(lcpValue: number, entry: LargestContentfulPaint): SpanAttributes | undefined {
const navigationEntry = getNavigationEntry();
if (!navigationEntry) {
return undefined;
}

const activationStart = navigationEntry.activationStart || 0;
const ttfb = Math.max(navigationEntry.responseStart - activationStart, 0);
if (ttfb > lcpValue) {
return undefined;
}

let requestStart = ttfb;
let responseEnd = ttfb;

// Text and inline (data:, blob:) images aren't fetched, so all time after TTFB is render delay.
if (/^https?:/.test(entry.url)) {
const resourceEntry = WINDOW.performance.getEntriesByType('resource').find(resource => resource.name === entry.url);
if (!resourceEntry) {
return undefined;
}

// `requestStart` is 0 for cross-origin resources without `Timing-Allow-Origin`.
const resourceRequestStart = (resourceEntry.requestStart || resourceEntry.startTime) - activationStart;
requestStart = Math.min(Math.max(resourceRequestStart, ttfb), lcpValue);
responseEnd = Math.min(Math.max(resourceEntry.responseEnd - activationStart, requestStart), lcpValue);
}

return {
[BROWSER_WEB_VITAL_LCP_RESOURCE_LOAD_DELAY]: requestStart - ttfb,
[BROWSER_WEB_VITAL_LCP_RESOURCE_LOAD_DURATION]: responseEnd - requestStart,
[BROWSER_WEB_VITAL_LCP_ELEMENT_RENDER_DELAY]: lcpValue - responseEnd,
};
}
16 changes: 14 additions & 2 deletions packages/browser-utils/src/web-vitals/spans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,13 @@ import { DEBUG_BUILD } from '../debug-build';
import { htmlTreeAsString } from '../htmlTreeAsString';
import { getComponentName } from '../component-name';
import type { InteractionType } from './inp';
import { getCachedInteractionContext, INP_ENTRY_MAP, MAX_PLAUSIBLE_INP_DURATION, UNKNOWN_ELEMENT_NAME } from './inp';
import {
getCachedInteractionContext,
getInpSubpartAttributes,
INP_ENTRY_MAP,
MAX_PLAUSIBLE_INP_DURATION,
UNKNOWN_ELEMENT_NAME,
} from './inp';
import type { InstrumentationHandlerCallback, MetricNavigationType } from '../instrumentation/performanceObserver';
import {
addClsInstrumentationHandler,
Expand All @@ -45,7 +51,7 @@ import {
BROWSER_WEB_VITAL_LCP_URL,
} from '@sentry/conventions/attributes';
import { _emitWebVitalSpan } from './emitSpan';
import { isValidLcpMetric } from './lcp';
import { getLcpSubpartAttributes, isValidLcpMetric } from './lcp';
import { listenForWebVitalReportEvents } from './reportEvents';
import { getNavigationSpanForMetric } from './softNavs';
import { getBrowserPerformanceAPI, msToSec, supportsWebVital } from '../performance/utils';
Expand Down Expand Up @@ -231,6 +237,10 @@ export function _sendLcpSpan(
entry?.renderTime != null && (attributes[BROWSER_WEB_VITAL_LCP_RENDER_TIME] = entry.renderTime);
entry?.size != null && (attributes[BROWSER_WEB_VITAL_LCP_SIZE] = entry.size);

if (entry && navigationType !== 'soft-navigation' && navigationType !== 'back-forward-cache') {
Object.assign(attributes, getLcpSubpartAttributes(lcpValue, entry));
}

_emitWebVitalSpan({
name,
op: UI_WEBVITAL_LCP,
Expand Down Expand Up @@ -456,6 +466,8 @@ export function _sendInpSpan(
selector && selector !== UNKNOWN_ELEMENT_NAME && (attributes[BROWSER_WEB_VITAL_INP_TARGET] = selector);
entryInteractionType && (attributes[BROWSER_WEB_VITAL_INP_INTERACTION_TYPE] = entryInteractionType);

cachedContext && Object.assign(attributes, getInpSubpartAttributes(cachedContext));

if (componentName) {
attributes[UI_COMPONENT_NAME] = componentName;
}
Expand Down
Loading
Loading