Skip to content

Commit 921b8df

Browse files
authored
fix(replay): Fix xhr start timestamps (#9341)
We've been using the wrong `startTimestamp` for the core xhr instrumentation. Outside of replay, this wasn't noticed because we are not actually using the anywhere 😬 But in replay, it lead to all xhr breadrcumbs showing an incorrect duration of `0`. Note that this is _maybe_ not 100% correct, as in theory you could call `xhr.send()` later, which is probably the _most correct_ start timestamp. But this would require us to keep the start time somewhere on the xhr object, which is a bit trickier than this solution. So I think it is fine to do this based on `xhr.open()` (and _definitely_ more correct than it was before). fixes getsentry/sentry#52790
1 parent a410b04 commit 921b8df

File tree

5 files changed

+181
-1
lines changed

5 files changed

+181
-1
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.Sentry = Sentry;
4+
window.Replay = new Sentry.Replay({
5+
flushMinDelay: 200,
6+
flushMaxDelay: 200,
7+
minReplayDuration: 0,
8+
});
9+
10+
Sentry.init({
11+
dsn: 'https://[email protected]/1337',
12+
sampleRate: 1,
13+
// We ensure to sample for errors, so by default nothing is sent
14+
replaysSessionSampleRate: 0.0,
15+
replaysOnErrorSampleRate: 1.0,
16+
17+
integrations: [window.Replay],
18+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { expect } from '@playwright/test';
2+
3+
import { sentryTest } from '../../../../../utils/fixtures';
4+
import { envelopeRequestParser, waitForErrorRequest } from '../../../../../utils/helpers';
5+
import {
6+
getCustomRecordingEvents,
7+
shouldSkipReplayTest,
8+
waitForReplayRequest,
9+
} from '../../../../../utils/replayHelpers';
10+
11+
sentryTest('captures correct timestamps', async ({ getLocalTestPath, page, browserName }) => {
12+
// These are a bit flaky on non-chromium browsers
13+
if (shouldSkipReplayTest() || browserName !== 'chromium') {
14+
sentryTest.skip();
15+
}
16+
17+
await page.route('**/foo', route => {
18+
return route.fulfill({
19+
status: 200,
20+
});
21+
});
22+
23+
await page.route('https://dsn.ingest.sentry.io/**/*', async route => {
24+
await new Promise(resolve => setTimeout(resolve, 10));
25+
return route.fulfill({
26+
status: 200,
27+
contentType: 'application/json',
28+
body: JSON.stringify({ id: 'test-id' }),
29+
});
30+
});
31+
32+
const requestPromise = waitForErrorRequest(page);
33+
const replayRequestPromise1 = waitForReplayRequest(page, 0);
34+
35+
const url = await getLocalTestPath({ testDir: __dirname });
36+
await page.goto(url);
37+
38+
await page.evaluate(() => {
39+
/* eslint-disable */
40+
fetch('http://localhost:7654/foo', {
41+
method: 'POST',
42+
body: '{"foo":"bar"}',
43+
}).then(() => {
44+
// @ts-expect-error Sentry is a global
45+
Sentry.captureException('test error');
46+
});
47+
/* eslint-enable */
48+
});
49+
50+
const request = await requestPromise;
51+
const eventData = envelopeRequestParser(request);
52+
53+
const replayReq1 = await replayRequestPromise1;
54+
const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1);
55+
56+
const xhrSpan = performanceSpans1.find(span => span.op === 'resource.fetch')!;
57+
58+
expect(xhrSpan).toBeDefined();
59+
60+
const { startTimestamp, endTimestamp } = xhrSpan;
61+
62+
expect(startTimestamp).toEqual(expect.any(Number));
63+
expect(endTimestamp).toEqual(expect.any(Number));
64+
expect(endTimestamp).toBeGreaterThan(startTimestamp);
65+
66+
expect(eventData!.breadcrumbs![0].timestamp).toBeGreaterThan(startTimestamp);
67+
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.Sentry = Sentry;
4+
window.Replay = new Sentry.Replay({
5+
flushMinDelay: 200,
6+
flushMaxDelay: 200,
7+
minReplayDuration: 0,
8+
});
9+
10+
Sentry.init({
11+
dsn: 'https://[email protected]/1337',
12+
sampleRate: 1,
13+
// We ensure to sample for errors, so by default nothing is sent
14+
replaysSessionSampleRate: 0.0,
15+
replaysOnErrorSampleRate: 1.0,
16+
17+
integrations: [window.Replay],
18+
});
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { expect } from '@playwright/test';
2+
3+
import { sentryTest } from '../../../../../utils/fixtures';
4+
import { envelopeRequestParser, waitForErrorRequest } from '../../../../../utils/helpers';
5+
import {
6+
getCustomRecordingEvents,
7+
shouldSkipReplayTest,
8+
waitForReplayRequest,
9+
} from '../../../../../utils/replayHelpers';
10+
11+
sentryTest('captures correct timestamps', async ({ getLocalTestPath, page, browserName }) => {
12+
// These are a bit flaky on non-chromium browsers
13+
if (shouldSkipReplayTest() || browserName !== 'chromium') {
14+
sentryTest.skip();
15+
}
16+
17+
await page.route('**/foo', route => {
18+
return route.fulfill({
19+
status: 200,
20+
});
21+
});
22+
23+
await page.route('https://dsn.ingest.sentry.io/**/*', async route => {
24+
await new Promise(resolve => setTimeout(resolve, 10));
25+
return route.fulfill({
26+
status: 200,
27+
contentType: 'application/json',
28+
body: JSON.stringify({ id: 'test-id' }),
29+
});
30+
});
31+
32+
const requestPromise = waitForErrorRequest(page);
33+
const replayRequestPromise1 = waitForReplayRequest(page, 0);
34+
35+
const url = await getLocalTestPath({ testDir: __dirname });
36+
await page.goto(url);
37+
38+
void page.evaluate(() => {
39+
/* eslint-disable */
40+
const xhr = new XMLHttpRequest();
41+
42+
xhr.open('POST', 'http://localhost:7654/foo');
43+
xhr.setRequestHeader('Accept', 'application/json');
44+
xhr.setRequestHeader('Content-Type', 'application/json');
45+
xhr.setRequestHeader('Cache', 'no-cache');
46+
xhr.setRequestHeader('X-Test-Header', 'test-value');
47+
xhr.send();
48+
49+
xhr.addEventListener('readystatechange', function () {
50+
if (xhr.readyState === 4) {
51+
// @ts-expect-error Sentry is a global
52+
setTimeout(() => Sentry.captureException('test error', 0));
53+
}
54+
});
55+
/* eslint-enable */
56+
});
57+
58+
const request = await requestPromise;
59+
const eventData = envelopeRequestParser(request);
60+
61+
const replayReq1 = await replayRequestPromise1;
62+
const { performanceSpans: performanceSpans1 } = getCustomRecordingEvents(replayReq1);
63+
64+
const xhrSpan = performanceSpans1.find(span => span.op === 'resource.xhr')!;
65+
66+
expect(xhrSpan).toBeDefined();
67+
68+
const { startTimestamp, endTimestamp } = xhrSpan;
69+
70+
expect(startTimestamp).toEqual(expect.any(Number));
71+
expect(endTimestamp).toEqual(expect.any(Number));
72+
expect(endTimestamp).toBeGreaterThan(startTimestamp);
73+
74+
expect(eventData!.breadcrumbs![0].timestamp).toBeGreaterThan(startTimestamp);
75+
});

packages/utils/src/instrument.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,8 @@ export function instrumentXHR(): void {
257257

258258
fill(xhrproto, 'open', function (originalOpen: () => void): () => void {
259259
return function (this: XMLHttpRequest & SentryWrappedXMLHttpRequest, ...args: any[]): void {
260+
const startTimestamp = Date.now();
261+
260262
const url = args[1];
261263
const xhrInfo: SentryXhrData = (this[SENTRY_XHR_DATA_KEY] = {
262264
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
@@ -291,7 +293,7 @@ export function instrumentXHR(): void {
291293
triggerHandlers('xhr', {
292294
args: args as [string, string],
293295
endTimestamp: Date.now(),
294-
startTimestamp: Date.now(),
296+
startTimestamp,
295297
xhr: this,
296298
} as HandlerDataXhr);
297299
}

0 commit comments

Comments
 (0)