diff --git a/dev-packages/browser-integration-tests/suites/replay/customEvents/test.ts b/dev-packages/browser-integration-tests/suites/replay/customEvents/test.ts index 41c90d94ffdf..053c31c3881e 100644 --- a/dev-packages/browser-integration-tests/suites/replay/customEvents/test.ts +++ b/dev-packages/browser-integration-tests/suites/replay/customEvents/test.ts @@ -2,8 +2,10 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../utils/fixtures'; import { + expectedCLSPerformanceSpan, expectedClickBreadcrumb, expectedFCPPerformanceSpan, + expectedFIDPerformanceSpan, expectedFPPerformanceSpan, expectedLCPPerformanceSpan, expectedMemoryPerformanceSpan, @@ -62,6 +64,8 @@ sentryTest( expect.arrayContaining([ expectedNavigationPerformanceSpan, expectedLCPPerformanceSpan, + expectedCLSPerformanceSpan, + expectedFIDPerformanceSpan, expectedFPPerformanceSpan, expectedFCPPerformanceSpan, expectedMemoryPerformanceSpan, // two memory spans - once per flush diff --git a/dev-packages/browser-integration-tests/suites/replay/multiple-pages/test.ts b/dev-packages/browser-integration-tests/suites/replay/multiple-pages/test.ts index 1148847f09c7..7bacf5a8ae17 100644 --- a/dev-packages/browser-integration-tests/suites/replay/multiple-pages/test.ts +++ b/dev-packages/browser-integration-tests/suites/replay/multiple-pages/test.ts @@ -2,8 +2,10 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../utils/fixtures'; import { + expectedCLSPerformanceSpan, expectedClickBreadcrumb, expectedFCPPerformanceSpan, + expectedFIDPerformanceSpan, expectedFPPerformanceSpan, expectedLCPPerformanceSpan, expectedMemoryPerformanceSpan, @@ -78,11 +80,13 @@ sentryTest( const collectedPerformanceSpans = [...recording0.performanceSpans, ...recording1.performanceSpans]; const collectedBreadcrumbs = [...recording0.breadcrumbs, ...recording1.breadcrumbs]; - expect(collectedPerformanceSpans.length).toEqual(6); + expect(collectedPerformanceSpans.length).toEqual(8); expect(collectedPerformanceSpans).toEqual( expect.arrayContaining([ expectedNavigationPerformanceSpan, expectedLCPPerformanceSpan, + expectedCLSPerformanceSpan, + expectedFIDPerformanceSpan, expectedFPPerformanceSpan, expectedFCPPerformanceSpan, expectedMemoryPerformanceSpan, // two memory spans - once per flush @@ -116,11 +120,13 @@ sentryTest( const collectedPerformanceSpansAfterReload = [...recording2.performanceSpans, ...recording3.performanceSpans]; const collectedBreadcrumbsAdterReload = [...recording2.breadcrumbs, ...recording3.breadcrumbs]; - expect(collectedPerformanceSpansAfterReload.length).toEqual(6); + expect(collectedPerformanceSpansAfterReload.length).toEqual(8); expect(collectedPerformanceSpansAfterReload).toEqual( expect.arrayContaining([ expectedReloadPerformanceSpan, expectedLCPPerformanceSpan, + expectedCLSPerformanceSpan, + expectedFIDPerformanceSpan, expectedFPPerformanceSpan, expectedFCPPerformanceSpan, expectedMemoryPerformanceSpan, @@ -188,6 +194,8 @@ sentryTest( expect.arrayContaining([ expectedNavigationPerformanceSpan, expectedLCPPerformanceSpan, + expectedCLSPerformanceSpan, + expectedFIDPerformanceSpan, expectedFPPerformanceSpan, expectedFCPPerformanceSpan, expectedMemoryPerformanceSpan, @@ -304,11 +312,13 @@ sentryTest( ]; const collectedBreadcrumbsAfterIndexNavigation = [...recording8.breadcrumbs, ...recording9.breadcrumbs]; - expect(collectedPerformanceSpansAfterIndexNavigation.length).toEqual(6); + expect(collectedPerformanceSpansAfterIndexNavigation.length).toEqual(8); expect(collectedPerformanceSpansAfterIndexNavigation).toEqual( expect.arrayContaining([ expectedNavigationPerformanceSpan, expectedLCPPerformanceSpan, + expectedCLSPerformanceSpan, + expectedFIDPerformanceSpan, expectedFPPerformanceSpan, expectedFCPPerformanceSpan, expectedMemoryPerformanceSpan, diff --git a/dev-packages/browser-integration-tests/utils/replayEventTemplates.ts b/dev-packages/browser-integration-tests/utils/replayEventTemplates.ts index 03354c6b3185..257c47fbfa9b 100644 --- a/dev-packages/browser-integration-tests/utils/replayEventTemplates.ts +++ b/dev-packages/browser-integration-tests/utils/replayEventTemplates.ts @@ -121,17 +121,56 @@ export const expectedMemoryPerformanceSpan = { }; export const expectedLCPPerformanceSpan = { - op: 'largest-contentful-paint', + op: 'web-vital', description: 'largest-contentful-paint', startTimestamp: expect.any(Number), endTimestamp: expect.any(Number), data: { value: expect.any(Number), nodeId: expect.any(Number), + rating: expect.any(String), size: expect.any(Number), }, }; +export const expectedCLSPerformanceSpan = { + op: 'web-vital', + description: 'cumulative-layout-shift', + startTimestamp: expect.any(Number), + endTimestamp: expect.any(Number), + data: { + value: expect.any(Number), + rating: expect.any(String), + size: expect.any(Number), + }, +}; + +export const expectedFIDPerformanceSpan = { + op: 'web-vital', + description: 'first-input-delay', + startTimestamp: expect.any(Number), + endTimestamp: expect.any(Number), + data: { + value: expect.any(Number), + rating: expect.any(String), + size: expect.any(Number), + nodeId: expect.any(Number), + }, +}; + +export const expectedINPPerformanceSpan = { + op: 'web-vital', + description: 'interaction-to-next-paint', + startTimestamp: expect.any(Number), + endTimestamp: expect.any(Number), + data: { + value: expect.any(Number), + rating: expect.any(String), + size: expect.any(Number), + nodeId: expect.any(Number), + }, +}; + export const expectedFCPPerformanceSpan = { op: 'paint', description: 'first-contentful-paint', diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/tests/fixtures/ReplayRecordingData.ts b/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/tests/fixtures/ReplayRecordingData.ts index 554fac59f88e..e7fd943c0f08 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/tests/fixtures/ReplayRecordingData.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-6-use-routes/tests/fixtures/ReplayRecordingData.ts @@ -212,7 +212,7 @@ export const ReplayRecordingData = [ data: { tag: 'performanceSpan', payload: { - op: 'largest-contentful-paint', + op: 'web-vital', description: 'largest-contentful-paint', startTimestamp: expect.any(Number), endTimestamp: expect.any(Number), diff --git a/dev-packages/e2e-tests/test-applications/react-send-to-sentry/tests/fixtures/ReplayRecordingData.ts b/dev-packages/e2e-tests/test-applications/react-send-to-sentry/tests/fixtures/ReplayRecordingData.ts index 554fac59f88e..156c2775f5ff 100644 --- a/dev-packages/e2e-tests/test-applications/react-send-to-sentry/tests/fixtures/ReplayRecordingData.ts +++ b/dev-packages/e2e-tests/test-applications/react-send-to-sentry/tests/fixtures/ReplayRecordingData.ts @@ -212,18 +212,56 @@ export const ReplayRecordingData = [ data: { tag: 'performanceSpan', payload: { - op: 'largest-contentful-paint', + op: 'web-vital', description: 'largest-contentful-paint', startTimestamp: expect.any(Number), endTimestamp: expect.any(Number), data: { value: expect.any(Number), size: expect.any(Number), + rating: expect.any(String), nodeId: 16, }, }, }, }, + { + type: 5, + timestamp: expect.any(Number), + data: { + tag: 'performanceSpan', + payload: { + op: 'web-vital', + description: 'cumulative-layout-shift', + startTimestamp: expect.any(Number), + endTimestamp: expect.any(Number), + data: { + value: expect.any(Number), + size: expect.any(Number), + rating: expect.any(String), + }, + }, + }, + }, + { + type: 5, + timestamp: expect.any(Number), + data: { + tag: 'performanceSpan', + payload: { + op: 'web-vital', + description: 'first-input-delay', + startTimestamp: expect.any(Number), + endTimestamp: expect.any(Number), + data: { + value: expect.any(Number), + size: expect.any(Number), + rating: expect.any(String), + nodeId: 10, + }, + }, + }, + }, { type: 5, timestamp: expect.any(Number), diff --git a/packages/browser-utils/src/index.ts b/packages/browser-utils/src/index.ts index 8e48e0988db9..f59ccbf8da8f 100644 --- a/packages/browser-utils/src/index.ts +++ b/packages/browser-utils/src/index.ts @@ -4,6 +4,7 @@ export { addFidInstrumentationHandler, addTtfbInstrumentationHandler, addLcpInstrumentationHandler, + addInpInstrumentationHandler, } from './metrics/instrument'; export { diff --git a/packages/replay-internal/src/coreHandlers/performanceObserver.ts b/packages/replay-internal/src/coreHandlers/performanceObserver.ts index 45b843760e52..638ef53b05fb 100644 --- a/packages/replay-internal/src/coreHandlers/performanceObserver.ts +++ b/packages/replay-internal/src/coreHandlers/performanceObserver.ts @@ -1,7 +1,18 @@ -import { addLcpInstrumentationHandler, addPerformanceInstrumentationHandler } from '@sentry-internal/browser-utils'; - +import { + addClsInstrumentationHandler, + addFidInstrumentationHandler, + addInpInstrumentationHandler, + addLcpInstrumentationHandler, + addPerformanceInstrumentationHandler, +} from '@sentry-internal/browser-utils'; import type { ReplayContainer } from '../types'; -import { getLargestContentfulPaint } from '../util/createPerformanceEntries'; +import { + getCumulativeLayoutShift, + getFirstInputDelay, + getInteractionToNextPaint, + getLargestContentfulPaint, + webVitalHandler, +} from '../util/createPerformanceEntries'; /** * Sets up a PerformanceObserver to listen to all performance entry types. @@ -26,9 +37,10 @@ export function setupPerformanceObserver(replay: ReplayContainer): () => void { }); clearCallbacks.push( - addLcpInstrumentationHandler(({ metric }) => { - replay.replayPerformanceEntries.push(getLargestContentfulPaint(metric)); - }), + addLcpInstrumentationHandler(webVitalHandler(getLargestContentfulPaint, replay)), + addClsInstrumentationHandler(webVitalHandler(getCumulativeLayoutShift, replay)), + addFidInstrumentationHandler(webVitalHandler(getFirstInputDelay, replay)), + addInpInstrumentationHandler(webVitalHandler(getInteractionToNextPaint, replay)), ); // A callback to cleanup all handlers diff --git a/packages/replay-internal/src/types/performance.ts b/packages/replay-internal/src/types/performance.ts index 2fe87d24a9c8..5241c12d847a 100644 --- a/packages/replay-internal/src/types/performance.ts +++ b/packages/replay-internal/src/types/performance.ts @@ -96,12 +96,17 @@ export type ResourceData = Pick function to normalize data for event @@ -25,6 +26,42 @@ const ENTRY_TYPES: Record< navigation: createNavigationEntry, }; +export interface Metric { + /** + * The current value of the metric. + */ + value: number; + + /** + * The rating as to whether the metric value is within the "good", + * "needs improvement", or "poor" thresholds of the metric. + */ + rating: 'good' | 'needs-improvement' | 'poor'; + + /** + * Any performance entries relevant to the metric value calculation. + * The array may also be empty if the metric value was not based on any + * entries (e.g. a CLS value of 0 given no layout shifts). + */ + entries: PerformanceEntry[] | PerformanceEventTiming[]; +} + +interface LayoutShiftAttribution { + node?: Node; + previousRect: DOMRectReadOnly; + currentRect: DOMRectReadOnly; +} + +/** + * Handler creater for web vitals + */ +export function webVitalHandler( + getter: (metric: Metric) => ReplayPerformanceEntry, + replay: ReplayContainer, +): (data: { metric: Metric }) => void { + return ({ metric }) => void replay.replayPerformanceEntries.push(getter(metric)); +} + /** * Create replay performance entries from the browser performance entries. */ @@ -141,29 +178,65 @@ function createResourceEntry( } /** - * Add a LCP event to the replay based on an LCP metric. + * Add a LCP event to the replay based on a LCP metric. */ -export function getLargestContentfulPaint(metric: { - value: number; - entries: PerformanceEntry[]; -}): ReplayPerformanceEntry { - const entries = metric.entries; - const lastEntry = entries[entries.length - 1] as (PerformanceEntry & { element?: Element }) | undefined; - const element = lastEntry ? lastEntry.element : undefined; +export function getLargestContentfulPaint(metric: Metric): ReplayPerformanceEntry { + const lastEntry = metric.entries[metric.entries.length - 1] as (PerformanceEntry & { element?: Node }) | undefined; + const node = lastEntry ? lastEntry.element : undefined; + return getWebVital(metric, 'largest-contentful-paint', node); +} +/** + * Add a CLS event to the replay based on a CLS metric. + */ +export function getCumulativeLayoutShift(metric: Metric): ReplayPerformanceEntry { + // get first node that shifts + const firstEntry = metric.entries[0] as (PerformanceEntry & { sources?: LayoutShiftAttribution[] }) | undefined; + const node = firstEntry ? (firstEntry.sources ? firstEntry.sources[0].node : undefined) : undefined; + return getWebVital(metric, 'cumulative-layout-shift', node); +} + +/** + * Add a FID event to the replay based on a FID metric. + */ +export function getFirstInputDelay(metric: Metric): ReplayPerformanceEntry { + const lastEntry = metric.entries[metric.entries.length - 1] as (PerformanceEntry & { target?: Node }) | undefined; + const node = lastEntry ? lastEntry.target : undefined; + return getWebVital(metric, 'first-input-delay', node); +} + +/** + * Add an INP event to the replay based on an INP metric. + */ +export function getInteractionToNextPaint(metric: Metric): ReplayPerformanceEntry { + const lastEntry = metric.entries[metric.entries.length - 1] as (PerformanceEntry & { target?: Node }) | undefined; + const node = lastEntry ? lastEntry.target : undefined; + return getWebVital(metric, 'interaction-to-next-paint', node); +} + +/** + * Add an web vital event to the replay based on the web vital metric. + */ +export function getWebVital( + metric: Metric, + name: string, + node: Node | undefined, +): ReplayPerformanceEntry { const value = metric.value; + const rating = metric.rating; const end = getAbsoluteTime(value); - const data: ReplayPerformanceEntry = { - type: 'largest-contentful-paint', - name: 'largest-contentful-paint', + const data: ReplayPerformanceEntry = { + type: 'web-vital', + name, start: end, end, data: { value, size: value, - nodeId: element ? record.mirror.getId(element) : undefined, + rating, + nodeId: node ? record.mirror.getId(node) : undefined, }, }; diff --git a/packages/replay-internal/test/unit/util/createPerformanceEntry.test.ts b/packages/replay-internal/test/unit/util/createPerformanceEntry.test.ts index 176de1c2d32e..f13d72feecf4 100644 --- a/packages/replay-internal/test/unit/util/createPerformanceEntry.test.ts +++ b/packages/replay-internal/test/unit/util/createPerformanceEntry.test.ts @@ -11,7 +11,13 @@ vi.mock('@sentry/utils', async () => ({ })); import { WINDOW } from '../../../src/constants'; -import { createPerformanceEntries, getLargestContentfulPaint } from '../../../src/util/createPerformanceEntries'; +import { + createPerformanceEntries, + getCumulativeLayoutShift, + getFirstInputDelay, + getInteractionToNextPaint, + getLargestContentfulPaint, +} from '../../../src/util/createPerformanceEntries'; import { PerformanceEntryNavigation } from '../../fixtures/performanceEntry/navigation'; describe('Unit | util | createPerformanceEntries', () => { @@ -66,17 +72,78 @@ describe('Unit | util | createPerformanceEntries', () => { it('works with an LCP metric', async () => { const metric = { value: 5108.299, + rating: 'good' as const, entries: [], }; const event = getLargestContentfulPaint(metric); expect(event).toEqual({ - type: 'largest-contentful-paint', + type: 'web-vital', name: 'largest-contentful-paint', start: 1672531205.108299, end: 1672531205.108299, - data: { value: 5108.299, size: 5108.299, nodeId: undefined }, + data: { value: 5108.299, rating: 'good', size: 5108.299, nodeId: undefined }, + }); + }); + }); + + describe('getCumulativeLayoutShift', () => { + it('works with an CLS metric', async () => { + const metric = { + value: 5108.299, + rating: 'good' as const, + entries: [], + }; + + const event = getCumulativeLayoutShift(metric); + + expect(event).toEqual({ + type: 'web-vital', + name: 'cumulative-layout-shift', + start: 1672531205.108299, + end: 1672531205.108299, + data: { value: 5108.299, size: 5108.299, rating: 'good', nodeId: undefined }, + }); + }); + }); + + describe('getFirstInputDelay', () => { + it('works with an FID metric', async () => { + const metric = { + value: 5108.299, + rating: 'good' as const, + entries: [], + }; + + const event = getFirstInputDelay(metric); + + expect(event).toEqual({ + type: 'web-vital', + name: 'first-input-delay', + start: 1672531205.108299, + end: 1672531205.108299, + data: { value: 5108.299, size: 5108.299, rating: 'good', nodeId: undefined }, + }); + }); + }); + + describe('getInteractionToNextPaint', () => { + it('works with an INP metric', async () => { + const metric = { + value: 5108.299, + rating: 'good' as const, + entries: [], + }; + + const event = getInteractionToNextPaint(metric); + + expect(event).toEqual({ + type: 'web-vital', + name: 'interaction-to-next-paint', + start: 1672531205.108299, + end: 1672531205.108299, + data: { value: 5108.299, size: 5108.299, rating: 'good', nodeId: undefined }, }); }); });