Skip to content

Commit 4d1a53e

Browse files
committed
feat(remix): Add Server-Timing header trace propagation
1 parent c2c2d43 commit 4d1a53e

26 files changed

+739
-14
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)