diff --git a/dev-packages/browser-integration-tests/utils/replayEventTemplates.ts b/dev-packages/browser-integration-tests/utils/replayEventTemplates.ts index 257c47fbfa9b..070590da9e8f 100644 --- a/dev-packages/browser-integration-tests/utils/replayEventTemplates.ts +++ b/dev-packages/browser-integration-tests/utils/replayEventTemplates.ts @@ -171,6 +171,19 @@ export const expectedINPPerformanceSpan = { }, }; +export const expectedTTFB = { + 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(Number), + }, +}; + export const expectedFCPPerformanceSpan = { op: 'paint', description: 'first-contentful-paint', 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..d2070d193f16 100644 --- a/packages/replay-internal/src/types/replayFrame.ts +++ b/packages/replay-internal/src/types/replayFrame.ts @@ -173,7 +173,7 @@ 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..695b4fc4e618 100644 --- a/packages/replay-internal/src/util/createPerformanceEntries.ts +++ b/packages/replay-internal/src/util/createPerformanceEntries.ts @@ -191,14 +191,17 @@ export function getLargestContentfulPaint(metric: Metric): ReplayPerformanceEntr * 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] - ? 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 +222,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 { + 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 { 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 = { type: 'web-vital', name, @@ -241,7 +264,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..e2d44ed687f6 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'; @@ -147,4 +148,24 @@ describe('Unit | util | createPerformanceEntries', () => { }); }); }); + + 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: undefined }, + }); + }); + }); });