Skip to content

Commit 03257e0

Browse files
authored
feat(core): Adapt spans for client-side fetch to streaming responses (#12723)
1 parent 9a8e910 commit 03257e0

File tree

13 files changed

+349
-45
lines changed

13 files changed

+349
-45
lines changed

.size-limit.js

+3-3
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ module.exports = [
1515
path: 'packages/browser/build/npm/esm/index.js',
1616
import: createImport('init', 'browserTracingIntegration'),
1717
gzip: true,
18-
limit: '35 KB',
18+
limit: '36 KB',
1919
},
2020
{
2121
name: '@sentry/browser (incl. Tracing, Replay)',
@@ -29,7 +29,7 @@ module.exports = [
2929
path: 'packages/browser/build/npm/esm/index.js',
3030
import: createImport('init', 'browserTracingIntegration', 'replayIntegration'),
3131
gzip: true,
32-
limit: '65 KB',
32+
limit: '66 KB',
3333
modifyWebpackConfig: function (config) {
3434
const webpack = require('webpack');
3535
config.plugins.push(
@@ -107,7 +107,7 @@ module.exports = [
107107
import: createImport('init', 'ErrorBoundary', 'reactRouterV6BrowserTracingIntegration'),
108108
ignore: ['react/jsx-runtime'],
109109
gzip: true,
110-
limit: '38 KB',
110+
limit: '39 KB',
111111
},
112112
// Vue SDK (ESM)
113113
{

dev-packages/e2e-tests/test-applications/react-router-6/package.json

+6-2
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"@sentry/react": "latest || *",
77
"@types/react": "18.0.0",
88
"@types/react-dom": "18.0.0",
9+
"express": "4.19.2",
910
"react": "18.2.0",
1011
"react-dom": "18.2.0",
1112
"react-router-dom": "^6.4.1",
@@ -14,7 +15,9 @@
1415
},
1516
"scripts": {
1617
"build": "react-scripts build",
17-
"start": "serve -s build",
18+
"start": "run-p start:client start:server",
19+
"start:client": "node server/app.js",
20+
"start:server": "serve -s build",
1821
"test": "playwright test",
1922
"clean": "npx rimraf node_modules pnpm-lock.yaml",
2023
"test:build": "pnpm install && npx playwright install && pnpm build",
@@ -43,7 +46,8 @@
4346
"devDependencies": {
4447
"@playwright/test": "^1.44.1",
4548
"@sentry-internal/test-utils": "link:../../../test-utils",
46-
"serve": "14.0.1"
49+
"serve": "14.0.1",
50+
"npm-run-all2": "^6.2.0"
4751
},
4852
"volta": {
4953
"extends": "../../package.json"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
const express = require('express');
2+
3+
const app = express();
4+
const PORT = 8080;
5+
6+
const wait = time => {
7+
return new Promise(resolve => {
8+
setTimeout(() => {
9+
resolve();
10+
}, time);
11+
});
12+
};
13+
14+
async function sseHandler(request, response, timeout = false) {
15+
response.headers = {
16+
'Content-Type': 'text/event-stream',
17+
Connection: 'keep-alive',
18+
'Cache-Control': 'no-cache',
19+
'Access-Control-Allow-Origin': '*',
20+
};
21+
22+
response.setHeader('Cache-Control', 'no-cache');
23+
response.setHeader('Content-Type', 'text/event-stream');
24+
response.setHeader('Access-Control-Allow-Origin', '*');
25+
response.setHeader('Connection', 'keep-alive');
26+
27+
response.flushHeaders();
28+
29+
await wait(2000);
30+
31+
for (let index = 0; index < 10; index++) {
32+
response.write(`data: ${new Date().toISOString()}\n\n`);
33+
if (timeout) {
34+
await wait(10000);
35+
}
36+
}
37+
38+
response.end();
39+
}
40+
41+
app.get('/sse', (req, res) => sseHandler(req, res));
42+
43+
app.get('/sse-timeout', (req, res) => sseHandler(req, res, true));
44+
45+
app.listen(PORT, () => {
46+
console.log(`SSE service listening at http://localhost:${PORT}`);
47+
});

dev-packages/e2e-tests/test-applications/react-router-6/src/index.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
useNavigationType,
1212
} from 'react-router-dom';
1313
import Index from './pages/Index';
14+
import SSE from './pages/SSE';
1415
import User from './pages/User';
1516

1617
const replay = Sentry.replayIntegration();
@@ -48,6 +49,7 @@ root.render(
4849
<SentryRoutes>
4950
<Route path="/" element={<Index />} />
5051
<Route path="/user/:id" element={<User />} />
52+
<Route path="/sse" element={<SSE />} />
5153
</SentryRoutes>
5254
</BrowserRouter>,
5355
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import * as Sentry from '@sentry/react';
2+
// biome-ignore lint/nursery/noUnusedImports: Need React import for JSX
3+
import * as React from 'react';
4+
5+
const fetchSSE = async ({ timeout }: { timeout: boolean }) => {
6+
Sentry.startSpanManual({ name: 'sse stream using fetch' }, async span => {
7+
const res = await Sentry.startSpan({ name: 'sse fetch call' }, async () => {
8+
const endpoint = `http://localhost:8080/${timeout ? 'sse-timeout' : 'sse'}`;
9+
return await fetch(endpoint);
10+
});
11+
12+
const stream = res.body;
13+
const reader = stream?.getReader();
14+
15+
const readChunk = async () => {
16+
const readRes = await reader?.read();
17+
if (readRes?.done) {
18+
return;
19+
}
20+
21+
new TextDecoder().decode(readRes?.value);
22+
23+
await readChunk();
24+
};
25+
26+
try {
27+
await readChunk();
28+
} catch (error) {
29+
console.error('Could not fetch sse', error);
30+
}
31+
32+
span.end();
33+
});
34+
};
35+
36+
const SSE = () => {
37+
return (
38+
<>
39+
<button id="fetch-button" onClick={() => fetchSSE({ timeout: false })}>
40+
Fetch SSE
41+
</button>
42+
<button id="fetch-timeout-button" onClick={() => fetchSSE({ timeout: true })}>
43+
Fetch timeout SSE
44+
</button>
45+
</>
46+
);
47+
};
48+
49+
export default SSE;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForTransaction } from '@sentry-internal/test-utils';
3+
import { SpanJSON } from '@sentry/types';
4+
5+
test('Waits for sse streaming when creating spans', async ({ page }) => {
6+
await page.goto('/sse');
7+
8+
const transactionPromise = waitForTransaction('react-router-6', async transactionEvent => {
9+
return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload';
10+
});
11+
12+
const fetchButton = page.locator('id=fetch-button');
13+
await fetchButton.click();
14+
15+
const rootSpan = await transactionPromise;
16+
const sseFetchCall = rootSpan.spans?.filter(span => span.description === 'sse fetch call')[0] as SpanJSON;
17+
const httpGet = rootSpan.spans?.filter(span => span.description === 'GET http://localhost:8080/sse')[0] as SpanJSON;
18+
19+
expect(sseFetchCall).toBeDefined();
20+
expect(httpGet).toBeDefined();
21+
22+
expect(sseFetchCall?.timestamp).toBeDefined();
23+
expect(sseFetchCall?.start_timestamp).toBeDefined();
24+
expect(httpGet?.timestamp).toBeDefined();
25+
expect(httpGet?.start_timestamp).toBeDefined();
26+
27+
// http headers get sent instantly from the server
28+
const resolveDuration = Math.round((sseFetchCall.timestamp as number) - sseFetchCall.start_timestamp);
29+
30+
// body streams after 2s
31+
const resolveBodyDuration = Math.round((httpGet.timestamp as number) - httpGet.start_timestamp);
32+
33+
expect(resolveDuration).toBe(0);
34+
expect(resolveBodyDuration).toBe(2);
35+
});
36+
37+
test('Aborts when stream takes longer than 5s', async ({ page }) => {
38+
await page.goto('/sse');
39+
40+
const transactionPromise = waitForTransaction('react-router-6', async transactionEvent => {
41+
return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload';
42+
});
43+
44+
const fetchButton = page.locator('id=fetch-timeout-button');
45+
await fetchButton.click();
46+
47+
const rootSpan = await transactionPromise;
48+
const sseFetchCall = rootSpan.spans?.filter(span => span.description === 'sse fetch call')[0] as SpanJSON;
49+
const httpGet = rootSpan.spans?.filter(
50+
span => span.description === 'GET http://localhost:8080/sse-timeout',
51+
)[0] as SpanJSON;
52+
53+
expect(sseFetchCall).toBeDefined();
54+
expect(httpGet).toBeDefined();
55+
56+
expect(sseFetchCall?.timestamp).toBeDefined();
57+
expect(sseFetchCall?.start_timestamp).toBeDefined();
58+
expect(httpGet?.timestamp).toBeDefined();
59+
expect(httpGet?.start_timestamp).toBeDefined();
60+
61+
// http headers get sent instantly from the server
62+
const resolveDuration = Math.round((sseFetchCall.timestamp as number) - sseFetchCall.start_timestamp);
63+
64+
// body streams after 10s but client should abort reading after 5s
65+
const resolveBodyDuration = Math.round((httpGet.timestamp as number) - httpGet.start_timestamp);
66+
67+
expect(resolveDuration).toBe(0);
68+
expect(resolveBodyDuration).toBe(7);
69+
});

packages/browser/src/tracing/browserTracingIntegration.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -398,7 +398,7 @@ export const browserTracingIntegration = ((_options: Partial<BrowserTracingOptio
398398
registerInpInteractionListener();
399399
}
400400

401-
instrumentOutgoingRequests({
401+
instrumentOutgoingRequests(client, {
402402
traceFetch,
403403
traceXHR,
404404
tracePropagationTargets: client.getOptions().tracePropagationTargets,

packages/browser/src/tracing/request.ts

+36-1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
import type { Client, HandlerDataXhr, SentryWrappedXMLHttpRequest, Span } from '@sentry/types';
2424
import {
2525
BAGGAGE_HEADER_NAME,
26+
addFetchEndInstrumentationHandler,
2627
addFetchInstrumentationHandler,
2728
browserPerformanceTimeOrigin,
2829
dynamicSamplingContextToSentryBaggageHeader,
@@ -93,14 +94,17 @@ export interface RequestInstrumentationOptions {
9394
shouldCreateSpanForRequest?(this: void, url: string): boolean;
9495
}
9596

97+
const responseToSpanId = new WeakMap<object, string>();
98+
const spanIdToEndTimestamp = new Map<string, number>();
99+
96100
export const defaultRequestInstrumentationOptions: RequestInstrumentationOptions = {
97101
traceFetch: true,
98102
traceXHR: true,
99103
enableHTTPTimings: true,
100104
};
101105

102106
/** Registers span creators for xhr and fetch requests */
103-
export function instrumentOutgoingRequests(_options?: Partial<RequestInstrumentationOptions>): void {
107+
export function instrumentOutgoingRequests(client: Client, _options?: Partial<RequestInstrumentationOptions>): void {
104108
const { traceFetch, traceXHR, shouldCreateSpanForRequest, enableHTTPTimings, tracePropagationTargets } = {
105109
traceFetch: defaultRequestInstrumentationOptions.traceFetch,
106110
traceXHR: defaultRequestInstrumentationOptions.traceXHR,
@@ -115,8 +119,39 @@ export function instrumentOutgoingRequests(_options?: Partial<RequestInstrumenta
115119
const spans: Record<string, Span> = {};
116120

117121
if (traceFetch) {
122+
// Keeping track of http requests, whose body payloads resolved later than the intial resolved request
123+
// e.g. streaming using server sent events (SSE)
124+
client.addEventProcessor(event => {
125+
if (event.type === 'transaction' && event.spans) {
126+
event.spans.forEach(span => {
127+
if (span.op === 'http.client') {
128+
const updatedTimestamp = spanIdToEndTimestamp.get(span.span_id);
129+
if (updatedTimestamp) {
130+
span.timestamp = updatedTimestamp / 1000;
131+
spanIdToEndTimestamp.delete(span.span_id);
132+
}
133+
}
134+
});
135+
}
136+
return event;
137+
});
138+
139+
addFetchEndInstrumentationHandler(handlerData => {
140+
if (handlerData.response) {
141+
const span = responseToSpanId.get(handlerData.response);
142+
if (span && handlerData.endTimestamp) {
143+
spanIdToEndTimestamp.set(span, handlerData.endTimestamp);
144+
}
145+
}
146+
});
147+
118148
addFetchInstrumentationHandler(handlerData => {
119149
const createdSpan = instrumentFetchRequest(handlerData, shouldCreateSpan, shouldAttachHeadersWithTargets, spans);
150+
151+
if (handlerData.response && handlerData.fetchData.__span) {
152+
responseToSpanId.set(handlerData.response, handlerData.fetchData.__span);
153+
}
154+
120155
// We cannot use `window.location` in the generic fetch instrumentation,
121156
// but we need it for reliable `server.address` attribute.
122157
// so we extend this in here

packages/browser/test/unit/tracing/request.test.ts

+15-3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as browserUtils from '@sentry-internal/browser-utils';
2+
import type { Client } from '@sentry/types';
23
import * as utils from '@sentry/utils';
34
import { WINDOW } from '../../../src/helpers';
45

@@ -10,16 +11,27 @@ beforeAll(() => {
1011
global.Request = {};
1112
});
1213

14+
class MockClient implements Partial<Client> {
15+
public addEventProcessor: () => void;
16+
constructor() {
17+
// Mock addEventProcessor function
18+
this.addEventProcessor = jest.fn();
19+
}
20+
}
21+
1322
describe('instrumentOutgoingRequests', () => {
23+
let client: Client;
24+
1425
beforeEach(() => {
1526
jest.clearAllMocks();
27+
client = new MockClient() as unknown as Client;
1628
});
1729

1830
it('instruments fetch and xhr requests', () => {
1931
const addFetchSpy = jest.spyOn(utils, 'addFetchInstrumentationHandler');
2032
const addXhrSpy = jest.spyOn(browserUtils, 'addXhrInstrumentationHandler');
2133

22-
instrumentOutgoingRequests();
34+
instrumentOutgoingRequests(client);
2335

2436
expect(addFetchSpy).toHaveBeenCalledWith(expect.any(Function));
2537
expect(addXhrSpy).toHaveBeenCalledWith(expect.any(Function));
@@ -28,15 +40,15 @@ describe('instrumentOutgoingRequests', () => {
2840
it('does not instrument fetch requests if traceFetch is false', () => {
2941
const addFetchSpy = jest.spyOn(utils, 'addFetchInstrumentationHandler');
3042

31-
instrumentOutgoingRequests({ traceFetch: false });
43+
instrumentOutgoingRequests(client, { traceFetch: false });
3244

3345
expect(addFetchSpy).not.toHaveBeenCalled();
3446
});
3547

3648
it('does not instrument xhr requests if traceXHR is false', () => {
3749
const addXhrSpy = jest.spyOn(browserUtils, 'addXhrInstrumentationHandler');
3850

39-
instrumentOutgoingRequests({ traceXHR: false });
51+
instrumentOutgoingRequests(client, { traceXHR: false });
4052

4153
expect(addXhrSpy).not.toHaveBeenCalled();
4254
});

packages/sveltekit/test/client/browserTracingIntegration.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ describe('browserTracingIntegration', () => {
6060
return createdRootSpan as Span;
6161
});
6262

63-
const fakeClient = { getOptions: () => ({}), on: () => {} };
63+
const fakeClient = { getOptions: () => ({}), on: () => {}, addEventProcessor: () => {} };
6464

6565
const mockedRoutingSpan = {
6666
end: () => {},

0 commit comments

Comments
 (0)