Skip to content

Commit e4f4597

Browse files
authored
feat(react-router): Trace propagation (#16070)
- Adds new util function `getMetaTagTransformer` for injecting trace meta tags to the html `<head>` - Adds helper function `createSentryHandleRequest` which is a complete Sentry-instrumented handleRequest implementation that handles both route parametrization and trace meta tag injection. -> this is for users that do not need any modifications in their `handleRequest` function - Renames `sentryHandleRequest` to `wrapSentryHandleRequest` to avoid confusion closes #15515
1 parent 5cd3457 commit e4f4597

File tree

8 files changed

+742
-62
lines changed

8 files changed

+742
-62
lines changed

dev-packages/e2e-tests/test-applications/react-router-7-framework/app/entry.server.tsx

+9-58
Original file line numberDiff line numberDiff line change
@@ -1,68 +1,19 @@
1-
import { PassThrough } from 'node:stream';
2-
31
import { createReadableStreamFromReadable } from '@react-router/node';
42
import * as Sentry from '@sentry/react-router';
5-
import { isbot } from 'isbot';
6-
import type { RenderToPipeableStreamOptions } from 'react-dom/server';
73
import { renderToPipeableStream } from 'react-dom/server';
8-
import type { AppLoadContext, EntryContext } from 'react-router';
94
import { ServerRouter } from 'react-router';
10-
const ABORT_DELAY = 5_000;
11-
12-
function handleRequest(
13-
request: Request,
14-
responseStatusCode: number,
15-
responseHeaders: Headers,
16-
routerContext: EntryContext,
17-
loadContext: AppLoadContext,
18-
) {
19-
return new Promise((resolve, reject) => {
20-
let shellRendered = false;
21-
let userAgent = request.headers.get('user-agent');
22-
23-
// Ensure requests from bots and SPA Mode renders wait for all content to load before responding
24-
// https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation
25-
let readyOption: keyof RenderToPipeableStreamOptions =
26-
(userAgent && isbot(userAgent)) || routerContext.isSpaMode ? 'onAllReady' : 'onShellReady';
27-
28-
const { pipe, abort } = renderToPipeableStream(<ServerRouter context={routerContext} url={request.url} />, {
29-
[readyOption]() {
30-
shellRendered = true;
31-
const body = new PassThrough();
32-
const stream = createReadableStreamFromReadable(body);
33-
34-
responseHeaders.set('Content-Type', 'text/html');
35-
36-
resolve(
37-
new Response(stream, {
38-
headers: responseHeaders,
39-
status: responseStatusCode,
40-
}),
41-
);
42-
43-
pipe(body);
44-
},
45-
onShellError(error: unknown) {
46-
reject(error);
47-
},
48-
onError(error: unknown) {
49-
responseStatusCode = 500;
50-
// Log streaming rendering errors from inside the shell. Don't log
51-
// errors encountered during initial shell rendering since they'll
52-
// reject and get logged in handleDocumentRequest.
53-
if (shellRendered) {
54-
console.error(error);
55-
}
56-
},
57-
});
5+
import { type HandleErrorFunction } from 'react-router';
586

59-
setTimeout(abort, ABORT_DELAY);
60-
});
61-
}
7+
const ABORT_DELAY = 5_000;
628

63-
export default Sentry.sentryHandleRequest(handleRequest);
9+
const handleRequest = Sentry.createSentryHandleRequest({
10+
streamTimeout: ABORT_DELAY,
11+
ServerRouter,
12+
renderToPipeableStream,
13+
createReadableStreamFromReadable,
14+
});
6415

65-
import { type HandleErrorFunction } from 'react-router';
16+
export default handleRequest;
6617

6718
export const handleError: HandleErrorFunction = (error, { request }) => {
6819
// React Router may abort some interrupted requests, don't log those
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForTransaction } from '@sentry-internal/test-utils';
3+
import { APP_NAME } from '../constants';
4+
5+
test.describe('Trace propagation', () => {
6+
test('should inject metatags in ssr pageload', async ({ page }) => {
7+
await page.goto(`/`);
8+
const sentryTraceContent = await page.getAttribute('meta[name="sentry-trace"]', 'content');
9+
expect(sentryTraceContent).toBeDefined();
10+
expect(sentryTraceContent).toMatch(/^[a-f0-9]{32}-[a-f0-9]{16}-[01]$/);
11+
const baggageContent = await page.getAttribute('meta[name="baggage"]', 'content');
12+
expect(baggageContent).toBeDefined();
13+
expect(baggageContent).toContain('sentry-environment=qa');
14+
expect(baggageContent).toContain('sentry-public_key=');
15+
expect(baggageContent).toContain('sentry-trace_id=');
16+
expect(baggageContent).toContain('sentry-transaction=');
17+
expect(baggageContent).toContain('sentry-sampled=');
18+
});
19+
20+
test('should have trace connection', async ({ page }) => {
21+
const serverTxPromise = waitForTransaction(APP_NAME, async transactionEvent => {
22+
return transactionEvent.transaction === 'GET *';
23+
});
24+
25+
const clientTxPromise = waitForTransaction(APP_NAME, async transactionEvent => {
26+
return transactionEvent.transaction === '/';
27+
});
28+
29+
await page.goto(`/`);
30+
const serverTx = await serverTxPromise;
31+
const clientTx = await clientTxPromise;
32+
33+
expect(clientTx.contexts?.trace?.trace_id).toEqual(serverTx.contexts?.trace?.trace_id);
34+
expect(clientTx.contexts?.trace?.parent_span_id).toBe(serverTx.contexts?.trace?.span_id);
35+
});
36+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import React from 'react';
2+
import type { AppLoadContext, EntryContext, ServerRouter } from 'react-router';
3+
import type { ReactNode } from 'react';
4+
import { getMetaTagTransformer, wrapSentryHandleRequest } from './wrapSentryHandleRequest';
5+
import type { createReadableStreamFromReadable } from '@react-router/node';
6+
import { PassThrough } from 'stream';
7+
8+
type RenderToPipeableStreamOptions = {
9+
[key: string]: unknown;
10+
onShellReady?: () => void;
11+
onAllReady?: () => void;
12+
onShellError?: (error: unknown) => void;
13+
onError?: (error: unknown) => void;
14+
};
15+
16+
type RenderToPipeableStreamResult = {
17+
pipe: (destination: NodeJS.WritableStream) => void;
18+
abort: () => void;
19+
};
20+
21+
type RenderToPipeableStreamFunction = (
22+
node: ReactNode,
23+
options: RenderToPipeableStreamOptions,
24+
) => RenderToPipeableStreamResult;
25+
26+
export interface SentryHandleRequestOptions {
27+
/**
28+
* Timeout in milliseconds after which the rendering stream will be aborted
29+
* @default 10000
30+
*/
31+
streamTimeout?: number;
32+
33+
/**
34+
* React's renderToPipeableStream function from 'react-dom/server'
35+
*/
36+
renderToPipeableStream: RenderToPipeableStreamFunction;
37+
38+
/**
39+
* The <ServerRouter /> component from '@react-router/server'
40+
*/
41+
ServerRouter: typeof ServerRouter;
42+
43+
/**
44+
* createReadableStreamFromReadable from '@react-router/node'
45+
*/
46+
createReadableStreamFromReadable: typeof createReadableStreamFromReadable;
47+
48+
/**
49+
* Regular expression to identify bot user agents
50+
* @default /bot|crawler|spider|googlebot|chrome-lighthouse|baidu|bing|google|yahoo|lighthouse/i
51+
*/
52+
botRegex?: RegExp;
53+
}
54+
55+
/**
56+
* A complete Sentry-instrumented handleRequest implementation that handles both
57+
* route parametrization and trace meta tag injection.
58+
*
59+
* @param options Configuration options
60+
* @returns A Sentry-instrumented handleRequest function
61+
*/
62+
export function createSentryHandleRequest(
63+
options: SentryHandleRequestOptions,
64+
): (
65+
request: Request,
66+
responseStatusCode: number,
67+
responseHeaders: Headers,
68+
routerContext: EntryContext,
69+
loadContext: AppLoadContext,
70+
) => Promise<unknown> {
71+
const {
72+
streamTimeout = 10000,
73+
renderToPipeableStream,
74+
ServerRouter,
75+
createReadableStreamFromReadable,
76+
botRegex = /bot|crawler|spider|googlebot|chrome-lighthouse|baidu|bing|google|yahoo|lighthouse/i,
77+
} = options;
78+
79+
const handleRequest = function handleRequest(
80+
request: Request,
81+
responseStatusCode: number,
82+
responseHeaders: Headers,
83+
routerContext: EntryContext,
84+
_loadContext: AppLoadContext,
85+
): Promise<Response> {
86+
return new Promise((resolve, reject) => {
87+
let shellRendered = false;
88+
const userAgent = request.headers.get('user-agent');
89+
90+
// Determine if we should use onAllReady or onShellReady
91+
const isBot = typeof userAgent === 'string' && botRegex.test(userAgent);
92+
const isSpaMode = !!(routerContext as { isSpaMode?: boolean }).isSpaMode;
93+
94+
const readyOption = isBot || isSpaMode ? 'onAllReady' : 'onShellReady';
95+
96+
const { pipe, abort } = renderToPipeableStream(<ServerRouter context={routerContext} url={request.url} />, {
97+
[readyOption]() {
98+
shellRendered = true;
99+
const body = new PassThrough();
100+
101+
const stream = createReadableStreamFromReadable(body);
102+
103+
responseHeaders.set('Content-Type', 'text/html');
104+
105+
resolve(
106+
new Response(stream, {
107+
headers: responseHeaders,
108+
status: responseStatusCode,
109+
}),
110+
);
111+
112+
// this injects trace data to the HTML head
113+
pipe(getMetaTagTransformer(body));
114+
},
115+
onShellError(error: unknown) {
116+
reject(error);
117+
},
118+
onError(error: unknown) {
119+
// eslint-disable-next-line no-param-reassign
120+
responseStatusCode = 500;
121+
// Log streaming rendering errors from inside the shell. Don't log
122+
// errors encountered during initial shell rendering since they'll
123+
// reject and get logged in handleDocumentRequest.
124+
if (shellRendered) {
125+
// eslint-disable-next-line no-console
126+
console.error(error);
127+
}
128+
},
129+
});
130+
131+
// Abort the rendering stream after the `streamTimeout`
132+
setTimeout(abort, streamTimeout);
133+
});
134+
};
135+
136+
// Wrap the handle request function for request parametrization
137+
return wrapSentryHandleRequest(handleRequest);
138+
}
+3-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
export * from '@sentry/node';
22

33
export { init } from './sdk';
4-
export { sentryHandleRequest } from './sentryHandleRequest';
4+
// eslint-disable-next-line deprecation/deprecation
5+
export { wrapSentryHandleRequest, sentryHandleRequest, getMetaTagTransformer } from './wrapSentryHandleRequest';
6+
export { createSentryHandleRequest, type SentryHandleRequestOptions } from './createSentryHandleRequest';

packages/react-router/src/server/sentryHandleRequest.ts renamed to packages/react-router/src/server/wrapSentryHandleRequest.ts

+31-2
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { context } from '@opentelemetry/api';
22
import { RPCType, getRPCMetadata } from '@opentelemetry/core';
33
import { ATTR_HTTP_ROUTE } from '@opentelemetry/semantic-conventions';
4-
import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, getActiveSpan, getRootSpan } from '@sentry/core';
4+
import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, getActiveSpan, getRootSpan, getTraceMetaTags } from '@sentry/core';
55
import type { AppLoadContext, EntryContext } from 'react-router';
6+
import type { PassThrough } from 'stream';
7+
import { Transform } from 'stream';
68

79
type OriginalHandleRequest = (
810
request: Request,
@@ -18,7 +20,7 @@ type OriginalHandleRequest = (
1820
* @param originalHandle - The original handleRequest function to wrap
1921
* @returns A wrapped version of the handle request function with Sentry instrumentation
2022
*/
21-
export function sentryHandleRequest(originalHandle: OriginalHandleRequest): OriginalHandleRequest {
23+
export function wrapSentryHandleRequest(originalHandle: OriginalHandleRequest): OriginalHandleRequest {
2224
return async function sentryInstrumentedHandleRequest(
2325
request: Request,
2426
responseStatusCode: number,
@@ -47,6 +49,33 @@ export function sentryHandleRequest(originalHandle: OriginalHandleRequest): Orig
4749
});
4850
}
4951
}
52+
5053
return originalHandle(request, responseStatusCode, responseHeaders, routerContext, loadContext);
5154
};
5255
}
56+
57+
/** @deprecated Use `wrapSentryHandleRequest` instead. */
58+
export const sentryHandleRequest = wrapSentryHandleRequest;
59+
60+
/**
61+
* Injects Sentry trace meta tags into the HTML response by piping through a transform stream.
62+
* This enables distributed tracing by adding trace context to the HTML document head.
63+
*
64+
* @param body - PassThrough stream containing the HTML response body to modify
65+
*/
66+
export function getMetaTagTransformer(body: PassThrough): Transform {
67+
const headClosingTag = '</head>';
68+
const htmlMetaTagTransformer = new Transform({
69+
transform(chunk, _encoding, callback) {
70+
const html = Buffer.isBuffer(chunk) ? chunk.toString() : String(chunk);
71+
if (html.includes(headClosingTag)) {
72+
const modifiedHtml = html.replace(headClosingTag, `${getTraceMetaTags()}${headClosingTag}`);
73+
callback(null, modifiedHtml);
74+
return;
75+
}
76+
callback(null, chunk);
77+
},
78+
});
79+
htmlMetaTagTransformer.pipe(body);
80+
return htmlMetaTagTransformer;
81+
}

0 commit comments

Comments
 (0)