Skip to content

Commit 8ce1277

Browse files
committed
feat(replay): Share performance instrumentation with tracing
1 parent df08e8f commit 8ce1277

File tree

24 files changed

+339
-318
lines changed

24 files changed

+339
-318
lines changed

packages/browser-integration-tests/suites/replay/eventBufferError/test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { envelopeRequestParser } from '../../../utils/helpers';
55
import {
66
getDecompressedRecordingEvents,
77
getReplaySnapshot,
8+
isCustomSnapshot,
89
isReplayEvent,
910
REPLAY_DEFAULT_FLUSH_MAX_DELAY,
1011
shouldSkipReplayTest,
@@ -41,8 +42,8 @@ sentryTest(
4142
// We only want to count replays here
4243
if (event && isReplayEvent(event)) {
4344
const events = getDecompressedRecordingEvents(route.request());
44-
// this makes sure we ignore e.g. mouse move events which can otherwise lead to flakes
45-
if (events.length > 0) {
45+
// Make sure to not count mouse moves or performance spans
46+
if (events.filter(event => !isCustomSnapshot(event) || event.data.tag !== 'performanceSpan').length > 0) {
4647
called++;
4748
}
4849
}

packages/browser-integration-tests/suites/replay/largeMutations/defaultOptions/template.html

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
44
<meta charset="utf-8" />
55
</head>
66
<body>
7-
<button id="button-add">Add items</button>
8-
<button id="button-modify">Modify items</button>
9-
<button id="button-remove">Remove items</button>
7+
<button id="noop" type="button">Noop</button>
8+
<button id="button-add" type="button">Add items</button>
9+
<button id="button-modify" type="button">Modify items</button>
10+
<button id="button-remove" type="button">Remove items</button>
1011
<ul class="list"></ul>
1112

1213
<script>

packages/browser-integration-tests/suites/replay/largeMutations/defaultOptions/test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ sentryTest(
2121
const url = await getLocalTestPath({ testDir: __dirname });
2222

2323
const [res0] = await Promise.all([waitForReplayRequest(page, 0), page.goto(url)]);
24+
// Ensure LCP is captured
25+
await Promise.all([waitForReplayRequest(page), page.click('#noop')]);
2426
await forceFlushReplay();
2527

2628
const [res1] = await Promise.all([waitForReplayRequest(page), page.click('#button-add')]);

packages/browser-integration-tests/suites/replay/largeMutations/mutationLimit/template.html

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
44
<meta charset="utf-8" />
55
</head>
66
<body>
7-
<button id="button-add">Add items</button>
8-
<button id="button-modify">Modify items</button>
9-
<button id="button-remove">Remove items</button>
7+
<button id="noop" type="button">Noop</button>
8+
<button id="button-add" type="button">Add items</button>
9+
<button id="button-modify" type="button">Modify items</button>
10+
<button id="button-remove" type="button">Remove items</button>
1011
<ul class="list"></ul>
1112

1213
<script>

packages/browser-integration-tests/suites/replay/largeMutations/mutationLimit/test.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,16 +23,14 @@ sentryTest(
2323
});
2424
});
2525

26-
const reqPromise0 = waitForReplayRequest(page, 0);
27-
2826
const url = await getLocalTestPath({ testDir: __dirname });
2927

30-
const [res0] = await Promise.all([reqPromise0, page.goto(url)]);
28+
const [res0] = await Promise.all([waitForReplayRequest(page, 0), page.goto(url)]);
29+
// Ensure LCP is captured
30+
await Promise.all([waitForReplayRequest(page), page.click('#noop')]);
3131
await forceFlushReplay();
3232

33-
const reqPromise1 = waitForReplayRequest(page);
34-
35-
const [res1] = await Promise.all([reqPromise1, page.click('#button-add')]);
33+
const [res1] = await Promise.all([waitForReplayRequest(page), page.click('#button-add')]);
3634
await forceFlushReplay();
3735

3836
// replay should be stopped due to mutation limit

packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp/template.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,6 @@
66
<body>
77
<div id="content"></div>
88
<img src="https://example.com/path/to/image.png" />
9+
<button type="button">Test button</button>
910
</body>
1011
</html>

packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp/test.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,11 @@ sentryTest('should capture a LCP vital with element details.', async ({ browserN
1515
);
1616

1717
const url = await getLocalTestPath({ testDir: __dirname });
18-
await page.goto(url);
19-
20-
const eventData = await getFirstSentryEnvelopeRequest<Event>(page);
18+
const [eventData] = await Promise.all([
19+
getFirstSentryEnvelopeRequest<Event>(page),
20+
page.goto(url),
21+
page.click('button'),
22+
]);
2123

2224
expect(eventData.measurements).toBeDefined();
2325
expect(eventData.measurements?.lcp?.value).toBeDefined();

packages/browser-integration-tests/utils/replayHelpers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ function isFullSnapshot(event: RecordingEvent): event is FullRecordingSnapshot {
153153
return event.type === EventType.FullSnapshot;
154154
}
155155

156-
function isCustomSnapshot(event: RecordingEvent): event is RecordingEvent & { data: CustomRecordingEvent } {
156+
export function isCustomSnapshot(event: RecordingEvent): event is RecordingEvent & { data: CustomRecordingEvent } {
157157
return event.type === EventType.Custom;
158158
}
159159

packages/replay/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
},
6060
"dependencies": {
6161
"@sentry/core": "7.74.1",
62+
"@sentry-internal/tracing": "7.74.1",
6263
"@sentry/types": "7.74.1",
6364
"@sentry/utils": "7.74.1"
6465
},
Lines changed: 28 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,37 @@
1-
import type { AllPerformanceEntry, ReplayContainer } from '../types';
2-
import { dedupePerformanceEntries } from '../util/dedupePerformanceEntries';
1+
import { addPerformanceInstrumentationHandler } from '@sentry-internal/tracing';
2+
3+
import type { ReplayContainer } from '../types';
34

45
/**
56
* Sets up a PerformanceObserver to listen to all performance entry types.
7+
* Returns a callback to stop observing.
68
*/
7-
export function setupPerformanceObserver(replay: ReplayContainer): PerformanceObserver {
8-
const performanceObserverHandler = (list: PerformanceObserverEntryList): void => {
9-
// For whatever reason the observer was returning duplicate navigation
10-
// entries (the other entry types were not duplicated).
11-
const newPerformanceEntries = dedupePerformanceEntries(
12-
replay.performanceEvents,
13-
list.getEntries() as AllPerformanceEntry[],
14-
);
15-
replay.performanceEvents = newPerformanceEntries;
16-
};
9+
export function setupPerformanceObserver(replay: ReplayContainer): () => void {
10+
function addPerformanceEntry(entry: PerformanceEntry): void {
11+
// It is possible for entries to come up multiple times
12+
if (!replay.performanceEvents.includes(entry)) {
13+
replay.performanceEvents.push(entry);
14+
}
15+
}
1716

18-
const performanceObserver = new PerformanceObserver(performanceObserverHandler);
17+
function onEntries({ entries }: { entries: PerformanceEntry[] }): void {
18+
entries.forEach(addPerformanceEntry);
19+
}
1920

20-
[
21-
'element',
22-
'event',
23-
'first-input',
24-
'largest-contentful-paint',
25-
'layout-shift',
26-
'longtask',
27-
'navigation',
28-
'paint',
29-
'resource',
30-
].forEach(type => {
31-
try {
32-
performanceObserver.observe({
33-
type,
34-
buffered: true,
35-
});
36-
} catch {
37-
// This can throw if an entry type is not supported in the browser.
38-
// Ignore these errors.
39-
}
21+
const clearCallbacks: (() => void)[] = [];
22+
23+
(['navigation', 'paint', 'resource'] as const).forEach(type => {
24+
clearCallbacks.push(addPerformanceInstrumentationHandler(type, onEntries));
4025
});
4126

42-
return performanceObserver;
27+
clearCallbacks.push(
28+
addPerformanceInstrumentationHandler('lcp', ({ metric }) => {
29+
replay.lcpMetric = metric;
30+
}),
31+
);
32+
33+
// A callback to cleanup all handlers
34+
return () => {
35+
clearCallbacks.forEach(clearCallback => clearCallback());
36+
};
4337
}

packages/replay/src/replay.ts

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ import { addEvent, addEventSync } from './util/addEvent';
4242
import { addGlobalListeners } from './util/addGlobalListeners';
4343
import { addMemoryEntry } from './util/addMemoryEntry';
4444
import { createBreadcrumb } from './util/createBreadcrumb';
45-
import { createPerformanceEntries } from './util/createPerformanceEntries';
45+
import { createPerformanceEntries, getLargestContentfulPaint } from './util/createPerformanceEntries';
4646
import { createPerformanceSpans } from './util/createPerformanceSpans';
4747
import { debounce } from './util/debounce';
4848
import { getHandleRecordingEmit } from './util/handleRecordingEmit';
@@ -64,6 +64,8 @@ export class ReplayContainer implements ReplayContainerInterface {
6464
*/
6565
public performanceEvents: AllPerformanceEntry[];
6666

67+
public lcpMetric: { value: number; entries: PerformanceEntry[] } | undefined;
68+
6769
public session: Session | undefined;
6870

6971
public clickDetector: ClickDetector | undefined;
@@ -101,7 +103,7 @@ export class ReplayContainer implements ReplayContainerInterface {
101103

102104
private readonly _options: ReplayPluginOptions;
103105

104-
private _performanceObserver: PerformanceObserver | undefined;
106+
private _performanceCleanupCallback?: () => void;
105107

106108
private _debouncedFlush: ReturnType<typeof debounce>;
107109
private _flushLock: Promise<unknown> | undefined;
@@ -817,12 +819,7 @@ export class ReplayContainer implements ReplayContainerInterface {
817819
this._handleException(err);
818820
}
819821

820-
// PerformanceObserver //
821-
if (!('PerformanceObserver' in WINDOW)) {
822-
return;
823-
}
824-
825-
this._performanceObserver = setupPerformanceObserver(this);
822+
this._performanceCleanupCallback = setupPerformanceObserver(this);
826823
}
827824

828825
/**
@@ -840,9 +837,8 @@ export class ReplayContainer implements ReplayContainerInterface {
840837
this.clickDetector.removeListeners();
841838
}
842839

843-
if (this._performanceObserver) {
844-
this._performanceObserver.disconnect();
845-
this._performanceObserver = undefined;
840+
if (this._performanceCleanupCallback) {
841+
this._performanceCleanupCallback();
846842
}
847843
} catch (err) {
848844
this._handleException(err);
@@ -999,7 +995,14 @@ export class ReplayContainer implements ReplayContainerInterface {
999995
const entries = [...this.performanceEvents];
1000996
this.performanceEvents = [];
1001997

1002-
return Promise.all(createPerformanceSpans(this, createPerformanceEntries(entries)));
998+
const performanceEntries = createPerformanceEntries(entries);
999+
1000+
if (this.lcpMetric) {
1001+
performanceEntries.push(getLargestContentfulPaint(this.lcpMetric));
1002+
this.lcpMetric = undefined;
1003+
}
1004+
1005+
return Promise.all(createPerformanceSpans(this, performanceEntries));
10031006
}
10041007

10051008
/**

packages/replay/src/types/replay.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,7 @@ export interface ReplayContainer {
442442
eventBuffer: EventBuffer | null;
443443
clickDetector: ReplayClickDetector | undefined;
444444
performanceEvents: AllPerformanceEntry[];
445+
lcpMetric?: { value: number; entries: PerformanceEntry[] };
445446
session: Session | undefined;
446447
recordingMode: ReplayRecordingMode;
447448
timeouts: Timeouts;

packages/replay/src/util/createPerformanceEntries.ts

Lines changed: 21 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,6 @@ const ENTRY_TYPES: Record<
2323
paint: createPaintEntry,
2424
// @ts-expect-error TODO: entry type does not fit the create* functions entry type
2525
navigation: createNavigationEntry,
26-
// @ts-expect-error TODO: entry type does not fit the create* functions entry type
27-
['largest-contentful-paint']: createLargestContentfulPaint,
2826
};
2927

3028
/**
@@ -37,7 +35,7 @@ export function createPerformanceEntries(
3735
}
3836

3937
function createPerformanceEntry(entry: AllPerformanceEntry): ReplayPerformanceEntry<AllPerformanceEntryData> | null {
40-
if (ENTRY_TYPES[entry.entryType] === undefined) {
38+
if (!ENTRY_TYPES[entry.entryType]) {
4139
return null;
4240
}
4341

@@ -142,37 +140,32 @@ function createResourceEntry(
142140
};
143141
}
144142

145-
function createLargestContentfulPaint(
146-
entry: PerformanceEntry & { size: number; element: Node },
147-
): ReplayPerformanceEntry<LargestContentfulPaintData> {
148-
const { entryType, startTime, size } = entry;
149-
150-
let startTimeOrNavigationActivation = 0;
143+
/**
144+
* Add a LCP event to the replay based on an LCP metric.
145+
*/
146+
export function getLargestContentfulPaint(metric: {
147+
value: number;
148+
entries: PerformanceEntry[];
149+
}): ReplayPerformanceEntry<LargestContentfulPaintData> {
150+
const entries = metric.entries;
151+
const lastEntry = entries[entries.length - 1] as LargestContentfulPaint | undefined;
152+
const element = lastEntry ? lastEntry.element : undefined;
151153

152-
if (WINDOW.performance) {
153-
const navEntry = WINDOW.performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming & {
154-
activationStart: number;
155-
};
154+
const value = metric.value;
156155

157-
// See https://github.com/GoogleChrome/web-vitals/blob/9f11c4c6578fb4c5ee6fa4e32b9d1d756475f135/src/lib/getActivationStart.ts#L21
158-
startTimeOrNavigationActivation = (navEntry && navEntry.activationStart) || 0;
159-
}
156+
const end = getAbsoluteTime(value);
160157

161-
// value is in ms
162-
const value = Math.max(startTime - startTimeOrNavigationActivation, 0);
163-
// LCP doesn't have a "duration", it just happens at a single point in time.
164-
// But the UI expects both, so use end (in seconds) for both timestamps.
165-
const end = getAbsoluteTime(startTimeOrNavigationActivation) + value / 1000;
166-
167-
return {
168-
type: entryType,
169-
name: entryType,
158+
const data: ReplayPerformanceEntry<LargestContentfulPaintData> = {
159+
type: 'largest-contentful-paint',
160+
name: 'largest-contentful-paint',
170161
start: end,
171162
end,
172163
data: {
173-
value, // LCP "duration" in ms
174-
size,
175-
nodeId: record.mirror.getId(entry.element),
164+
value,
165+
size: value,
166+
nodeId: element ? record.mirror.getId(element) : undefined,
176167
},
177168
};
169+
170+
return data;
178171
}

0 commit comments

Comments
 (0)