Skip to content

Commit ae7206f

Browse files
onurtemizkanLms24
andauthored
feat(remix): Server Timing Headers Trace Propagation (#18653)
Adds automatic trace propagation from server to client via the Server-Timing HTTP header for Remix applications. The client-side reading of Server-Timing headers via the Performance API was added in #18673. Adds: - `generateSentryServerTimingHeader(span)` public utility that generates a Server-Timing header value containing Sentry trace context - Automatic injection in the document request handler for normal page responses - Automatic injection on redirect responses from loaders and actions, which bypass the document request handler entirely. This is an advantage over meta tag injection, which cannot work on redirect responses since they have no HTML body - For Cloudflare/Hydrogen apps: call `generateSentryServerTimingHeader()` manually and append the value to the response's `Server-Timing` header in entry.server.tsx (see remix-hydrogen e2e test for example) Works on both Node.js and Cloudflare Workers environments. Closes #18696 --------- Co-authored-by: Lukas Stracke <lukas.stracke@sentry.io>
1 parent de7f71e commit ae7206f

26 files changed

+755
-12
lines changed

dev-packages/e2e-tests/test-applications/remix-hydrogen/app/entry.server.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { RemixServer } from '@remix-run/react';
2+
import { generateSentryServerTimingHeader } from '@sentry/remix/cloudflare';
23
import { createContentSecurityPolicy } from '@shopify/hydrogen';
34
import type { EntryContext } from '@shopify/remix-oxygen';
45
import isbot from 'isbot';
@@ -43,8 +44,15 @@ export default async function handleRequest(
4344
// This is required for Sentry's profiling integration
4445
responseHeaders.set('Document-Policy', 'js-profiling');
4546

46-
return new Response(body, {
47+
const response = new Response(body, {
4748
headers: responseHeaders,
4849
status: responseStatusCode,
4950
});
51+
52+
const serverTimingValue = generateSentryServerTimingHeader();
53+
if (serverTimingValue) {
54+
response.headers.append('Server-Timing', serverTimingValue);
55+
}
56+
57+
return response;
5058
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { expect, test } from '@playwright/test';
2+
3+
test('Server-Timing header contains sentry-trace on page load', async ({ page }) => {
4+
const responsePromise = page.waitForResponse(
5+
response =>
6+
response.url().endsWith('/') && response.status() === 200 && response.request().resourceType() === 'document',
7+
);
8+
9+
await page.goto('/');
10+
11+
const response = await responsePromise;
12+
const serverTimingHeader = response.headers()['server-timing'];
13+
14+
expect(serverTimingHeader).toBeDefined();
15+
expect(serverTimingHeader).toContain('sentry-trace');
16+
expect(serverTimingHeader).toContain('baggage');
17+
});
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
/** @type {import('eslint').Linter.Config} */
2+
module.exports = {
3+
extends: ['@remix-run/eslint-config', '@remix-run/eslint-config/node'],
4+
};
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
node_modules
2+
build
3+
.env
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
@sentry:registry=http://127.0.0.1:4873
2+
@sentry-internal:registry=http://127.0.0.1:4873
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/**
2+
* By default, Remix will handle hydrating your app on the client for you.
3+
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal`
4+
* For more information, see https://remix.run/file-conventions/entry.client
5+
*/
6+
7+
// Extend the Window interface to include ENV
8+
declare global {
9+
interface Window {
10+
ENV: {
11+
SENTRY_DSN: string;
12+
[key: string]: unknown;
13+
};
14+
}
15+
}
16+
17+
import { RemixBrowser, useLocation, useMatches } from '@remix-run/react';
18+
import * as Sentry from '@sentry/remix';
19+
import { StrictMode, startTransition, useEffect } from 'react';
20+
import { hydrateRoot } from 'react-dom/client';
21+
22+
Sentry.init({
23+
environment: 'qa', // dynamic sampling bias to keep transactions
24+
dsn: window.ENV.SENTRY_DSN,
25+
integrations: [
26+
Sentry.browserTracingIntegration({
27+
useEffect,
28+
useLocation,
29+
useMatches,
30+
}),
31+
],
32+
// Performance Monitoring
33+
tracesSampleRate: 1.0, // Capture 100% of the transactions
34+
tunnel: 'http://localhost:3031/', // proxy server
35+
});
36+
37+
startTransition(() => {
38+
hydrateRoot(
39+
document,
40+
<StrictMode>
41+
<RemixBrowser />
42+
</StrictMode>,
43+
);
44+
});
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import * as Sentry from '@sentry/remix';
2+
3+
import { PassThrough } from 'node:stream';
4+
5+
import type { AppLoadContext, EntryContext } from '@remix-run/node';
6+
import { createReadableStreamFromReadable } from '@remix-run/node';
7+
import { installGlobals } from '@remix-run/node';
8+
import { RemixServer } from '@remix-run/react';
9+
import isbot from 'isbot';
10+
import { renderToPipeableStream } from 'react-dom/server';
11+
12+
installGlobals();
13+
14+
const ABORT_DELAY = 5_000;
15+
16+
export const handleError = Sentry.sentryHandleError;
17+
18+
export default function handleRequest(
19+
request: Request,
20+
responseStatusCode: number,
21+
responseHeaders: Headers,
22+
remixContext: EntryContext,
23+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
24+
loadContext: AppLoadContext,
25+
) {
26+
return isbot(request.headers.get('user-agent'))
27+
? handleBotRequest(request, responseStatusCode, responseHeaders, remixContext)
28+
: handleBrowserRequest(request, responseStatusCode, responseHeaders, remixContext);
29+
}
30+
31+
function handleBotRequest(
32+
request: Request,
33+
responseStatusCode: number,
34+
responseHeaders: Headers,
35+
remixContext: EntryContext,
36+
) {
37+
return new Promise((resolve, reject) => {
38+
let shellRendered = false;
39+
const { pipe, abort } = renderToPipeableStream(
40+
<RemixServer context={remixContext} url={request.url} abortDelay={ABORT_DELAY} />,
41+
{
42+
onAllReady() {
43+
shellRendered = true;
44+
const body = new PassThrough();
45+
const stream = createReadableStreamFromReadable(body);
46+
47+
responseHeaders.set('Content-Type', 'text/html');
48+
49+
resolve(
50+
new Response(stream, {
51+
headers: responseHeaders,
52+
status: responseStatusCode,
53+
}),
54+
);
55+
56+
pipe(body);
57+
},
58+
onShellError(error: unknown) {
59+
reject(error);
60+
},
61+
onError(error: unknown) {
62+
responseStatusCode = 500;
63+
if (shellRendered) {
64+
console.error(error);
65+
}
66+
},
67+
},
68+
);
69+
70+
setTimeout(abort, ABORT_DELAY);
71+
});
72+
}
73+
74+
function handleBrowserRequest(
75+
request: Request,
76+
responseStatusCode: number,
77+
responseHeaders: Headers,
78+
remixContext: EntryContext,
79+
) {
80+
return new Promise((resolve, reject) => {
81+
let shellRendered = false;
82+
const { pipe, abort } = renderToPipeableStream(
83+
<RemixServer context={remixContext} url={request.url} abortDelay={ABORT_DELAY} />,
84+
{
85+
onShellReady() {
86+
shellRendered = true;
87+
const body = new PassThrough();
88+
const stream = createReadableStreamFromReadable(body);
89+
90+
responseHeaders.set('Content-Type', 'text/html');
91+
92+
resolve(
93+
new Response(stream, {
94+
headers: responseHeaders,
95+
status: responseStatusCode,
96+
}),
97+
);
98+
99+
pipe(body);
100+
},
101+
onShellError(error: unknown) {
102+
reject(error);
103+
},
104+
onError(error: unknown) {
105+
responseStatusCode = 500;
106+
if (shellRendered) {
107+
console.error(error);
108+
}
109+
},
110+
},
111+
);
112+
113+
setTimeout(abort, ABORT_DELAY);
114+
});
115+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { cssBundleHref } from '@remix-run/css-bundle';
2+
import { LinksFunction, json } from '@remix-run/node';
3+
import {
4+
Links,
5+
LiveReload,
6+
Meta,
7+
Outlet,
8+
Scripts,
9+
ScrollRestoration,
10+
useLoaderData,
11+
useRouteError,
12+
} from '@remix-run/react';
13+
import { captureRemixErrorBoundaryError, withSentry } from '@sentry/remix';
14+
15+
export const links: LinksFunction = () => [...(cssBundleHref ? [{ rel: 'stylesheet', href: cssBundleHref }] : [])];
16+
17+
export const loader = () => {
18+
return json({
19+
ENV: {
20+
SENTRY_DSN: process.env.E2E_TEST_DSN,
21+
},
22+
});
23+
};
24+
25+
export function ErrorBoundary() {
26+
const error = useRouteError();
27+
const eventId = captureRemixErrorBoundaryError(error);
28+
29+
return (
30+
<div>
31+
<span>ErrorBoundary Error</span>
32+
<span id="event-id">{eventId}</span>
33+
</div>
34+
);
35+
}
36+
37+
function App() {
38+
const { ENV } = useLoaderData() as { ENV: { SENTRY_DSN: string } };
39+
40+
return (
41+
<html lang="en">
42+
<head>
43+
<meta charSet="utf-8" />
44+
<meta name="viewport" content="width=device-width,initial-scale=1" />
45+
<script
46+
dangerouslySetInnerHTML={{
47+
__html: `window.ENV = ${JSON.stringify(ENV)}`,
48+
}}
49+
/>
50+
<Meta />
51+
<Links />
52+
</head>
53+
<body>
54+
<Outlet />
55+
<ScrollRestoration />
56+
<Scripts />
57+
<LiveReload />
58+
</body>
59+
</html>
60+
);
61+
}
62+
63+
export default withSentry(App);
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { json, LoaderFunctionArgs } from '@remix-run/node';
2+
import { Link, useSearchParams } from '@remix-run/react';
3+
import * as Sentry from '@sentry/remix';
4+
5+
export const loader = async ({ request }: LoaderFunctionArgs) => {
6+
return json({});
7+
};
8+
9+
export default function Index() {
10+
const [searchParams] = useSearchParams();
11+
12+
if (searchParams.get('tag')) {
13+
Sentry.setTag('sentry_test', searchParams.get('tag'));
14+
}
15+
16+
return (
17+
<div style={{ fontFamily: 'system-ui, sans-serif', lineHeight: '1.8' }}>
18+
<h1>Server-Timing Trace Propagation Test</h1>
19+
<ul>
20+
<li>
21+
<Link id="navigation" to="/user/123">
22+
Navigate to User 123
23+
</Link>
24+
</li>
25+
</ul>
26+
</div>
27+
);
28+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { redirect } from '@remix-run/node';
2+
3+
export const loader = async () => {
4+
return redirect('/user/redirected');
5+
};

0 commit comments

Comments
 (0)