diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/entry.server.tsx b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/entry.server.tsx
index afae990db239..1db0c9e23207 100644
--- a/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/entry.server.tsx
+++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/entry.server.tsx
@@ -1,4 +1,5 @@
import { RemixServer } from '@remix-run/react';
+import { generateSentryServerTimingHeader } from '@sentry/remix/cloudflare';
import { createContentSecurityPolicy } from '@shopify/hydrogen';
import type { EntryContext } from '@shopify/remix-oxygen';
import isbot from 'isbot';
@@ -43,8 +44,15 @@ export default async function handleRequest(
// This is required for Sentry's profiling integration
responseHeaders.set('Document-Policy', 'js-profiling');
- return new Response(body, {
+ const response = new Response(body, {
headers: responseHeaders,
status: responseStatusCode,
});
+
+ const serverTimingValue = generateSentryServerTimingHeader();
+ if (serverTimingValue) {
+ response.headers.append('Server-Timing', serverTimingValue);
+ }
+
+ return response;
}
diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/tests/server-timing-header.test.ts b/dev-packages/e2e-tests/test-applications/remix-hydrogen/tests/server-timing-header.test.ts
new file mode 100644
index 000000000000..194afa2fa0a4
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/tests/server-timing-header.test.ts
@@ -0,0 +1,17 @@
+import { expect, test } from '@playwright/test';
+
+test('Server-Timing header contains sentry-trace on page load', async ({ page }) => {
+ const responsePromise = page.waitForResponse(
+ response =>
+ response.url().endsWith('/') && response.status() === 200 && response.request().resourceType() === 'document',
+ );
+
+ await page.goto('/');
+
+ const response = await responsePromise;
+ const serverTimingHeader = response.headers()['server-timing'];
+
+ expect(serverTimingHeader).toBeDefined();
+ expect(serverTimingHeader).toContain('sentry-trace');
+ expect(serverTimingHeader).toContain('baggage');
+});
diff --git a/dev-packages/e2e-tests/test-applications/remix-server-timing/.eslintrc.js b/dev-packages/e2e-tests/test-applications/remix-server-timing/.eslintrc.js
new file mode 100644
index 000000000000..f2faf1470fd8
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/.eslintrc.js
@@ -0,0 +1,4 @@
+/** @type {import('eslint').Linter.Config} */
+module.exports = {
+ extends: ['@remix-run/eslint-config', '@remix-run/eslint-config/node'],
+};
diff --git a/dev-packages/e2e-tests/test-applications/remix-server-timing/.gitignore b/dev-packages/e2e-tests/test-applications/remix-server-timing/.gitignore
new file mode 100644
index 000000000000..a735ebed5b56
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/.gitignore
@@ -0,0 +1,3 @@
+node_modules
+build
+.env
diff --git a/dev-packages/e2e-tests/test-applications/remix-server-timing/.npmrc b/dev-packages/e2e-tests/test-applications/remix-server-timing/.npmrc
new file mode 100644
index 000000000000..070f80f05092
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/.npmrc
@@ -0,0 +1,2 @@
+@sentry:registry=http://127.0.0.1:4873
+@sentry-internal:registry=http://127.0.0.1:4873
diff --git a/dev-packages/e2e-tests/test-applications/remix-server-timing/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/remix-server-timing/app/entry.client.tsx
new file mode 100644
index 000000000000..85c29d310c1a
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/app/entry.client.tsx
@@ -0,0 +1,44 @@
+/**
+ * By default, Remix will handle hydrating your app on the client for you.
+ * 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`
+ * For more information, see https://remix.run/file-conventions/entry.client
+ */
+
+// Extend the Window interface to include ENV
+declare global {
+ interface Window {
+ ENV: {
+ SENTRY_DSN: string;
+ [key: string]: unknown;
+ };
+ }
+}
+
+import { RemixBrowser, useLocation, useMatches } from '@remix-run/react';
+import * as Sentry from '@sentry/remix';
+import { StrictMode, startTransition, useEffect } from 'react';
+import { hydrateRoot } from 'react-dom/client';
+
+Sentry.init({
+ environment: 'qa', // dynamic sampling bias to keep transactions
+ dsn: window.ENV.SENTRY_DSN,
+ integrations: [
+ Sentry.browserTracingIntegration({
+ useEffect,
+ useLocation,
+ useMatches,
+ }),
+ ],
+ // Performance Monitoring
+ tracesSampleRate: 1.0, // Capture 100% of the transactions
+ tunnel: 'http://localhost:3031/', // proxy server
+});
+
+startTransition(() => {
+ hydrateRoot(
+ document,
+
+
+ ,
+ );
+});
diff --git a/dev-packages/e2e-tests/test-applications/remix-server-timing/app/entry.server.tsx b/dev-packages/e2e-tests/test-applications/remix-server-timing/app/entry.server.tsx
new file mode 100644
index 000000000000..3eb9423dff22
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/app/entry.server.tsx
@@ -0,0 +1,115 @@
+import * as Sentry from '@sentry/remix';
+
+import { PassThrough } from 'node:stream';
+
+import type { AppLoadContext, EntryContext } from '@remix-run/node';
+import { createReadableStreamFromReadable } from '@remix-run/node';
+import { installGlobals } from '@remix-run/node';
+import { RemixServer } from '@remix-run/react';
+import isbot from 'isbot';
+import { renderToPipeableStream } from 'react-dom/server';
+
+installGlobals();
+
+const ABORT_DELAY = 5_000;
+
+export const handleError = Sentry.sentryHandleError;
+
+export default function handleRequest(
+ request: Request,
+ responseStatusCode: number,
+ responseHeaders: Headers,
+ remixContext: EntryContext,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ loadContext: AppLoadContext,
+) {
+ return isbot(request.headers.get('user-agent'))
+ ? handleBotRequest(request, responseStatusCode, responseHeaders, remixContext)
+ : handleBrowserRequest(request, responseStatusCode, responseHeaders, remixContext);
+}
+
+function handleBotRequest(
+ request: Request,
+ responseStatusCode: number,
+ responseHeaders: Headers,
+ remixContext: EntryContext,
+) {
+ return new Promise((resolve, reject) => {
+ let shellRendered = false;
+ const { pipe, abort } = renderToPipeableStream(
+ ,
+ {
+ onAllReady() {
+ 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;
+ if (shellRendered) {
+ console.error(error);
+ }
+ },
+ },
+ );
+
+ setTimeout(abort, ABORT_DELAY);
+ });
+}
+
+function handleBrowserRequest(
+ request: Request,
+ responseStatusCode: number,
+ responseHeaders: Headers,
+ remixContext: EntryContext,
+) {
+ return new Promise((resolve, reject) => {
+ let shellRendered = false;
+ const { pipe, abort } = renderToPipeableStream(
+ ,
+ {
+ onShellReady() {
+ 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;
+ if (shellRendered) {
+ console.error(error);
+ }
+ },
+ },
+ );
+
+ setTimeout(abort, ABORT_DELAY);
+ });
+}
diff --git a/dev-packages/e2e-tests/test-applications/remix-server-timing/app/root.tsx b/dev-packages/e2e-tests/test-applications/remix-server-timing/app/root.tsx
new file mode 100644
index 000000000000..beb9fdb70357
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/app/root.tsx
@@ -0,0 +1,63 @@
+import { cssBundleHref } from '@remix-run/css-bundle';
+import { LinksFunction, json } from '@remix-run/node';
+import {
+ Links,
+ LiveReload,
+ Meta,
+ Outlet,
+ Scripts,
+ ScrollRestoration,
+ useLoaderData,
+ useRouteError,
+} from '@remix-run/react';
+import { captureRemixErrorBoundaryError, withSentry } from '@sentry/remix';
+
+export const links: LinksFunction = () => [...(cssBundleHref ? [{ rel: 'stylesheet', href: cssBundleHref }] : [])];
+
+export const loader = () => {
+ return json({
+ ENV: {
+ SENTRY_DSN: process.env.E2E_TEST_DSN,
+ },
+ });
+};
+
+export function ErrorBoundary() {
+ const error = useRouteError();
+ const eventId = captureRemixErrorBoundaryError(error);
+
+ return (
+
+ ErrorBoundary Error
+ {eventId}
+
+ );
+}
+
+function App() {
+ const { ENV } = useLoaderData() as { ENV: { SENTRY_DSN: string } };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default withSentry(App);
diff --git a/dev-packages/e2e-tests/test-applications/remix-server-timing/app/routes/_index.tsx b/dev-packages/e2e-tests/test-applications/remix-server-timing/app/routes/_index.tsx
new file mode 100644
index 000000000000..89449786a33e
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/app/routes/_index.tsx
@@ -0,0 +1,28 @@
+import { json, LoaderFunctionArgs } from '@remix-run/node';
+import { Link, useSearchParams } from '@remix-run/react';
+import * as Sentry from '@sentry/remix';
+
+export const loader = async ({ request }: LoaderFunctionArgs) => {
+ return json({});
+};
+
+export default function Index() {
+ const [searchParams] = useSearchParams();
+
+ if (searchParams.get('tag')) {
+ Sentry.setTag('sentry_test', searchParams.get('tag'));
+ }
+
+ return (
+
+
Server-Timing Trace Propagation Test
+
+ -
+
+ Navigate to User 123
+
+
+
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/remix-server-timing/app/routes/redirect-test.tsx b/dev-packages/e2e-tests/test-applications/remix-server-timing/app/routes/redirect-test.tsx
new file mode 100644
index 000000000000..f41020cf3bca
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/app/routes/redirect-test.tsx
@@ -0,0 +1,5 @@
+import { redirect } from '@remix-run/node';
+
+export const loader = async () => {
+ return redirect('/user/redirected');
+};
diff --git a/dev-packages/e2e-tests/test-applications/remix-server-timing/app/routes/user.$id.tsx b/dev-packages/e2e-tests/test-applications/remix-server-timing/app/routes/user.$id.tsx
new file mode 100644
index 000000000000..a4ce451adf36
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/app/routes/user.$id.tsx
@@ -0,0 +1,19 @@
+import { json, LoaderFunctionArgs } from '@remix-run/node';
+import { Link, useLoaderData } from '@remix-run/react';
+
+export const loader = async ({ params }: LoaderFunctionArgs) => {
+ await new Promise(resolve => setTimeout(resolve, 10));
+ return json({ userId: params.id });
+};
+
+export default function User() {
+ const { userId } = useLoaderData();
+
+ return (
+
+
User {userId}
+
This is a parameterized route for user {userId}.
+
Back to Home
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/remix-server-timing/globals.d.ts b/dev-packages/e2e-tests/test-applications/remix-server-timing/globals.d.ts
new file mode 100644
index 000000000000..78ed2345c6e4
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/globals.d.ts
@@ -0,0 +1,2 @@
+///
+///
diff --git a/dev-packages/e2e-tests/test-applications/remix-server-timing/instrument.server.cjs b/dev-packages/e2e-tests/test-applications/remix-server-timing/instrument.server.cjs
new file mode 100644
index 000000000000..6d211cac4592
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/instrument.server.cjs
@@ -0,0 +1,8 @@
+const Sentry = require('@sentry/remix');
+
+Sentry.init({
+ tracesSampleRate: 1.0, // Capture 100% of the transactions, reduce in production!
+ environment: 'qa', // dynamic sampling bias to keep transactions
+ dsn: process.env.E2E_TEST_DSN,
+ tunnel: 'http://localhost:3031/', // proxy server
+});
diff --git a/dev-packages/e2e-tests/test-applications/remix-server-timing/package.json b/dev-packages/e2e-tests/test-applications/remix-server-timing/package.json
new file mode 100644
index 000000000000..d31e86ff0cdc
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/package.json
@@ -0,0 +1,42 @@
+{
+ "private": true,
+ "sideEffects": false,
+ "scripts": {
+ "build": "remix vite:build && pnpm typecheck",
+ "dev": "remix vite:dev",
+ "start": "NODE_OPTIONS='--require=./instrument.server.cjs' remix-serve build/server/index.js",
+ "typecheck": "tsc",
+ "clean": "npx rimraf node_modules pnpm-lock.yaml",
+ "test:build": "pnpm install && pnpm build",
+ "test:assert": "pnpm playwright test"
+ },
+ "dependencies": {
+ "@sentry/remix": "latest || *",
+ "@remix-run/css-bundle": "2.17.4",
+ "@remix-run/node": "2.17.4",
+ "@remix-run/react": "2.17.4",
+ "@remix-run/serve": "2.17.4",
+ "isbot": "^3.6.8",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0"
+ },
+ "devDependencies": {
+ "@playwright/test": "~1.56.0",
+ "@sentry-internal/test-utils": "link:../../../test-utils",
+ "@remix-run/dev": "2.17.4",
+ "@remix-run/eslint-config": "2.17.4",
+ "@types/react": "^18.2.64",
+ "@types/react-dom": "^18.2.34",
+ "@types/prop-types": "15.7.7",
+ "eslint": "^8.38.0",
+ "typescript": "^5.1.6",
+ "vite": "^5.4.11",
+ "vite-tsconfig-paths": "^4.2.1"
+ },
+ "resolutions": {
+ "@types/react": "18.2.22"
+ },
+ "volta": {
+ "extends": "../../package.json"
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/remix-server-timing/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/remix-server-timing/playwright.config.mjs
new file mode 100644
index 000000000000..b52ff06a5105
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/playwright.config.mjs
@@ -0,0 +1,8 @@
+import { getPlaywrightConfig } from '@sentry-internal/test-utils';
+
+const config = getPlaywrightConfig({
+ startCommand: 'pnpm start',
+ port: 3030,
+});
+
+export default config;
diff --git a/dev-packages/e2e-tests/test-applications/remix-server-timing/remix.env.d.ts b/dev-packages/e2e-tests/test-applications/remix-server-timing/remix.env.d.ts
new file mode 100644
index 000000000000..dcf8c45e1d4c
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/remix.env.d.ts
@@ -0,0 +1,2 @@
+///
+///
diff --git a/dev-packages/e2e-tests/test-applications/remix-server-timing/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/remix-server-timing/start-event-proxy.mjs
new file mode 100644
index 000000000000..2fbbf5087be1
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/start-event-proxy.mjs
@@ -0,0 +1,6 @@
+import { startEventProxyServer } from '@sentry-internal/test-utils';
+
+startEventProxyServer({
+ port: 3031,
+ proxyServerName: 'remix-server-timing',
+});
diff --git a/dev-packages/e2e-tests/test-applications/remix-server-timing/tests/server-timing-trace-propagation.test.ts b/dev-packages/e2e-tests/test-applications/remix-server-timing/tests/server-timing-trace-propagation.test.ts
new file mode 100644
index 000000000000..38b53d2bcc90
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/tests/server-timing-trace-propagation.test.ts
@@ -0,0 +1,103 @@
+import { expect, test } from '@playwright/test';
+import { waitForTransaction } from '@sentry-internal/test-utils';
+
+test('propagates trace context from server-timing header to client pageload', async ({ page }) => {
+ const testTag = crypto.randomUUID();
+
+ const responsePromise = page.waitForResponse(
+ response => response.url().includes(`tag=${testTag}`) && response.status() === 200,
+ );
+
+ const pageLoadTransactionPromise = waitForTransaction('remix-server-timing', transactionEvent => {
+ return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.tags?.['sentry_test'] === testTag;
+ });
+
+ const httpServerTransactionPromise = waitForTransaction('remix-server-timing', transactionEvent => {
+ return transactionEvent.contexts?.trace?.op === 'http.server';
+ });
+
+ await page.goto(`/?tag=${testTag}`);
+
+ const response = await responsePromise;
+ const serverTimingHeader = response.headers()['server-timing'];
+
+ expect(serverTimingHeader).toBeDefined();
+ expect(serverTimingHeader).toContain('sentry-trace');
+ expect(serverTimingHeader).toContain('baggage');
+
+ const sentryTraceMatch = serverTimingHeader?.match(/sentry-trace;desc="([^"]+)"/);
+ expect(sentryTraceMatch).toBeTruthy();
+ const [headerTraceId, headerSpanId, headerSampled] = sentryTraceMatch?.[1]?.split('-') || [];
+
+ expect(headerTraceId).toHaveLength(32);
+ expect(headerSpanId).toHaveLength(16);
+ expect(headerSampled).toBe('1');
+
+ const pageloadTransaction = await pageLoadTransactionPromise;
+ const httpServerTransaction = await httpServerTransactionPromise;
+
+ expect(pageloadTransaction).toBeDefined();
+ expect(pageloadTransaction.transaction).toBe('/');
+
+ expect(httpServerTransaction.transaction).toMatch(/^GET http:\/\/localhost:\d+\/$/);
+
+ expect(pageloadTransaction.contexts?.trace?.trace_id).toEqual(headerTraceId);
+ expect(pageloadTransaction.contexts?.trace?.parent_span_id).toEqual(headerSpanId);
+
+ expect(httpServerTransaction.contexts?.trace?.trace_id).toEqual(headerTraceId);
+ expect(httpServerTransaction.contexts?.trace?.span_id).toEqual(headerSpanId);
+});
+
+test('includes server-timing header on redirect responses', async ({ page }) => {
+ const redirectResponsePromise = page.waitForResponse(response => response.url().includes('/redirect-test'));
+ const redirectedPageloadResponsePromise = page.waitForResponse(response =>
+ response.url().includes('/user/redirected'),
+ );
+
+ const pageLoadTransactionPromise = waitForTransaction('remix-server-timing', transactionEvent => {
+ return transactionEvent.contexts?.trace?.op === 'pageload';
+ });
+
+ await page.goto('/redirect-test');
+
+ const redirectResponse = await redirectResponsePromise;
+ const redirectServerTimingHeader = redirectResponse.headers()['server-timing'];
+
+ expect(redirectServerTimingHeader).toBeDefined();
+ expect(redirectServerTimingHeader).toContain('sentry-trace');
+ expect(redirectServerTimingHeader).toContain('baggage');
+
+ const redirectSentryTraceMatch = redirectServerTimingHeader?.match(/sentry-trace;desc="([^"]+)"/);
+ expect(redirectSentryTraceMatch).toBeTruthy();
+ expect(redirectSentryTraceMatch![1]).toMatch(/[a-f0-9]{32}-[a-f0-9]{16}-1/);
+
+ const redirectedPageloadResponse = await redirectedPageloadResponsePromise;
+
+ const serverTimingHeader = redirectedPageloadResponse.headers()['server-timing'];
+ const sentryTraceMatch = serverTimingHeader?.match(/sentry-trace;desc="([^"]+)"/);
+ expect(sentryTraceMatch).toBeTruthy();
+ const [traceId, spanId] = sentryTraceMatch![1].split('-');
+ expect(traceId).toHaveLength(32);
+ expect(spanId).toHaveLength(16);
+
+ await page.waitForURL(/\/user\/redirected/);
+ await expect(page.locator('h1')).toContainText('User redirected');
+
+ const pageLoadTransaction = await pageLoadTransactionPromise;
+ expect(pageLoadTransaction.transaction).toBe('/user/:id');
+ expect(pageLoadTransaction.contexts?.trace?.trace_id).toEqual(traceId);
+ expect(pageLoadTransaction.contexts?.trace?.parent_span_id).toEqual(spanId);
+});
+
+test('excludes server-timing header from client-side navigation data fetches', async ({ page }) => {
+ await page.goto('/');
+ await page.locator('#navigation').waitFor({ state: 'visible' });
+
+ const navDataFetchPromise = page.waitForResponse(
+ response =>
+ response.url().includes('/user/123') && (response.url().includes('_data=') || response.url().endsWith('.data')),
+ );
+ await page.click('#navigation');
+ const navDataFetch = await navDataFetchPromise;
+ expect(navDataFetch.headers()['server-timing']).toBeUndefined();
+});
diff --git a/dev-packages/e2e-tests/test-applications/remix-server-timing/tsconfig.json b/dev-packages/e2e-tests/test-applications/remix-server-timing/tsconfig.json
new file mode 100644
index 000000000000..91f6b263f2c7
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/tsconfig.json
@@ -0,0 +1,23 @@
+{
+ "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx", "globals.d.ts"],
+ "compilerOptions": {
+ "lib": ["DOM", "DOM.Iterable", "ES2022"],
+ "types": ["@remix-run/node", "vite/client"],
+ "isolatedModules": true,
+ "esModuleInterop": true,
+ "jsx": "react-jsx",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "resolveJsonModule": true,
+ "target": "ES2022",
+ "strict": true,
+ "allowJs": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "baseUrl": ".",
+ "paths": {
+ "~/*": ["./app/*"]
+ },
+ "noEmit": true
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/remix-server-timing/vite.config.ts b/dev-packages/e2e-tests/test-applications/remix-server-timing/vite.config.ts
new file mode 100644
index 000000000000..d4d7f23895c1
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/remix-server-timing/vite.config.ts
@@ -0,0 +1,15 @@
+import { vitePlugin as remix } from '@remix-run/dev';
+import { sentryRemixVitePlugin } from '@sentry/remix';
+import { defineConfig } from 'vite';
+import tsconfigPaths from 'vite-tsconfig-paths';
+
+export default defineConfig({
+ plugins: [
+ remix({
+ ignoredRouteFiles: ['**/.*'],
+ serverModuleFormat: 'cjs',
+ }),
+ sentryRemixVitePlugin(),
+ tsconfigPaths(),
+ ],
+});
diff --git a/packages/remix/src/cloudflare/index.ts b/packages/remix/src/cloudflare/index.ts
index 9b78855ae2d3..ede127a694ce 100644
--- a/packages/remix/src/cloudflare/index.ts
+++ b/packages/remix/src/cloudflare/index.ts
@@ -13,6 +13,7 @@ export { captureRemixErrorBoundaryError } from '../client/errors';
export { withSentry } from '../client/performance';
export { ErrorBoundary, browserTracingIntegration } from '../client';
export { makeWrappedCreateRequestHandler, sentryHandleError };
+export { generateSentryServerTimingHeader } from '../server/serverTimingTracePropagation';
/**
* Instruments a Remix build to capture errors and performance data.
diff --git a/packages/remix/src/server/index.ts b/packages/remix/src/server/index.ts
index 1533a1ca7221..cafec61ac9af 100644
--- a/packages/remix/src/server/index.ts
+++ b/packages/remix/src/server/index.ts
@@ -139,3 +139,4 @@ export * from '@sentry/node';
export { init, getRemixDefaultIntegrations } from './sdk';
export { captureRemixServerException } from './errors';
export { sentryHandleError, wrapHandleErrorWithSentry, instrumentBuild } from './instrumentServer';
+export { generateSentryServerTimingHeader } from './serverTimingTracePropagation';
diff --git a/packages/remix/src/server/instrumentServer.ts b/packages/remix/src/server/instrumentServer.ts
index d8864d254a99..f4b19926f802 100644
--- a/packages/remix/src/server/instrumentServer.ts
+++ b/packages/remix/src/server/instrumentServer.ts
@@ -36,9 +36,10 @@ import {
withIsolationScope,
} from '@sentry/core';
import { DEBUG_BUILD } from '../utils/debug-build';
-import { createRoutes, getTransactionName } from '../utils/utils';
+import { createRoutes, getTransactionName, isCloudflareEnv } from '../utils/utils';
import { extractData, isResponse, json } from '../utils/vendor/response';
import { captureRemixServerException, errorHandleDataFunction } from './errors';
+import { generateSentryServerTimingHeader, injectServerTimingHeaderValue } from './serverTimingTracePropagation';
type AppData = unknown;
type RemixRequest = Parameters[0];
@@ -95,11 +96,6 @@ export function wrapHandleErrorWithSentry(
};
}
-function isCloudflareEnv(): boolean {
- // eslint-disable-next-line no-restricted-globals
- return navigator?.userAgent?.includes('Cloudflare');
-}
-
function getTraceAndBaggage(): {
sentryTrace?: string;
sentryBaggage?: string;
@@ -119,13 +115,16 @@ function getTraceAndBaggage(): {
function makeWrappedDocumentRequestFunction(instrumentTracing?: boolean) {
return function (origDocumentRequestFunction: HandleDocumentRequestFunction): HandleDocumentRequestFunction {
return async function (this: unknown, request: Request, ...args: unknown[]): Promise {
+ const serverTimingHeader = generateSentryServerTimingHeader();
+
+ let response: Response;
+
if (instrumentTracing) {
const activeSpan = getActiveSpan();
const rootSpan = activeSpan && getRootSpan(activeSpan);
-
const name = rootSpan ? spanToJSON(rootSpan).description : undefined;
- return startSpan(
+ response = await startSpan(
{
// If we don't have a root span, `onlyIfParent` will lead to the span not being created anyhow
// So we don't need to care too much about the fallback name, it's just for typing purposes....
@@ -143,8 +142,14 @@ function makeWrappedDocumentRequestFunction(instrumentTracing?: boolean) {
},
);
} else {
- return origDocumentRequestFunction.call(this, request, ...args);
+ response = await origDocumentRequestFunction.call(this, request, ...args);
+ }
+
+ if (serverTimingHeader && response instanceof Response) {
+ return injectServerTimingHeaderValue(response, serverTimingHeader);
}
+
+ return response;
};
};
}
@@ -186,13 +191,15 @@ function makeWrappedDataFunction(
build?: ServerBuild,
): DataFunction {
return async function (this: unknown, args: DataFunctionArgs): Promise {
+ let res: Response | AppData;
+
if (instrumentTracing) {
// Update span name for Cloudflare Workers/Hydrogen environments
if (build) {
updateSpanWithRoute(args, build);
}
- return startSpan(
+ res = await startSpan(
{
op: `function.remix.${name}`,
name: id,
@@ -207,8 +214,18 @@ function makeWrappedDataFunction(
},
);
} else {
- return errorHandleDataFunction.call(this, origFn, name, args);
+ res = await errorHandleDataFunction.call(this, origFn, name, args);
}
+
+ // Redirects bypass makeWrappedDocumentRequestFunction, so we inject Server-Timing here.
+ if (isResponse(res) && isRedirectResponse(res)) {
+ const serverTimingHeader = generateSentryServerTimingHeader();
+ if (serverTimingHeader) {
+ return injectServerTimingHeaderValue(res, serverTimingHeader);
+ }
+ }
+
+ return res;
};
}
diff --git a/packages/remix/src/server/serverTimingTracePropagation.ts b/packages/remix/src/server/serverTimingTracePropagation.ts
new file mode 100644
index 000000000000..fd8440f3578d
--- /dev/null
+++ b/packages/remix/src/server/serverTimingTracePropagation.ts
@@ -0,0 +1,58 @@
+import type { Span } from '@sentry/core';
+import { debug, getTraceData, isNodeEnv } from '@sentry/core';
+import { DEBUG_BUILD } from '../utils/debug-build';
+import { isCloudflareEnv } from '../utils/utils';
+
+/** Generate a Server-Timing header value containing Sentry trace context. */
+export function generateSentryServerTimingHeader(): string | null {
+ if (!isNodeEnv() && !isCloudflareEnv()) {
+ return null;
+ }
+
+ try {
+ const traceData = getTraceData();
+ const sentryTrace = traceData['sentry-trace'];
+ const baggage = traceData.baggage;
+
+ if (!sentryTrace) {
+ return null;
+ }
+
+ const parts: string[] = [];
+
+ parts.push(`sentry-trace;desc="${sentryTrace}"`);
+
+ if (baggage) {
+ parts.push(`baggage;desc="${baggage}"`);
+ }
+
+ return parts.join(', ');
+ } catch (e) {
+ DEBUG_BUILD && debug.warn('Failed to generate Server-Timing header', e);
+ return null;
+ }
+}
+
+/** @internal */
+export function injectServerTimingHeaderValue(response: Response, serverTimingValue: string): Response {
+ try {
+ const headers = new Headers(response.headers);
+ const existing = headers.get('Server-Timing');
+
+ // Avoid duplicate entries when manually injected in entry.server.tsx
+ if (existing?.includes('sentry-trace')) {
+ return response;
+ }
+
+ headers.set('Server-Timing', existing ? `${existing}, ${serverTimingValue}` : serverTimingValue);
+
+ return new Response(response.body, {
+ status: response.status,
+ statusText: response.statusText,
+ headers,
+ });
+ } catch (e) {
+ DEBUG_BUILD && debug.warn('Failed to add Server-Timing header to response', e);
+ return response;
+ }
+}
diff --git a/packages/remix/src/utils/utils.ts b/packages/remix/src/utils/utils.ts
index c179bc43f61f..2e9fa21687bc 100644
--- a/packages/remix/src/utils/utils.ts
+++ b/packages/remix/src/utils/utils.ts
@@ -109,6 +109,12 @@ export function convertRemixRouteIdToPath(routeId: string): string {
return routePath;
}
+/** Check if running in Cloudflare Workers environment. */
+export function isCloudflareEnv(): boolean {
+ // eslint-disable-next-line no-restricted-globals
+ return typeof navigator !== 'undefined' && navigator?.userAgent?.includes('Cloudflare');
+}
+
/**
* Get transaction name from routes and url
*/
diff --git a/packages/remix/test/server/serverTimingTracePropagation.test.ts b/packages/remix/test/server/serverTimingTracePropagation.test.ts
new file mode 100644
index 000000000000..7e9852e97c6b
--- /dev/null
+++ b/packages/remix/test/server/serverTimingTracePropagation.test.ts
@@ -0,0 +1,143 @@
+import { getActiveSpan, getTraceData, isNodeEnv, spanToBaggageHeader, spanToTraceHeader } from '@sentry/core';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import {
+ generateSentryServerTimingHeader,
+ injectServerTimingHeaderValue,
+} from '../../src/server/serverTimingTracePropagation';
+
+const mockSpan = {
+ spanId: 'test-span-id',
+ spanContext: () => ({ traceId: '12345678901234567890123456789012' }),
+};
+const mockRootSpan = {
+ spanId: 'root-span-id',
+ spanContext: () => ({ traceId: '12345678901234567890123456789012' }),
+};
+
+vi.mock('@sentry/core', () => ({
+ debug: {
+ log: vi.fn(),
+ warn: vi.fn(),
+ },
+ getActiveSpan: vi.fn(),
+ getRootSpan: vi.fn(() => mockRootSpan),
+ getTraceData: vi.fn(() => ({
+ 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1',
+ baggage: 'sentry-environment=production,sentry-release=1.0.0',
+ })),
+ spanToTraceHeader: vi.fn(() => '12345678901234567890123456789012-1234567890123456-1'),
+ spanToBaggageHeader: vi.fn(() => 'sentry-environment=production,sentry-release=1.0.0'),
+ isNodeEnv: vi.fn(() => true),
+}));
+
+describe('serverTimingTracePropagation', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(isNodeEnv).mockReturnValue(true);
+ vi.mocked(getActiveSpan).mockReturnValue(mockSpan);
+ vi.mocked(getTraceData).mockReturnValue({
+ 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1',
+ baggage: 'sentry-environment=production,sentry-release=1.0.0',
+ });
+ });
+
+ describe('generateSentryServerTimingHeader', () => {
+ it('returns null in browser environments', () => {
+ vi.mocked(isNodeEnv).mockReturnValueOnce(false);
+
+ expect(generateSentryServerTimingHeader()).toBeNull();
+ });
+
+ it('returns null without trace data', () => {
+ vi.mocked(getActiveSpan).mockReturnValueOnce(undefined);
+ vi.mocked(getTraceData).mockReturnValueOnce({});
+
+ expect(generateSentryServerTimingHeader()).toBeNull();
+ });
+
+ it('produces correct Server-Timing format', () => {
+ const result = generateSentryServerTimingHeader();
+
+ expect(result).toBe(
+ 'sentry-trace;desc="12345678901234567890123456789012-1234567890123456-1", baggage;desc="sentry-environment=production,sentry-release=1.0.0"',
+ );
+ });
+
+ it('falls back to getTraceData without active span', () => {
+ vi.mocked(getActiveSpan).mockReturnValueOnce(undefined);
+ vi.mocked(getTraceData).mockReturnValueOnce({
+ 'sentry-trace': 'fallback-trace-id-1234567890123456-0',
+ baggage: 'sentry-fallback=true',
+ });
+
+ const result = generateSentryServerTimingHeader();
+
+ expect(result).toContain('sentry-trace;desc="fallback-trace-id-1234567890123456-0"');
+ expect(result).toContain('sentry-fallback=true');
+ });
+
+ it('generates header in Cloudflare environment when isNodeEnv is false', () => {
+ vi.mocked(isNodeEnv).mockReturnValueOnce(false);
+
+ const originalNavigator = globalThis.navigator;
+ Object.defineProperty(globalThis, 'navigator', {
+ value: { userAgent: 'Cloudflare' },
+ configurable: true,
+ });
+
+ const result = generateSentryServerTimingHeader();
+ expect(result).not.toBeNull();
+
+ // Restore
+ Object.defineProperty(globalThis, 'navigator', {
+ value: originalNavigator,
+ configurable: true,
+ });
+ });
+ });
+
+ describe('injectServerTimingHeaderValue', () => {
+ it('adds Server-Timing header to response', () => {
+ const mockResponse = new Response('test body', {
+ status: 200,
+ statusText: 'OK',
+ headers: new Headers(),
+ });
+
+ const result = injectServerTimingHeaderValue(mockResponse, 'sentry-trace;desc="test"');
+
+ expect(result.headers.get('Server-Timing')).toBe('sentry-trace;desc="test"');
+ expect(result.status).toBe(200);
+ expect(result.statusText).toBe('OK');
+ });
+
+ it('merges with existing Server-Timing header', () => {
+ const mockResponse = new Response('test body', {
+ status: 200,
+ headers: new Headers({ 'Server-Timing': 'cache;dur=100' }),
+ });
+
+ const result = injectServerTimingHeaderValue(mockResponse, 'sentry-trace;desc="test"');
+
+ expect(result.headers.get('Server-Timing')).toBe('cache;dur=100, sentry-trace;desc="test"');
+ });
+
+ it('skips injection when sentry-trace already exists in Server-Timing header', () => {
+ const mockResponse = new Response('test body', {
+ status: 200,
+ headers: new Headers({
+ 'Server-Timing': 'sentry-trace;desc="existing-trace", baggage;desc="existing-baggage"',
+ }),
+ });
+
+ const result = injectServerTimingHeaderValue(
+ mockResponse,
+ 'sentry-trace;desc="new-trace", baggage;desc="new-baggage"',
+ );
+
+ expect(result.headers.get('Server-Timing')).toBe(
+ 'sentry-trace;desc="existing-trace", baggage;desc="existing-baggage"',
+ );
+ });
+ });
+});