From 918c011c693661b58b918bb1c302eb457397bef3 Mon Sep 17 00:00:00 2001 From: Catherine Lee <55311782+c298lee@users.noreply.github.com> Date: Wed, 28 Aug 2024 19:41:19 -0400 Subject: [PATCH] feat(replay): Add layout shift to CLS replay data (#13386) We want to show the score for each layout shift as well as the all the nodes that contributed to the score, so we're adding a new `attributions` object to our web vitals data Relates to https://github.com/getsentry/sentry/issues/69881 --------- Co-authored-by: Billy Vong --- .../utils/replayEventTemplates.ts | 1 + .../tests/fixtures/ReplayRecordingData.ts | 2 +- .../tests/fixtures/ReplayRecordingData.ts | 1 + .../replay-internal/src/types/performance.ts | 4 ++ .../src/util/createPerformanceEntries.ts | 47 +++++++++++++++---- .../unit/util/createPerformanceEntry.test.ts | 8 ++-- 6 files changed, 48 insertions(+), 15 deletions(-) diff --git a/dev-packages/browser-integration-tests/utils/replayEventTemplates.ts b/dev-packages/browser-integration-tests/utils/replayEventTemplates.ts index f4defc27182c..e711ea3bb0bb 100644 --- a/dev-packages/browser-integration-tests/utils/replayEventTemplates.ts +++ b/dev-packages/browser-integration-tests/utils/replayEventTemplates.ts @@ -141,6 +141,7 @@ export const expectedCLSPerformanceSpan = { data: { value: expect.any(Number), nodeIds: expect.any(Array), + attributions: expect.any(Array), rating: expect.any(String), size: expect.any(Number), }, 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 e7fd943c0f08..f65efa7cca2d 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 @@ -219,7 +219,7 @@ export const ReplayRecordingData = [ data: { value: expect.any(Number), size: expect.any(Number), - nodeId: 16, + nodeIds: [16], }, }, }, 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 1b054c099b3d..76abdb1fa6b5 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 @@ -240,6 +240,7 @@ export const ReplayRecordingData = [ size: expect.any(Number), rating: expect.any(String), nodeIds: expect.any(Array), + attributions: expect.any(Array), }, }, }, diff --git a/packages/replay-internal/src/types/performance.ts b/packages/replay-internal/src/types/performance.ts index 6b264a44ee9c..7a60e51684f3 100644 --- a/packages/replay-internal/src/types/performance.ts +++ b/packages/replay-internal/src/types/performance.ts @@ -111,6 +111,10 @@ export interface WebVitalData { * The recording id of the web vital nodes. -1 if not found */ nodeIds?: number[]; + /** + * The layout shifts of a CLS metric + */ + attributions?: { value: number; sources?: number[] }[]; } /** diff --git a/packages/replay-internal/src/util/createPerformanceEntries.ts b/packages/replay-internal/src/util/createPerformanceEntries.ts index d55c2269d0f4..830f878dc8ea 100644 --- a/packages/replay-internal/src/util/createPerformanceEntries.ts +++ b/packages/replay-internal/src/util/createPerformanceEntries.ts @@ -43,7 +43,13 @@ export interface Metric { * 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[]; + entries: PerformanceEntry[] | LayoutShift[]; +} + +interface LayoutShift extends PerformanceEntry { + value: number; + sources: LayoutShiftAttribution[]; + hadRecentInput: boolean; } interface LayoutShiftAttribution { @@ -52,6 +58,11 @@ interface LayoutShiftAttribution { currentRect: DOMRectReadOnly; } +interface Attribution { + value: number; + nodeIds?: number[]; +} + /** * Handler creater for web vitals */ @@ -187,22 +198,32 @@ export function getLargestContentfulPaint(metric: Metric): ReplayPerformanceEntr return getWebVital(metric, 'largest-contentful-paint', node); } +function isLayoutShift(entry: PerformanceEntry | LayoutShift): entry is LayoutShift { + return (entry as LayoutShift).sources !== undefined; +} + /** * Add a CLS event to the replay based on a CLS metric. */ export function getCumulativeLayoutShift(metric: Metric): ReplayPerformanceEntry { - const lastEntry = metric.entries[metric.entries.length - 1] as - | (PerformanceEntry & { sources?: LayoutShiftAttribution[] }) - | undefined; + const layoutShifts: Attribution[] = []; const nodes: Node[] = []; - if (lastEntry && lastEntry.sources) { - for (const source of lastEntry.sources) { - if (source.node) { - nodes.push(source.node); + for (const entry of metric.entries) { + if (isLayoutShift(entry)) { + const nodeIds = []; + for (const source of entry.sources) { + if (source.node) { + nodes.push(source.node); + const nodeId = record.mirror.getId(source.node); + if (nodeId) { + nodeIds.push(nodeId); + } + } } + layoutShifts.push({ value: entry.value, nodeIds }); } } - return getWebVital(metric, 'cumulative-layout-shift', nodes); + return getWebVital(metric, 'cumulative-layout-shift', nodes, layoutShifts); } /** @@ -226,7 +247,12 @@ export function getInteractionToNextPaint(metric: Metric): ReplayPerformanceEntr /** * Add an web vital event to the replay based on the web vital metric. */ -function getWebVital(metric: Metric, name: string, nodes: Node[] | undefined): ReplayPerformanceEntry { +function getWebVital( + metric: Metric, + name: string, + nodes: Node[] | undefined, + attributions?: Attribution[], +): ReplayPerformanceEntry { const value = metric.value; const rating = metric.rating; @@ -242,6 +268,7 @@ function getWebVital(metric: Metric, name: string, nodes: Node[] | undefined): R size: value, rating, nodeIds: nodes ? nodes.map(node => record.mirror.getId(node)) : undefined, + attributions, }, }; diff --git a/packages/replay-internal/test/unit/util/createPerformanceEntry.test.ts b/packages/replay-internal/test/unit/util/createPerformanceEntry.test.ts index d85698d1be1d..b82a8941269d 100644 --- a/packages/replay-internal/test/unit/util/createPerformanceEntry.test.ts +++ b/packages/replay-internal/test/unit/util/createPerformanceEntry.test.ts @@ -83,7 +83,7 @@ describe('Unit | util | createPerformanceEntries', () => { name: 'largest-contentful-paint', start: 1672531205.108299, end: 1672531205.108299, - data: { value: 5108.299, rating: 'good', size: 5108.299, nodeIds: undefined }, + data: { value: 5108.299, rating: 'good', size: 5108.299, nodeIds: undefined, attributions: undefined }, }); }); }); @@ -103,7 +103,7 @@ describe('Unit | util | createPerformanceEntries', () => { name: 'cumulative-layout-shift', start: 1672531205.108299, end: 1672531205.108299, - data: { value: 5108.299, size: 5108.299, rating: 'good', nodeIds: [] }, + data: { value: 5108.299, size: 5108.299, rating: 'good', nodeIds: [], attributions: [] }, }); }); }); @@ -123,7 +123,7 @@ describe('Unit | util | createPerformanceEntries', () => { name: 'first-input-delay', start: 1672531205.108299, end: 1672531205.108299, - data: { value: 5108.299, size: 5108.299, rating: 'good', nodeIds: undefined }, + data: { value: 5108.299, size: 5108.299, rating: 'good', nodeIds: undefined, attributions: undefined }, }); }); }); @@ -143,7 +143,7 @@ describe('Unit | util | createPerformanceEntries', () => { name: 'interaction-to-next-paint', start: 1672531205.108299, end: 1672531205.108299, - data: { value: 5108.299, size: 5108.299, rating: 'good', nodeIds: undefined }, + data: { value: 5108.299, size: 5108.299, rating: 'good', nodeIds: undefined, attributions: undefined }, }); }); });