Skip to content

Commit 1f07bc8

Browse files
committed
feat(remix): Server Timing Headers Trace Propagation PoC
1 parent 1b1cf85 commit 1f07bc8

27 files changed

+1347
-63
lines changed

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { RemixServer } from '@remix-run/react';
2+
import { addSentryServerTimingHeader } 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,11 @@ 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+
// Add Server-Timing header with Sentry trace context for client-side trace propagation
53+
return addSentryServerTimingHeader(response);
5054
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { expect, test } from '@playwright/test';
2+
3+
test.describe.configure({ mode: 'serial' });
4+
5+
test('Server-Timing header contains sentry-trace on page load (Cloudflare)', async ({ page }) => {
6+
// Intercept the document response (not data requests or other resources)
7+
const responsePromise = page.waitForResponse(
8+
response =>
9+
response.url().endsWith('/') && response.status() === 200 && response.request().resourceType() === 'document',
10+
);
11+
12+
await page.goto('/');
13+
14+
const response = await responsePromise;
15+
const serverTimingHeader = response.headers()['server-timing'];
16+
17+
expect(serverTimingHeader).toBeDefined();
18+
expect(serverTimingHeader).toContain('sentry-trace');
19+
expect(serverTimingHeader).toContain('baggage');
20+
});
21+
22+
test('Server-Timing header contains valid trace ID format (Cloudflare)', async ({ page }) => {
23+
// Match only the document response for /user/123 (not .data requests)
24+
const responsePromise = page.waitForResponse(
25+
response =>
26+
response.url().endsWith('/user/123') &&
27+
response.status() === 200 &&
28+
response.request().resourceType() === 'document',
29+
);
30+
31+
await page.goto('/user/123');
32+
33+
const response = await responsePromise;
34+
const serverTimingHeader = response.headers()['server-timing'];
35+
36+
expect(serverTimingHeader).toBeDefined();
37+
38+
// Extract sentry-trace value from header
39+
// Format: sentry-trace;desc="traceid-spanid" or sentry-trace;desc="traceid-spanid-sampled"
40+
const sentryTraceMatch = serverTimingHeader.match(/sentry-trace;desc="([^"]+)"/);
41+
expect(sentryTraceMatch).toBeTruthy();
42+
43+
const sentryTraceValue = sentryTraceMatch![1];
44+
45+
// Validate sentry-trace format: traceid-spanid or traceid-spanid-sampled (case insensitive)
46+
// The format is: 32 hex chars, dash, 16 hex chars, optionally followed by dash and 0 or 1
47+
const traceIdMatch = sentryTraceValue.match(/^([a-fA-F0-9]{32})-([a-fA-F0-9]{16})(?:-([01]))?$/);
48+
expect(traceIdMatch).toBeTruthy();
49+
50+
// Verify the trace ID and span ID parts
51+
const [, traceId, spanId] = traceIdMatch!;
52+
expect(traceId).toHaveLength(32);
53+
expect(spanId).toHaveLength(16);
54+
});
55+
56+
test('Server-Timing header is present on parameterized routes (Cloudflare)', async ({ page }) => {
57+
// Match only the document response for /user/456 (not .data requests)
58+
const responsePromise = page.waitForResponse(
59+
response =>
60+
response.url().endsWith('/user/456') &&
61+
response.status() === 200 &&
62+
response.request().resourceType() === 'document',
63+
);
64+
65+
await page.goto('/user/456');
66+
67+
const response = await responsePromise;
68+
const serverTimingHeader = response.headers()['server-timing'];
69+
70+
expect(serverTimingHeader).toBeDefined();
71+
expect(serverTimingHeader).toContain('sentry-trace');
72+
});
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/** @type {import('eslint').Linter.Config} */
2+
module.exports = {
3+
extends: ['@remix-run/eslint-config', '@remix-run/eslint-config/node'],
4+
rules: {
5+
'import/no-unresolved': 'off',
6+
},
7+
};
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: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import * as Sentry from '@sentry/remix';
2+
3+
/**
4+
* By default, Remix will handle generating the HTTP Response for you.
5+
* 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`
6+
* For more information, see https://remix.run/file-conventions/entry.server
7+
*/
8+
9+
import { PassThrough } from 'node:stream';
10+
11+
import type { AppLoadContext, EntryContext } from '@remix-run/node';
12+
import { createReadableStreamFromReadable } from '@remix-run/node';
13+
import { installGlobals } from '@remix-run/node';
14+
import { RemixServer } from '@remix-run/react';
15+
import isbot from 'isbot';
16+
import { renderToPipeableStream } from 'react-dom/server';
17+
18+
installGlobals();
19+
20+
const ABORT_DELAY = 5_000;
21+
22+
const handleErrorImpl = () => {
23+
Sentry.setTag('remix-test-tag', 'remix-test-value');
24+
};
25+
26+
export const handleError = Sentry.wrapHandleErrorWithSentry(handleErrorImpl);
27+
28+
export default function handleRequest(
29+
request: Request,
30+
responseStatusCode: number,
31+
responseHeaders: Headers,
32+
remixContext: EntryContext,
33+
loadContext: AppLoadContext,
34+
) {
35+
return isbot(request.headers.get('user-agent'))
36+
? handleBotRequest(request, responseStatusCode, responseHeaders, remixContext)
37+
: handleBrowserRequest(request, responseStatusCode, responseHeaders, remixContext);
38+
}
39+
40+
function handleBotRequest(
41+
request: Request,
42+
responseStatusCode: number,
43+
responseHeaders: Headers,
44+
remixContext: EntryContext,
45+
) {
46+
return new Promise((resolve, reject) => {
47+
let shellRendered = false;
48+
const { pipe, abort } = renderToPipeableStream(
49+
<RemixServer context={remixContext} url={request.url} abortDelay={ABORT_DELAY} />,
50+
{
51+
onAllReady() {
52+
shellRendered = true;
53+
const body = new PassThrough();
54+
const stream = createReadableStreamFromReadable(body);
55+
56+
responseHeaders.set('Content-Type', 'text/html');
57+
58+
// Server-Timing header is automatically injected by Sentry SDK
59+
// via wrapRequestHandler in instrumentServer.ts
60+
61+
resolve(
62+
new Response(stream, {
63+
headers: responseHeaders,
64+
status: responseStatusCode,
65+
}),
66+
);
67+
68+
pipe(body);
69+
},
70+
onShellError(error: unknown) {
71+
reject(error);
72+
},
73+
onError(error: unknown) {
74+
responseStatusCode = 500;
75+
// Log streaming rendering errors from inside the shell. Don't log
76+
// errors encountered during initial shell rendering since they'll
77+
// reject and get logged in handleDocumentRequest.
78+
if (shellRendered) {
79+
console.error(error);
80+
}
81+
},
82+
},
83+
);
84+
85+
setTimeout(abort, ABORT_DELAY);
86+
});
87+
}
88+
89+
function handleBrowserRequest(
90+
request: Request,
91+
responseStatusCode: number,
92+
responseHeaders: Headers,
93+
remixContext: EntryContext,
94+
) {
95+
return new Promise((resolve, reject) => {
96+
let shellRendered = false;
97+
const { pipe, abort } = renderToPipeableStream(
98+
<RemixServer context={remixContext} url={request.url} abortDelay={ABORT_DELAY} />,
99+
{
100+
onShellReady() {
101+
shellRendered = true;
102+
const body = new PassThrough();
103+
const stream = createReadableStreamFromReadable(body);
104+
105+
responseHeaders.set('Content-Type', 'text/html');
106+
107+
// Server-Timing header is automatically injected by Sentry SDK
108+
// via wrapRequestHandler in instrumentServer.ts
109+
110+
resolve(
111+
new Response(stream, {
112+
headers: responseHeaders,
113+
status: responseStatusCode,
114+
}),
115+
);
116+
117+
pipe(body);
118+
},
119+
onShellError(error: unknown) {
120+
reject(error);
121+
},
122+
onError(error: unknown) {
123+
responseStatusCode = 500;
124+
// Log streaming rendering errors from inside the shell. Don't log
125+
// errors encountered during initial shell rendering since they'll
126+
// reject and get logged in handleDocumentRequest.
127+
if (shellRendered) {
128+
console.error(error);
129+
}
130+
},
131+
},
132+
);
133+
134+
setTimeout(abort, ABORT_DELAY);
135+
});
136+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
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+
// NOTE: We intentionally do NOT use meta tags for trace propagation in this test app
26+
// to verify that Server-Timing header propagation works correctly.
27+
// The trace context is propagated via Server-Timing header instead.
28+
29+
export function ErrorBoundary() {
30+
const error = useRouteError();
31+
const eventId = captureRemixErrorBoundaryError(error);
32+
33+
return (
34+
<div>
35+
<span>ErrorBoundary Error</span>
36+
<span id="event-id">{eventId}</span>
37+
</div>
38+
);
39+
}
40+
41+
function App() {
42+
const { ENV } = useLoaderData() as { ENV: { SENTRY_DSN: string } };
43+
44+
return (
45+
<html lang="en">
46+
<head>
47+
<meta charSet="utf-8" />
48+
<meta name="viewport" content="width=device-width,initial-scale=1" />
49+
<script
50+
dangerouslySetInnerHTML={{
51+
__html: `window.ENV = ${JSON.stringify(ENV)}`,
52+
}}
53+
/>
54+
<Meta />
55+
<Links />
56+
</head>
57+
<body>
58+
<Outlet />
59+
<ScrollRestoration />
60+
<Scripts />
61+
<LiveReload />
62+
</body>
63+
</html>
64+
);
65+
}
66+
67+
export default withSentry(App);

0 commit comments

Comments
 (0)