diff --git a/.size-limit.js b/.size-limit.js index 2e7899cb934a..5a2ff44cc32d 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -48,7 +48,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'replayCanvasIntegration'), gzip: true, - limit: '76 KB', + limit: '77 KB', }, { name: '@sentry/browser (incl. Tracing, Replay, Feedback)', 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 7bacf5a8ae17..2e4b0bdf61bd 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 @@ -13,6 +13,7 @@ import { expectedNavigationPerformanceSpan, expectedNavigationPushPerformanceSpan, expectedReloadPerformanceSpan, + expectedTTFBPerformanceSpan, getExpectedReplayEvent, } from '../../../utils/replayEventTemplates'; import { @@ -80,13 +81,14 @@ sentryTest( const collectedPerformanceSpans = [...recording0.performanceSpans, ...recording1.performanceSpans]; const collectedBreadcrumbs = [...recording0.breadcrumbs, ...recording1.breadcrumbs]; - expect(collectedPerformanceSpans.length).toEqual(8); + expect(collectedPerformanceSpans.length).toEqual(9); expect(collectedPerformanceSpans).toEqual( expect.arrayContaining([ expectedNavigationPerformanceSpan, expectedLCPPerformanceSpan, expectedCLSPerformanceSpan, expectedFIDPerformanceSpan, + expectedTTFBPerformanceSpan, expectedFPPerformanceSpan, expectedFCPPerformanceSpan, expectedMemoryPerformanceSpan, // two memory spans - once per flush @@ -120,12 +122,13 @@ sentryTest( const collectedPerformanceSpansAfterReload = [...recording2.performanceSpans, ...recording3.performanceSpans]; const collectedBreadcrumbsAdterReload = [...recording2.breadcrumbs, ...recording3.breadcrumbs]; - expect(collectedPerformanceSpansAfterReload.length).toEqual(8); + expect(collectedPerformanceSpansAfterReload.length).toEqual(9); expect(collectedPerformanceSpansAfterReload).toEqual( expect.arrayContaining([ expectedReloadPerformanceSpan, expectedLCPPerformanceSpan, expectedCLSPerformanceSpan, + expectedTTFBPerformanceSpan, expectedFIDPerformanceSpan, expectedFPPerformanceSpan, expectedFCPPerformanceSpan, @@ -195,6 +198,7 @@ sentryTest( expectedNavigationPerformanceSpan, expectedLCPPerformanceSpan, expectedCLSPerformanceSpan, + expectedTTFBPerformanceSpan, expectedFIDPerformanceSpan, expectedFPPerformanceSpan, expectedFCPPerformanceSpan, @@ -312,12 +316,13 @@ sentryTest( ]; const collectedBreadcrumbsAfterIndexNavigation = [...recording8.breadcrumbs, ...recording9.breadcrumbs]; - expect(collectedPerformanceSpansAfterIndexNavigation.length).toEqual(8); + expect(collectedPerformanceSpansAfterIndexNavigation.length).toEqual(9); expect(collectedPerformanceSpansAfterIndexNavigation).toEqual( expect.arrayContaining([ expectedNavigationPerformanceSpan, expectedLCPPerformanceSpan, expectedCLSPerformanceSpan, + expectedTTFBPerformanceSpan, expectedFIDPerformanceSpan, expectedFPPerformanceSpan, expectedFCPPerformanceSpan, diff --git a/dev-packages/browser-integration-tests/utils/replayEventTemplates.ts b/dev-packages/browser-integration-tests/utils/replayEventTemplates.ts index 257c47fbfa9b..c15eb57be429 100644 --- a/dev-packages/browser-integration-tests/utils/replayEventTemplates.ts +++ b/dev-packages/browser-integration-tests/utils/replayEventTemplates.ts @@ -127,7 +127,7 @@ export const expectedLCPPerformanceSpan = { endTimestamp: expect.any(Number), data: { value: expect.any(Number), - nodeId: expect.any(Number), + nodeId: expect.any(Array), rating: expect.any(String), size: expect.any(Number), }, @@ -142,6 +142,7 @@ export const expectedCLSPerformanceSpan = { value: expect.any(Number), rating: expect.any(String), size: expect.any(Number), + nodeId: expect.any(Array), }, }; @@ -154,7 +155,7 @@ export const expectedFIDPerformanceSpan = { value: expect.any(Number), rating: expect.any(String), size: expect.any(Number), - nodeId: expect.any(Number), + nodeId: expect.any(Array), }, }; @@ -167,7 +168,20 @@ export const expectedINPPerformanceSpan = { value: expect.any(Number), rating: expect.any(String), size: expect.any(Number), - nodeId: expect.any(Number), + nodeId: expect.any(Array), + }, +}; + +export const expectedTTFBPerformanceSpan = { + op: 'web-vital', + description: 'time-to-first-byte', + startTimestamp: expect.any(Number), + endTimestamp: expect.any(Number), + data: { + value: expect.any(Number), + rating: expect.any(String), + size: expect.any(Number), + nodeId: expect.any(Array), }, }; 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 156c2775f5ff..9f3a4427310c 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 @@ -206,6 +206,25 @@ export const ReplayRecordingData = [ }, }, }, + { + type: 5, + timestamp: expect.any(Number), + data: { + tag: 'performanceSpan', + payload: { + op: 'web-vital', + description: 'time-to-first-byte', + startTimestamp: expect.any(Number), + endTimestamp: expect.any(Number), + data: { + value: expect.any(Number), + size: expect.any(Number), + rating: expect.any(String), + nodeId: [], + }, + }, + }, + }, { type: 5, timestamp: expect.any(Number), @@ -220,7 +239,7 @@ export const ReplayRecordingData = [ value: expect.any(Number), size: expect.any(Number), rating: expect.any(String), - nodeId: 16, + nodeId: [16], }, }, }, @@ -239,6 +258,7 @@ export const ReplayRecordingData = [ value: expect.any(Number), size: expect.any(Number), rating: expect.any(String), + nodeId: [], }, }, }, @@ -257,7 +277,7 @@ export const ReplayRecordingData = [ value: expect.any(Number), size: expect.any(Number), rating: expect.any(String), - nodeId: 10, + nodeId: [10], }, }, }, diff --git a/packages/replay-internal/src/coreHandlers/performanceObserver.ts b/packages/replay-internal/src/coreHandlers/performanceObserver.ts index 638ef53b05fb..ea6ac6eed869 100644 --- a/packages/replay-internal/src/coreHandlers/performanceObserver.ts +++ b/packages/replay-internal/src/coreHandlers/performanceObserver.ts @@ -4,6 +4,7 @@ import { addInpInstrumentationHandler, addLcpInstrumentationHandler, addPerformanceInstrumentationHandler, + addTtfbInstrumentationHandler, } from '@sentry-internal/browser-utils'; import type { ReplayContainer } from '../types'; import { @@ -11,6 +12,7 @@ import { getFirstInputDelay, getInteractionToNextPaint, getLargestContentfulPaint, + getTimeToFirstByte, webVitalHandler, } from '../util/createPerformanceEntries'; @@ -41,6 +43,7 @@ export function setupPerformanceObserver(replay: ReplayContainer): () => void { addClsInstrumentationHandler(webVitalHandler(getCumulativeLayoutShift, replay)), addFidInstrumentationHandler(webVitalHandler(getFirstInputDelay, replay)), addInpInstrumentationHandler(webVitalHandler(getInteractionToNextPaint, replay)), + addTtfbInstrumentationHandler(webVitalHandler(getTimeToFirstByte, 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 5241c12d847a..a3a73cc0289e 100644 --- a/packages/replay-internal/src/types/performance.ts +++ b/packages/replay-internal/src/types/performance.ts @@ -110,7 +110,7 @@ export interface WebVitalData { /** * The recording id of the LCP node. -1 if not found */ - nodeId?: number; + nodeId?: number | number[]; } /** diff --git a/packages/replay-internal/src/types/replayFrame.ts b/packages/replay-internal/src/types/replayFrame.ts index 0fa43ff41eb2..07e6392d1fd0 100644 --- a/packages/replay-internal/src/types/replayFrame.ts +++ b/packages/replay-internal/src/types/replayFrame.ts @@ -173,7 +173,12 @@ interface ReplayHistoryFrame extends ReplayBaseSpanFrame { interface ReplayWebVitalFrame extends ReplayBaseSpanFrame { data: WebVitalData; - op: 'largest-contentful-paint' | 'cumulative-layout-shift' | 'first-input-delay' | 'interaction-to-next-paint'; + op: + | 'largest-contentful-paint' + | 'cumulative-layout-shift' + | 'first-input-delay' + | 'interaction-to-next-paint' + | 'time-to-first-byte'; } interface ReplayMemoryFrame extends ReplayBaseSpanFrame { diff --git a/packages/replay-internal/src/util/createPerformanceEntries.ts b/packages/replay-internal/src/util/createPerformanceEntries.ts index 28ccf60280e8..dfcc3ec08c6f 100644 --- a/packages/replay-internal/src/util/createPerformanceEntries.ts +++ b/packages/replay-internal/src/util/createPerformanceEntries.ts @@ -191,14 +191,18 @@ export function getLargestContentfulPaint(metric: Metric): ReplayPerformanceEntr * Add a CLS event to the replay based on a CLS metric. */ export function getCumulativeLayoutShift(metric: Metric): ReplayPerformanceEntry<WebVitalData> { - // get first node that shifts - const firstEntry = metric.entries[0] as (PerformanceEntry & { sources?: LayoutShiftAttribution[] }) | undefined; - const node = firstEntry - ? firstEntry.sources && firstEntry.sources[0] - ? firstEntry.sources[0].node - : undefined - : undefined; - return getWebVital(metric, 'cumulative-layout-shift', node); + const lastEntry = metric.entries[metric.entries.length - 1] as + | (PerformanceEntry & { sources?: LayoutShiftAttribution[] }) + | undefined; + const nodes: Node[] = []; + if (lastEntry && lastEntry.sources) { + for (const source of lastEntry.sources) { + if (source.node) { + nodes.push(source.node); + } + } + } + return getWebVital(metric, 'cumulative-layout-shift', nodes); } /** @@ -219,19 +223,39 @@ export function getInteractionToNextPaint(metric: Metric): ReplayPerformanceEntr return getWebVital(metric, 'interaction-to-next-paint', node); } +/** + * Add a TTFB event to the replay based on an INP metric. + */ +export function getTimeToFirstByte(metric: Metric): ReplayPerformanceEntry<WebVitalData> { + const lastEntry = metric.entries[metric.entries.length - 1] as (PerformanceEntry & { target?: Node }) | undefined; + const node = lastEntry ? lastEntry.target : undefined; + return getWebVital(metric, 'time-to-first-byte', 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, + node: Node | Node[] | undefined, ): ReplayPerformanceEntry<WebVitalData> { const value = metric.value; const rating = metric.rating; const end = getAbsoluteTime(value); + const nodeIds: number[] = []; + if (Array.isArray(node)) { + for (const n of node) { + nodeIds.push(record.mirror.getId(n)); + } + } else { + if (node) { + nodeIds.push(record.mirror.getId(node)); + } + } + const data: ReplayPerformanceEntry<WebVitalData> = { type: 'web-vital', name, @@ -241,7 +265,7 @@ export function getWebVital( value, size: value, rating, - nodeId: node ? record.mirror.getId(node) : undefined, + nodeId: nodeIds ? nodeIds : undefined, }, }; diff --git a/packages/replay-internal/test/unit/util/createPerformanceEntry.test.ts b/packages/replay-internal/test/unit/util/createPerformanceEntry.test.ts index f13d72feecf4..f63bae23d1b2 100644 --- a/packages/replay-internal/test/unit/util/createPerformanceEntry.test.ts +++ b/packages/replay-internal/test/unit/util/createPerformanceEntry.test.ts @@ -17,6 +17,7 @@ import { getFirstInputDelay, getInteractionToNextPaint, getLargestContentfulPaint, + getTimeToFirstByte, } from '../../../src/util/createPerformanceEntries'; import { PerformanceEntryNavigation } from '../../fixtures/performanceEntry/navigation'; @@ -83,7 +84,7 @@ describe('Unit | util | createPerformanceEntries', () => { name: 'largest-contentful-paint', start: 1672531205.108299, end: 1672531205.108299, - data: { value: 5108.299, rating: 'good', size: 5108.299, nodeId: undefined }, + data: { value: 5108.299, rating: 'good', size: 5108.299, nodeId: [] }, }); }); }); @@ -103,7 +104,7 @@ describe('Unit | util | createPerformanceEntries', () => { name: 'cumulative-layout-shift', start: 1672531205.108299, end: 1672531205.108299, - data: { value: 5108.299, size: 5108.299, rating: 'good', nodeId: undefined }, + data: { value: 5108.299, size: 5108.299, rating: 'good', nodeId: [] }, }); }); }); @@ -123,7 +124,7 @@ describe('Unit | util | createPerformanceEntries', () => { name: 'first-input-delay', start: 1672531205.108299, end: 1672531205.108299, - data: { value: 5108.299, size: 5108.299, rating: 'good', nodeId: undefined }, + data: { value: 5108.299, size: 5108.299, rating: 'good', nodeId: [] }, }); }); }); @@ -143,7 +144,27 @@ describe('Unit | util | createPerformanceEntries', () => { name: 'interaction-to-next-paint', start: 1672531205.108299, end: 1672531205.108299, - data: { value: 5108.299, size: 5108.299, rating: 'good', nodeId: undefined }, + data: { value: 5108.299, size: 5108.299, rating: 'good', nodeId: [] }, + }); + }); + }); + + describe('getTimeToFirstByte', () => { + it('works with an TTFB metric', async () => { + const metric = { + value: 5108.299, + rating: 'good' as const, + entries: [], + }; + + const event = getTimeToFirstByte(metric); + + expect(event).toEqual({ + type: 'web-vital', + name: 'time-to-first-byte', + start: 1672531205.108299, + end: 1672531205.108299, + data: { value: 5108.299, size: 5108.299, rating: 'good', nodeId: [] }, }); }); });