Skip to content

feat(react-router): Trace propagation #16070

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Apr 22, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,68 +1,19 @@
import { PassThrough } from 'node:stream';

import { createReadableStreamFromReadable } from '@react-router/node';
import * as Sentry from '@sentry/react-router';
import { isbot } from 'isbot';
import type { RenderToPipeableStreamOptions } from 'react-dom/server';
import { renderToPipeableStream } from 'react-dom/server';
import type { AppLoadContext, EntryContext } from 'react-router';
import { ServerRouter } from 'react-router';
const ABORT_DELAY = 5_000;

function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
loadContext: AppLoadContext,
) {
return new Promise((resolve, reject) => {
let shellRendered = false;
let userAgent = request.headers.get('user-agent');

// Ensure requests from bots and SPA Mode renders wait for all content to load before responding
// https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation
let readyOption: keyof RenderToPipeableStreamOptions =
(userAgent && isbot(userAgent)) || routerContext.isSpaMode ? 'onAllReady' : 'onShellReady';

const { pipe, abort } = renderToPipeableStream(<ServerRouter context={routerContext} url={request.url} />, {
[readyOption]() {
shellRendered = true;
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);

responseHeaders.set('Content-Type', 'text/html');

resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
}),
);

pipe(body);
},
onShellError(error: unknown) {
reject(error);
},
onError(error: unknown) {
responseStatusCode = 500;
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error);
}
},
});
import { type HandleErrorFunction } from 'react-router';

setTimeout(abort, ABORT_DELAY);
});
}
const ABORT_DELAY = 5_000;

export default Sentry.sentryHandleRequest(handleRequest);
const handleRequest = Sentry.createSentryHandleRequest({
streamTimeout: ABORT_DELAY,
ServerRouter,
renderToPipeableStream,
createReadableStreamFromReadable,
});

import { type HandleErrorFunction } from 'react-router';
export default handleRequest;

export const handleError: HandleErrorFunction = (error, { request }) => {
// React Router may abort some interrupted requests, don't log those
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';
import { APP_NAME } from '../constants';

test.describe('Trace propagation', () => {
test('should inject metatags in ssr pageload', async ({ page }) => {
await page.goto(`/`);
const sentryTraceContent = await page.getAttribute('meta[name="sentry-trace"]', 'content');
expect(sentryTraceContent).toBeDefined();
expect(sentryTraceContent).toMatch(/^[a-f0-9]{32}-[a-f0-9]{16}-[01]$/);
const baggageContent = await page.getAttribute('meta[name="baggage"]', 'content');
expect(baggageContent).toBeDefined();
expect(baggageContent).toContain('sentry-environment=qa');
expect(baggageContent).toContain('sentry-public_key=');
expect(baggageContent).toContain('sentry-trace_id=');
expect(baggageContent).toContain('sentry-transaction=');
expect(baggageContent).toContain('sentry-sampled=');
});

test('should have trace connection', async ({ page }) => {
const serverTxPromise = waitForTransaction(APP_NAME, async transactionEvent => {
return transactionEvent.transaction === 'GET *';
});

const clientTxPromise = waitForTransaction(APP_NAME, async transactionEvent => {
return transactionEvent.transaction === '/';
});

await page.goto(`/`);
const serverTx = await serverTxPromise;
const clientTx = await clientTxPromise;

expect(clientTx.contexts?.trace?.trace_id).toEqual(serverTx.contexts?.trace?.trace_id);
expect(clientTx.contexts?.trace?.parent_span_id).toBe(serverTx.contexts?.trace?.span_id);
});
});
138 changes: 138 additions & 0 deletions packages/react-router/src/server/createSentryHandleRequest.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import React from 'react';
import type { AppLoadContext, EntryContext, ServerRouter } from 'react-router';
import type { ReactNode } from 'react';
import { getMetaTagTransformer, wrapSentryHandleRequest } from './wrapSentryHandleRequest';
import type { createReadableStreamFromReadable } from '@react-router/node';
import { PassThrough } from 'stream';

type RenderToPipeableStreamOptions = {
[key: string]: unknown;
onShellReady?: () => void;
onAllReady?: () => void;
onShellError?: (error: unknown) => void;
onError?: (error: unknown) => void;
};

type RenderToPipeableStreamResult = {
pipe: (destination: NodeJS.WritableStream) => void;
abort: () => void;
};

type RenderToPipeableStreamFunction = (
node: ReactNode,
options: RenderToPipeableStreamOptions,
) => RenderToPipeableStreamResult;

export interface SentryHandleRequestOptions {
/**
* Timeout in milliseconds after which the rendering stream will be aborted
* @default 10000
*/
streamTimeout?: number;

/**
* React's renderToPipeableStream function from 'react-dom/server'
*/
renderToPipeableStream: RenderToPipeableStreamFunction;

/**
* The <ServerRouter /> component from '@react-router/server'
*/
ServerRouter: typeof ServerRouter;

/**
* createReadableStreamFromReadable from '@react-router/node'
*/
createReadableStreamFromReadable: typeof createReadableStreamFromReadable;

/**
* Regular expression to identify bot user agents
* @default /bot|crawler|spider|googlebot|chrome-lighthouse|baidu|bing|google|yahoo|lighthouse/i
*/
botRegex?: RegExp;
}

/**
* A complete Sentry-instrumented handleRequest implementation that handles both
* route parametrization and trace meta tag injection.
*
* @param options Configuration options
* @returns A Sentry-instrumented handleRequest function
*/
export function createSentryHandleRequest(
options: SentryHandleRequestOptions,
): (
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
loadContext: AppLoadContext,
) => Promise<unknown> {
const {
streamTimeout = 10000,
renderToPipeableStream,
ServerRouter,
createReadableStreamFromReadable,
botRegex = /bot|crawler|spider|googlebot|chrome-lighthouse|baidu|bing|google|yahoo|lighthouse/i,
} = options;

const handleRequest = function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
_loadContext: AppLoadContext,
): Promise<Response> {
return new Promise((resolve, reject) => {
let shellRendered = false;
const userAgent = request.headers.get('user-agent');

// Determine if we should use onAllReady or onShellReady
const isBot = typeof userAgent === 'string' && botRegex.test(userAgent);
const isSpaMode = !!(routerContext as { isSpaMode?: boolean }).isSpaMode;

const readyOption = isBot || isSpaMode ? 'onAllReady' : 'onShellReady';

const { pipe, abort } = renderToPipeableStream(<ServerRouter context={routerContext} url={request.url} />, {
[readyOption]() {
shellRendered = true;
const body = new PassThrough();

const stream = createReadableStreamFromReadable(body);

responseHeaders.set('Content-Type', 'text/html');

resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
}),
);

// this injects trace data to the HTML head
pipe(getMetaTagTransformer(body));
},
onShellError(error: unknown) {
reject(error);
},
onError(error: unknown) {
// eslint-disable-next-line no-param-reassign
responseStatusCode = 500;
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
// eslint-disable-next-line no-console
console.error(error);
}
},
});

// Abort the rendering stream after the `streamTimeout`
setTimeout(abort, streamTimeout);
});
};

// Wrap the handle request function for request parametrization
return wrapSentryHandleRequest(handleRequest);
}
4 changes: 3 additions & 1 deletion packages/react-router/src/server/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export * from '@sentry/node';

export { init } from './sdk';
export { sentryHandleRequest } from './sentryHandleRequest';
// eslint-disable-next-line deprecation/deprecation
export { wrapSentryHandleRequest, sentryHandleRequest, getMetaTagTransformer } from './wrapSentryHandleRequest';
export { createSentryHandleRequest, type SentryHandleRequestOptions } from './createSentryHandleRequest';
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { context } from '@opentelemetry/api';
import { RPCType, getRPCMetadata } from '@opentelemetry/core';
import { ATTR_HTTP_ROUTE } from '@opentelemetry/semantic-conventions';
import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, getActiveSpan, getRootSpan } from '@sentry/core';
import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, getActiveSpan, getRootSpan, getTraceMetaTags } from '@sentry/core';
import type { AppLoadContext, EntryContext } from 'react-router';
import type { PassThrough } from 'stream';
import { Transform } from 'stream';

type OriginalHandleRequest = (
request: Request,
Expand All @@ -18,7 +20,7 @@ type OriginalHandleRequest = (
* @param originalHandle - The original handleRequest function to wrap
* @returns A wrapped version of the handle request function with Sentry instrumentation
*/
export function sentryHandleRequest(originalHandle: OriginalHandleRequest): OriginalHandleRequest {
export function wrapSentryHandleRequest(originalHandle: OriginalHandleRequest): OriginalHandleRequest {
return async function sentryInstrumentedHandleRequest(
request: Request,
responseStatusCode: number,
Expand Down Expand Up @@ -47,6 +49,33 @@ export function sentryHandleRequest(originalHandle: OriginalHandleRequest): Orig
});
}
}

return originalHandle(request, responseStatusCode, responseHeaders, routerContext, loadContext);
};
}

/** @deprecated Use `wrapSentryHandleRequest` instead. */
export const sentryHandleRequest = wrapSentryHandleRequest;

/**
* Injects Sentry trace meta tags into the HTML response by piping through a transform stream.
* This enables distributed tracing by adding trace context to the HTML document head.
*
* @param body - PassThrough stream containing the HTML response body to modify
*/
export function getMetaTagTransformer(body: PassThrough): Transform {
const headClosingTag = '</head>';
const htmlMetaTagTransformer = new Transform({
transform(chunk, _encoding, callback) {
const html = Buffer.isBuffer(chunk) ? chunk.toString() : String(chunk);
if (html.includes(headClosingTag)) {
const modifiedHtml = html.replace(headClosingTag, `${getTraceMetaTags()}${headClosingTag}`);
callback(null, modifiedHtml);
return;
}
callback(null, chunk);
},
});
htmlMetaTagTransformer.pipe(body);
return htmlMetaTagTransformer;
}
Loading
Loading