diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/.npmrc b/dev-packages/e2e-tests/test-applications/nitro-3/.npmrc
new file mode 100644
index 000000000000..070f80f05092
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nitro-3/.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/nitro-3/index.html b/dev-packages/e2e-tests/test-applications/nitro-3/index.html
new file mode 100644
index 000000000000..4e9315ac391e
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nitro-3/index.html
@@ -0,0 +1,11 @@
+
+
+
+
+ Nitro E2E Test
+
+
+ Nitro E2E Test App
+
+
+
diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/instrument.mjs b/dev-packages/e2e-tests/test-applications/nitro-3/instrument.mjs
new file mode 100644
index 000000000000..53b80d309a5b
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nitro-3/instrument.mjs
@@ -0,0 +1,8 @@
+import * as Sentry from '@sentry/nitro';
+
+Sentry.init({
+ environment: 'qa', // dynamic sampling bias to keep transactions
+ dsn: process.env.E2E_TEST_DSN,
+ tunnel: `http://localhost:3031/`, // proxy server
+ tracesSampleRate: 1,
+});
diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/package.json b/dev-packages/e2e-tests/test-applications/nitro-3/package.json
new file mode 100644
index 000000000000..cc137fb81458
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nitro-3/package.json
@@ -0,0 +1,29 @@
+{
+ "name": "nitro-3",
+ "version": "1.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "build": "vite build",
+ "start": "PORT=3030 NODE_OPTIONS='--import ./instrument.mjs' node .output/server/index.mjs",
+ "clean": "npx rimraf node_modules pnpm-lock.yaml .output",
+ "test": "playwright test",
+ "test:build": "pnpm install && pnpm build",
+ "test:assert": "pnpm test"
+ },
+ "dependencies": {
+ "@sentry/browser": "latest || *",
+ "@sentry/nitro": "latest || *"
+ },
+ "devDependencies": {
+ "@playwright/test": "~1.56.0",
+ "@sentry-internal/test-utils": "link:../../../test-utils",
+ "@sentry/core": "latest || *",
+ "nitro": "https://pkg.pr.new/nitrojs/nitro@4001",
+ "rolldown": "latest",
+ "vite": "latest"
+ },
+ "volta": {
+ "extends": "../../package.json"
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nitro-3/playwright.config.mjs
new file mode 100644
index 000000000000..31f2b913b58b
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nitro-3/playwright.config.mjs
@@ -0,0 +1,7 @@
+import { getPlaywrightConfig } from '@sentry-internal/test-utils';
+
+const config = getPlaywrightConfig({
+ startCommand: `pnpm start`,
+});
+
+export default config;
diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/server/api/index.ts b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/index.ts
new file mode 100644
index 000000000000..a9fca21eecfb
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/index.ts
@@ -0,0 +1,5 @@
+import { defineHandler } from 'nitro/h3';
+
+export default defineHandler(() => {
+ return { status: 'ok' };
+});
diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-error.ts b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-error.ts
new file mode 100644
index 000000000000..170efb1977ab
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-error.ts
@@ -0,0 +1,5 @@
+import { defineHandler } from 'nitro/h3';
+
+export default defineHandler(() => {
+ throw new Error('This is a test error');
+});
diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-isolation/[id].ts b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-isolation/[id].ts
new file mode 100644
index 000000000000..a8c2cd7a99f5
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-isolation/[id].ts
@@ -0,0 +1,10 @@
+import { getDefaultIsolationScope, setTag } from '@sentry/core';
+import { defineHandler } from 'nitro/h3';
+
+export default defineHandler(() => {
+ setTag('my-isolated-tag', true);
+ // Check if the tag leaked into the default (global) isolation scope
+ setTag('my-global-scope-isolated-tag', getDefaultIsolationScope().getScopeData().tags['my-isolated-tag']);
+
+ throw new Error('Isolation test error');
+});
diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-param/[id].ts b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-param/[id].ts
new file mode 100644
index 000000000000..ef67525b36ba
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-param/[id].ts
@@ -0,0 +1,6 @@
+import { defineHandler } from 'nitro/h3';
+
+export default defineHandler(event => {
+ const id = event.req.url;
+ return { id };
+});
diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-transaction.ts b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-transaction.ts
new file mode 100644
index 000000000000..b488b371310d
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-transaction.ts
@@ -0,0 +1,5 @@
+import { defineHandler } from 'nitro/h3';
+
+export default defineHandler(() => {
+ return { status: 'ok', transaction: true };
+});
diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/server/middleware/test.ts b/dev-packages/e2e-tests/test-applications/nitro-3/server/middleware/test.ts
new file mode 100644
index 000000000000..92d8f80c3756
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nitro-3/server/middleware/test.ts
@@ -0,0 +1,10 @@
+import { defineHandler, getQuery, setResponseHeader } from 'nitro/h3';
+
+export default defineHandler(event => {
+ setResponseHeader(event, 'x-sentry-test-middleware', 'executed');
+
+ const query = getQuery(event);
+ if (query['middleware-error'] === '1') {
+ throw new Error('Middleware error');
+ }
+});
diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/src/main.ts b/dev-packages/e2e-tests/test-applications/nitro-3/src/main.ts
new file mode 100644
index 000000000000..d27d0ba1763a
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nitro-3/src/main.ts
@@ -0,0 +1,10 @@
+import * as Sentry from '@sentry/browser';
+
+// Let's us test trace propagation
+Sentry.init({
+ environment: 'qa',
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ tunnel: 'http://localhost:3031/', // proxy server
+ integrations: [Sentry.browserTracingIntegration()],
+ tracesSampleRate: 1.0,
+});
diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/nitro-3/start-event-proxy.mjs
new file mode 100644
index 000000000000..928e68908661
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nitro-3/start-event-proxy.mjs
@@ -0,0 +1,6 @@
+import { startEventProxyServer } from '@sentry-internal/test-utils';
+
+startEventProxyServer({
+ port: 3031,
+ proxyServerName: 'nitro-3',
+});
diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/nitro-3/tests/errors.test.ts
new file mode 100644
index 000000000000..33925b335ae9
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nitro-3/tests/errors.test.ts
@@ -0,0 +1,45 @@
+import { expect, test } from '@playwright/test';
+import { waitForError } from '@sentry-internal/test-utils';
+
+test('Sends an error event to Sentry', async ({ request }) => {
+ const errorEventPromise = waitForError('nitro-3', event => {
+ return !event.type && !!event.exception?.values?.some(v => v.value === 'This is a test error');
+ });
+
+ await request.get('/api/test-error');
+
+ const errorEvent = await errorEventPromise;
+
+ // Nitro wraps thrown errors in an HTTPError with .cause, producing a chained exception
+ expect(errorEvent.exception?.values).toHaveLength(2);
+
+ // The innermost exception (values[0]) is the original thrown error
+ expect(errorEvent.exception?.values?.[0]?.type).toBe('Error');
+ expect(errorEvent.exception?.values?.[0]?.value).toBe('This is a test error');
+ expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual(
+ expect.objectContaining({
+ handled: false,
+ type: 'auto.function.nitro',
+ }),
+ );
+
+ // The outermost exception (values[1]) is the HTTPError wrapper
+ expect(errorEvent.exception?.values?.[1]?.type).toBe('HTTPError');
+ expect(errorEvent.exception?.values?.[1]?.value).toBe('This is a test error');
+});
+
+test('Does not send 404 errors to Sentry', async ({ request }) => {
+ let errorReceived = false;
+
+ void waitForError('nitro-3', event => {
+ if (!event.type) {
+ errorReceived = true;
+ return true;
+ }
+ return false;
+ });
+
+ await request.get('/api/non-existent-route');
+
+ expect(errorReceived).toBe(false);
+});
diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/tests/isolation.test.ts b/dev-packages/e2e-tests/test-applications/nitro-3/tests/isolation.test.ts
new file mode 100644
index 000000000000..7234fa0948ca
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nitro-3/tests/isolation.test.ts
@@ -0,0 +1,25 @@
+import { expect, test } from '@playwright/test';
+import { waitForError, waitForTransaction } from '@sentry-internal/test-utils';
+
+test('Isolation scope prevents tag leaking between requests', async ({ request }) => {
+ const transactionEventPromise = waitForTransaction('nitro-3', event => {
+ return event?.transaction === 'GET /api/test-isolation/:id';
+ });
+
+ const errorPromise = waitForError('nitro-3', event => {
+ return !event.type && !!event.exception?.values?.some(v => v.value === 'Isolation test error');
+ });
+
+ await request.get('/api/test-isolation/1').catch(() => {
+ // noop - route throws
+ });
+
+ const transactionEvent = await transactionEventPromise;
+ const error = await errorPromise;
+
+ // Assert that isolation scope works properly
+ expect(error.tags?.['my-isolated-tag']).toBe(true);
+ expect(error.tags?.['my-global-scope-isolated-tag']).not.toBeDefined();
+ expect(transactionEvent.tags?.['my-isolated-tag']).toBe(true);
+ expect(transactionEvent.tags?.['my-global-scope-isolated-tag']).not.toBeDefined();
+});
diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nitro-3/tests/middleware.test.ts
new file mode 100644
index 000000000000..eec281d28f98
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nitro-3/tests/middleware.test.ts
@@ -0,0 +1,40 @@
+import { expect, test } from '@playwright/test';
+import { waitForError, waitForTransaction } from '@sentry-internal/test-utils';
+
+test('Creates middleware spans for requests', async ({ request }) => {
+ const transactionEventPromise = waitForTransaction('nitro-3', event => {
+ return event?.transaction === 'GET /api/test-transaction';
+ });
+
+ const response = await request.get('/api/test-transaction');
+
+ expect(response.headers()['x-sentry-test-middleware']).toBe('executed');
+
+ const transactionEvent = await transactionEventPromise;
+
+ // h3 middleware spans have origin auto.http.nitro.h3 and op middleware.nitro
+ const h3MiddlewareSpans = transactionEvent.spans?.filter(
+ span => span.origin === 'auto.http.nitro.h3' && span.op === 'middleware.nitro',
+ );
+ expect(h3MiddlewareSpans?.length).toBeGreaterThanOrEqual(1);
+});
+
+test('Captures errors thrown in middleware with error status on span', async ({ request }) => {
+ const errorEventPromise = waitForError('nitro-3', event => {
+ return !event.type && !!event.exception?.values?.some(v => v.value === 'Middleware error');
+ });
+
+ const transactionEventPromise = waitForTransaction('nitro-3', event => {
+ return event?.transaction === 'GET /api/test-transaction' && event?.contexts?.trace?.status === 'internal_error';
+ });
+
+ await request.get('/api/test-transaction?middleware-error=1');
+
+ const errorEvent = await errorEventPromise;
+ expect(errorEvent.exception?.values?.some(v => v.value === 'Middleware error')).toBe(true);
+
+ const transactionEvent = await transactionEventPromise;
+
+ // The transaction span should have error status
+ expect(transactionEvent.contexts?.trace?.status).toBe('internal_error');
+});
diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/tests/trace-propagation.test.ts b/dev-packages/e2e-tests/test-applications/nitro-3/tests/trace-propagation.test.ts
new file mode 100644
index 000000000000..705521ad759d
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nitro-3/tests/trace-propagation.test.ts
@@ -0,0 +1,16 @@
+import { expect, test } from '@playwright/test';
+import { waitForTransaction } from '@sentry-internal/test-utils';
+
+test('Propagates server trace to client pageload via Server-Timing headers', async ({ page }) => {
+ const clientTxnPromise = waitForTransaction('nitro-3', event => {
+ return event?.contexts?.trace?.op === 'pageload';
+ });
+
+ await page.goto('/');
+
+ const clientTxn = await clientTxnPromise;
+
+ expect(clientTxn.contexts?.trace?.trace_id).toBeDefined();
+ expect(clientTxn.contexts?.trace?.trace_id).toMatch(/[a-f0-9]{32}/);
+ expect(clientTxn.contexts?.trace?.op).toBe('pageload');
+});
diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/nitro-3/tests/transactions.test.ts
new file mode 100644
index 000000000000..48de9c4349df
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nitro-3/tests/transactions.test.ts
@@ -0,0 +1,78 @@
+import { expect, test } from '@playwright/test';
+import { waitForTransaction } from '@sentry-internal/test-utils';
+
+test('Sends a transaction event for a successful route', async ({ request }) => {
+ const transactionEventPromise = waitForTransaction('nitro-3', transactionEvent => {
+ return transactionEvent?.transaction === 'GET /api/test-transaction';
+ });
+
+ await request.get('/api/test-transaction');
+
+ const transactionEvent = await transactionEventPromise;
+
+ expect(transactionEvent).toEqual(
+ expect.objectContaining({
+ transaction: 'GET /api/test-transaction',
+ type: 'transaction',
+ }),
+ );
+
+ // srvx.request creates a span for the request
+ const srvxSpans = transactionEvent.spans?.filter(span => span.origin === 'auto.http.nitro.srvx');
+ expect(srvxSpans?.length).toBeGreaterThanOrEqual(1);
+
+ // h3 creates a child span for the route handler
+ const h3Spans = transactionEvent.spans?.filter(span => span.origin === 'auto.http.nitro.h3');
+ expect(h3Spans?.length).toBeGreaterThanOrEqual(1);
+});
+
+test('Sets correct HTTP status code on transaction', async ({ request }) => {
+ const transactionEventPromise = waitForTransaction('nitro-3', transactionEvent => {
+ return transactionEvent?.transaction === 'GET /api/test-transaction';
+ });
+
+ await request.get('/api/test-transaction');
+
+ const transactionEvent = await transactionEventPromise;
+
+ expect(transactionEvent.contexts?.trace?.data).toEqual(
+ expect.objectContaining({
+ 'http.response.status_code': 200,
+ }),
+ );
+
+ expect(transactionEvent.contexts?.trace?.status).toBe('ok');
+});
+
+test('Uses parameterized route for transaction name', async ({ request }) => {
+ const transactionEventPromise = waitForTransaction('nitro-3', transactionEvent => {
+ return transactionEvent?.transaction === 'GET /api/test-param/:id';
+ });
+
+ await request.get('/api/test-param/123');
+
+ const transactionEvent = await transactionEventPromise;
+
+ expect(transactionEvent).toEqual(
+ expect.objectContaining({
+ transaction: 'GET /api/test-param/:id',
+ transaction_info: expect.objectContaining({ source: 'route' }),
+ type: 'transaction',
+ }),
+ );
+
+ expect(transactionEvent.contexts?.trace?.data).toEqual(
+ expect.objectContaining({
+ 'http.route': '/api/test-param/:id',
+ }),
+ );
+});
+
+test('Sets Server-Timing response headers for trace propagation', async ({ request }) => {
+ const response = await request.get('/api/test-transaction');
+ const headers = response.headers();
+
+ expect(headers['server-timing']).toBeDefined();
+ expect(headers['server-timing']).toContain('sentry-trace;desc="');
+ expect(headers['server-timing']).toContain('baggage;desc="');
+});
diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/tsconfig.json b/dev-packages/e2e-tests/test-applications/nitro-3/tsconfig.json
new file mode 100644
index 000000000000..b9a951fbebb1
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nitro-3/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "paths": {
+ "~/*": ["./*"]
+ }
+ },
+ "include": ["src/**/*.ts", "routes/**/*.ts", "vite.config.ts"]
+}
diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/vite.config.ts b/dev-packages/e2e-tests/test-applications/nitro-3/vite.config.ts
new file mode 100644
index 000000000000..d488f8298777
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nitro-3/vite.config.ts
@@ -0,0 +1,15 @@
+import { withSentryConfig } from '@sentry/nitro';
+import { nitro } from 'nitro/vite';
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ plugins: [
+ nitro(
+ // FIXME: Nitro plugin has a type issue
+ // @ts-expect-error
+ withSentryConfig({
+ serverDir: './server',
+ }),
+ ),
+ ],
+});
diff --git a/packages/nitro/package.json b/packages/nitro/package.json
index 3e16c0a52339..69a0019402f3 100644
--- a/packages/nitro/package.json
+++ b/packages/nitro/package.json
@@ -39,10 +39,13 @@
},
"dependencies": {
"@sentry/core": "10.38.0",
- "@sentry/node": "10.38.0"
+ "@sentry/node": "10.38.0",
+ "otel-tracing-channel": "^0.2.0"
},
"devDependencies": {
- "nitro": "^3.0.1-alpha.1"
+ "h3": "^2.0.1-rc.13",
+ "nitro": "^3.0.1-alpha.1",
+ "srvx": "^0.11.1"
},
"scripts": {
"build": "run-p build:transpile build:types",
diff --git a/packages/nitro/rollup.npm.config.mjs b/packages/nitro/rollup.npm.config.mjs
index f92d004777ad..35b018b2d99c 100644
--- a/packages/nitro/rollup.npm.config.mjs
+++ b/packages/nitro/rollup.npm.config.mjs
@@ -3,11 +3,10 @@ import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollu
export default [
...makeNPMConfigVariants(
makeBaseNPMConfig({
- entrypoints: ['src/index.ts'],
+ entrypoints: ['src/index.ts', 'src/runtime/plugins/server.ts'],
packageSpecificConfig: {
- external: [/^nitro/],
+ external: [/^nitro/, 'otel-tracing-channel', /^h3/, /^srvx/],
},
}),
- { emitCjs: false },
),
];
diff --git a/packages/nitro/src/config.ts b/packages/nitro/src/config.ts
index 0a945bcdd82e..9b22023735e3 100644
--- a/packages/nitro/src/config.ts
+++ b/packages/nitro/src/config.ts
@@ -12,9 +12,7 @@ type SentryNitroOptions = {
* @returns The modified config to be exported
*/
export function withSentryConfig(config: NitroConfig, moduleOptions?: SentryNitroOptions): NitroConfig {
- setupSentryNitroModule(config, moduleOptions);
-
- return config;
+ return setupSentryNitroModule(config, moduleOptions);
}
/**
@@ -25,6 +23,12 @@ export function setupSentryNitroModule(
_moduleOptions?: SentryNitroOptions,
_serverConfigFile?: string,
): NitroConfig {
+ // @ts-expect-error Nitro tracing config is not out yet
+ if (!config.tracing) {
+ // @ts-expect-error Nitro tracing config is not out yet
+ config.tracing = true;
+ }
+
config.modules = config.modules || [];
config.modules.push(createNitroModule());
diff --git a/packages/nitro/src/instruments/instrumentServer.ts b/packages/nitro/src/instruments/instrumentServer.ts
new file mode 100644
index 000000000000..ec891055558b
--- /dev/null
+++ b/packages/nitro/src/instruments/instrumentServer.ts
@@ -0,0 +1,12 @@
+import type { Nitro } from 'nitro/types';
+import { addPlugin } from '../utils/plugin';
+import { createResolver } from '../utils/resolver';
+
+/**
+ * Sets up the Nitro server instrumentation plugin
+ * @param nitro - The Nitro instance.
+ */
+export function instrumentServer(nitro: Nitro): void {
+ const moduleResolver = createResolver(import.meta.url);
+ addPlugin(nitro, moduleResolver.resolve('../runtime/plugins/server'));
+}
diff --git a/packages/nitro/src/module.ts b/packages/nitro/src/module.ts
index 9c2c900b1717..1f0955301813 100644
--- a/packages/nitro/src/module.ts
+++ b/packages/nitro/src/module.ts
@@ -1,4 +1,5 @@
import type { NitroModule } from 'nitro/types';
+import { instrumentServer } from './instruments/instrumentServer';
/**
* Creates a Nitro module to setup the Sentry SDK.
@@ -6,8 +7,8 @@ import type { NitroModule } from 'nitro/types';
export function createNitroModule(): NitroModule {
return {
name: 'sentry',
- setup: _nitro => {
- // TODO: Setup the Sentry SDK.
+ setup: nitro => {
+ instrumentServer(nitro);
},
};
}
diff --git a/packages/nitro/src/runtime/README.md b/packages/nitro/src/runtime/README.md
new file mode 100644
index 000000000000..43c190e6d015
--- /dev/null
+++ b/packages/nitro/src/runtime/README.md
@@ -0,0 +1,5 @@
+# Nitro Runtime
+
+This directory contains the runtime code for Nitro, this includes plugins or any runtime code they may use.
+
+Do not mix runtime code with other code, this directory will be packaged with the SDK and shipped as-is.
diff --git a/packages/nitro/src/runtime/hooks/captureErrorHook.ts b/packages/nitro/src/runtime/hooks/captureErrorHook.ts
new file mode 100644
index 000000000000..7f56d8d74b2f
--- /dev/null
+++ b/packages/nitro/src/runtime/hooks/captureErrorHook.ts
@@ -0,0 +1,77 @@
+import { captureException, flushIfServerless, getClient, getCurrentScope } from '@sentry/core';
+import { HTTPError } from 'h3';
+import type { CapturedErrorContext } from 'nitro/types';
+
+/**
+ * Extracts the relevant context information from the error context (HTTPEvent in Nitro Error)
+ * and creates a structured context object.
+ */
+function extractErrorContext(errorContext: CapturedErrorContext | undefined): Record {
+ const ctx: Record = {};
+
+ if (!errorContext) {
+ return ctx;
+ }
+
+ if (errorContext.event) {
+ ctx.method = errorContext.event.req.method;
+
+ try {
+ const url = new URL(errorContext.event.req.url);
+ ctx.path = url.pathname;
+ } catch {
+ // If URL parsing fails, leave path undefined
+ }
+ }
+
+ if (Array.isArray(errorContext.tags)) {
+ ctx.tags = errorContext.tags;
+ }
+
+ return ctx;
+}
+
+/**
+ * Hook that can be added in a Nitro plugin. It captures an error and sends it to Sentry.
+ */
+export async function captureErrorHook(error: Error, errorContext: CapturedErrorContext): Promise {
+ const sentryClient = getClient();
+ const sentryClientOptions = sentryClient?.getOptions();
+
+ if (
+ sentryClientOptions &&
+ 'enableNitroErrorHandler' in sentryClientOptions &&
+ sentryClientOptions.enableNitroErrorHandler === false
+ ) {
+ return;
+ }
+
+ // Do not report HTTPErrors with 3xx or 4xx status codes
+ if (HTTPError.isError(error) && error.status >= 300 && error.status < 500) {
+ return;
+ }
+
+ const method = errorContext.event?.req.method ?? '';
+ let path: string | null = null;
+
+ try {
+ if (errorContext.event?.req.url) {
+ path = new URL(errorContext.event.req.url).pathname;
+ }
+ } catch {
+ // If URL parsing fails, leave path as null
+ }
+
+ if (path) {
+ getCurrentScope().setTransactionName(`${method} ${path}`);
+ }
+
+ const structuredContext = extractErrorContext(errorContext);
+
+ captureException(error, {
+ captureContext: { contexts: { nitro: structuredContext } },
+ mechanism: { handled: false, type: 'auto.function.nitro' },
+ });
+
+ await flushIfServerless();
+}
diff --git a/packages/nitro/src/runtime/hooks/captureTracingEvents.ts b/packages/nitro/src/runtime/hooks/captureTracingEvents.ts
new file mode 100644
index 000000000000..2dbabd1219aa
--- /dev/null
+++ b/packages/nitro/src/runtime/hooks/captureTracingEvents.ts
@@ -0,0 +1,242 @@
+import {
+ captureException,
+ getActiveSpan,
+ getClient,
+ getHttpSpanDetailsFromUrlObject,
+ getRootSpan,
+ GLOBAL_OBJ,
+ httpHeadersToSpanAttributes,
+ parseStringToURLObject,
+ SEMANTIC_ATTRIBUTE_SENTRY_OP,
+ SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
+ SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
+ setHttpStatus,
+ type Span,
+ SPAN_STATUS_ERROR,
+ startSpanManual,
+ updateSpanName,
+} from '@sentry/core';
+import type { TracingRequestEvent as H3TracingRequestEvent } from 'h3/tracing';
+import { tracingChannel } from 'otel-tracing-channel';
+import type { RequestEvent as SrvxRequestEvent } from 'srvx/tracing';
+import { setServerTimingHeaders } from './setServerTimingHeaders';
+
+/**
+ * Global object with the trace channels
+ */
+const globalWithTraceChannels = GLOBAL_OBJ as typeof GLOBAL_OBJ & {
+ __SENTRY_NITRO_HTTP_CHANNELS_INSTRUMENTED__: boolean;
+};
+
+/**
+ * Captures tracing events emitted by Nitro tracing channels.
+ */
+export function captureTracingEvents(): void {
+ if (globalWithTraceChannels.__SENTRY_NITRO_HTTP_CHANNELS_INSTRUMENTED__) {
+ return;
+ }
+
+ setupH3TracingChannels();
+ setupSrvxTracingChannels();
+ globalWithTraceChannels.__SENTRY_NITRO_HTTP_CHANNELS_INSTRUMENTED__ = true;
+}
+
+/**
+ * No-op function to satisfy the tracing channel subscribe callbacks
+ */
+const NOOP = (): void => {};
+
+/**
+ * Extracts the HTTP status code from a tracing channel result.
+ * The result is the return value of the traced handler, which is a Response for srvx
+ * and may or may not be a Response for h3.
+ */
+function getResponseStatusCode(result: unknown): number | undefined {
+ if (result && typeof result === 'object' && 'status' in result && typeof result.status === 'number') {
+ return result.status;
+ }
+ return undefined;
+}
+
+function onTraceEnd(data: { span?: Span; result?: unknown }): void {
+ const statusCode = getResponseStatusCode(data.result);
+ if (data.span && statusCode !== undefined) {
+ setHttpStatus(data.span, statusCode);
+ }
+
+ data.span?.end();
+}
+
+function onTraceError(data: { span?: Span; error: unknown }): void {
+ captureException(data.error);
+ data.span?.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
+ data.span?.end();
+}
+
+/**
+ * Extracts the parameterized route pattern from the h3 event context.
+ */
+function getParameterizedRoute(event: H3TracingRequestEvent['event']): string | undefined {
+ const matchedRoute = event.context?.matchedRoute;
+ if (!matchedRoute) {
+ return undefined;
+ }
+
+ const routePath = matchedRoute.route;
+
+ // Skip catch-all routes as they're not useful for transaction grouping
+ if (!routePath || routePath === '/**') {
+ return undefined;
+ }
+
+ return routePath;
+}
+
+function setupH3TracingChannels(): void {
+ const h3Channel = tracingChannel('h3.request', data => {
+ const parsedUrl = parseStringToURLObject(data.event.url.href);
+ const routePattern = getParameterizedRoute(data.event);
+
+ const [spanName, urlAttributes] = getHttpSpanDetailsFromUrlObject(
+ parsedUrl,
+ 'server',
+ 'auto.http.nitro.h3',
+ { method: data.event.req.method },
+ routePattern,
+ );
+
+ return startSpanManual(
+ {
+ name: spanName,
+ attributes: {
+ ...urlAttributes,
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.nitro.h3',
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: data?.type === 'middleware' ? 'middleware.nitro' : 'http.server',
+ },
+ },
+ s => s,
+ );
+ });
+
+ h3Channel.subscribe({
+ start: (data: H3TracingRequestEvent) => {
+ setServerTimingHeaders(data.event);
+ },
+ asyncStart: NOOP,
+ end: NOOP,
+ asyncEnd: (data: H3TracingRequestEvent & { span?: Span; result?: unknown }) => {
+ onTraceEnd(data);
+
+ if (!data.span) {
+ return;
+ }
+
+ // Update the root span (srvx transaction) with the parameterized route name.
+ // The srvx span is created before h3 resolves the route, so it initially has the raw URL.
+ // Note: data.type is always 'middleware' in asyncEnd regardless of handler type,
+ // so we rely on getParameterizedRoute() to filter out catch-all routes instead.
+ const rootSpan = getRootSpan(data.span);
+ if (rootSpan && rootSpan !== data.span) {
+ const routePattern = getParameterizedRoute(data.event);
+ if (routePattern) {
+ const method = data.event.req.method || 'GET';
+ updateSpanName(rootSpan, `${method} ${routePattern}`);
+ rootSpan.setAttributes({
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route',
+ 'http.route': routePattern,
+ });
+ }
+ }
+ },
+ error: onTraceError,
+ });
+}
+
+function setupSrvxTracingChannels(): void {
+ // Store the parent span for all middleware and fetch to share
+ // This ensures they all appear as siblings in the trace
+ let requestParentSpan: Span | null = null;
+
+ const fetchChannel = tracingChannel('srvx.request', data => {
+ const parsedUrl = data.request._url ? parseStringToURLObject(data.request._url.href) : undefined;
+ const [spanName, urlAttributes] = getHttpSpanDetailsFromUrlObject(parsedUrl, 'server', 'auto.http.nitro.srvx', {
+ method: data.request.method,
+ });
+
+ const sendDefaultPii = getClient()?.getOptions().sendDefaultPii ?? false;
+ const headerAttributes = httpHeadersToSpanAttributes(
+ Object.fromEntries(data.request.headers.entries()),
+ sendDefaultPii,
+ );
+
+ return startSpanManual(
+ {
+ name: spanName,
+ attributes: {
+ ...urlAttributes,
+ ...headerAttributes,
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.nitro.srvx',
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: data.middleware ? 'middleware.nitro' : 'http.server',
+ 'server.port': data.server.options.port,
+ },
+ // Use the same parent span as middleware to make them siblings
+ parentSpan: requestParentSpan || undefined,
+ },
+ span => span,
+ );
+ });
+
+ // Subscribe to events (span already created in bindStore)
+ fetchChannel.subscribe({
+ start: () => {},
+ asyncStart: () => {},
+ end: () => {},
+ asyncEnd: data => {
+ onTraceEnd(data);
+
+ // Reset parent span reference after the fetch handler completes
+ // This ensures each request gets a fresh parent span capture
+ requestParentSpan = null;
+ },
+ error: data => {
+ onTraceError(data);
+ // Reset parent span reference on error too
+ requestParentSpan = null;
+ },
+ });
+
+ const middlewareChannel = tracingChannel('srvx.middleware', data => {
+ // For the first middleware, capture the current parent span
+ if (data.middleware?.index === 0) {
+ requestParentSpan = getActiveSpan() || null;
+ }
+
+ const parsedUrl = data.request._url ? parseStringToURLObject(data.request._url.href) : undefined;
+ const [, urlAttributes] = getHttpSpanDetailsFromUrlObject(parsedUrl, 'server', 'auto.http.nitro.srvx', {
+ method: data.request.method,
+ });
+
+ // Create span as a child of the original parent, not the previous middleware
+ return startSpanManual(
+ {
+ name: `${data.middleware?.handler.name ?? 'unknown'} - ${data.request.method} ${data.request._url?.pathname}`,
+ attributes: {
+ ...urlAttributes,
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.nitro.srvx',
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'middleware.nitro',
+ },
+ parentSpan: requestParentSpan || undefined,
+ },
+ span => span,
+ );
+ });
+
+ // Subscribe to events (span already created in bindStore)
+ middlewareChannel.subscribe({
+ start: () => {},
+ asyncStart: () => {},
+ end: () => {},
+ asyncEnd: onTraceEnd,
+ error: onTraceError,
+ });
+}
diff --git a/packages/nitro/src/runtime/hooks/setServerTimingHeaders.ts b/packages/nitro/src/runtime/hooks/setServerTimingHeaders.ts
new file mode 100644
index 000000000000..4573f8171c19
--- /dev/null
+++ b/packages/nitro/src/runtime/hooks/setServerTimingHeaders.ts
@@ -0,0 +1,27 @@
+import { getTraceData } from '@sentry/core';
+import type { TracingRequestEvent as H3TracingRequestEvent } from 'h3/tracing';
+
+/**
+ * Sets Server-Timing response headers for trace propagation to the client.
+ * The browser SDK reads these via the Performance API to connect pageload traces.
+ */
+export function setServerTimingHeaders(event: H3TracingRequestEvent['event']): void {
+ if (event.context._sentryServerTimingSet) {
+ return;
+ }
+
+ const headers = event.res?.headers;
+ if (!headers) {
+ return;
+ }
+
+ const traceData = getTraceData();
+ if (traceData['sentry-trace']) {
+ headers.append('Server-Timing', `sentry-trace;desc="${traceData['sentry-trace']}"`);
+ }
+ if (traceData.baggage) {
+ headers.append('Server-Timing', `baggage;desc="${traceData.baggage}"`);
+ }
+
+ event.context._sentryServerTimingSet = true;
+}
diff --git a/packages/nitro/src/runtime/plugins/server.ts b/packages/nitro/src/runtime/plugins/server.ts
new file mode 100644
index 000000000000..a46880df000a
--- /dev/null
+++ b/packages/nitro/src/runtime/plugins/server.ts
@@ -0,0 +1,11 @@
+import { definePlugin } from 'nitro';
+import { captureErrorHook } from '../hooks/captureErrorHook';
+import { captureTracingEvents } from '../hooks/captureTracingEvents';
+
+export default definePlugin(nitroApp => {
+ // FIXME: Nitro hooks are not typed it seems
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
+ nitroApp.hooks.hook('error', captureErrorHook);
+
+ captureTracingEvents();
+});
diff --git a/packages/nitro/test/runtime/hooks/captureErrorHook.test.ts b/packages/nitro/test/runtime/hooks/captureErrorHook.test.ts
new file mode 100644
index 000000000000..2f288a4719ef
--- /dev/null
+++ b/packages/nitro/test/runtime/hooks/captureErrorHook.test.ts
@@ -0,0 +1,168 @@
+import * as SentryCore from '@sentry/core';
+import { HTTPError } from 'h3';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { captureErrorHook } from '../../../src/runtime/hooks/captureErrorHook';
+
+vi.mock('@sentry/core', async importOriginal => {
+ const mod = await importOriginal();
+ return {
+ ...(mod as any),
+ captureException: vi.fn(),
+ flushIfServerless: vi.fn(),
+ getClient: vi.fn(),
+ getCurrentScope: vi.fn(() => ({
+ setTransactionName: vi.fn(),
+ })),
+ };
+});
+
+describe('captureErrorHook', () => {
+ const mockErrorContext = {
+ event: {
+ req: { method: 'GET', url: 'http://localhost/test-path' },
+ },
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ (SentryCore.getClient as any).mockReturnValue({
+ getOptions: () => ({}),
+ });
+ (SentryCore.flushIfServerless as any).mockResolvedValue(undefined);
+ });
+
+ it('should capture regular errors', async () => {
+ const error = new Error('Test error');
+
+ await captureErrorHook(error, mockErrorContext);
+
+ expect(SentryCore.captureException).toHaveBeenCalledWith(
+ error,
+ expect.objectContaining({
+ mechanism: { handled: false, type: 'auto.function.nitro' },
+ }),
+ );
+ });
+
+ it('should include structured context with method and path', async () => {
+ const error = new Error('Test error');
+
+ await captureErrorHook(error, mockErrorContext);
+
+ expect(SentryCore.captureException).toHaveBeenCalledWith(
+ error,
+ expect.objectContaining({
+ captureContext: {
+ contexts: {
+ nitro: { method: 'GET', path: '/test-path' },
+ },
+ },
+ }),
+ );
+ });
+
+ it('should set transaction name from method and path', async () => {
+ const mockSetTransactionName = vi.fn();
+ (SentryCore.getCurrentScope as any).mockReturnValue({
+ setTransactionName: mockSetTransactionName,
+ });
+
+ const error = new Error('Test error');
+
+ await captureErrorHook(error, mockErrorContext);
+
+ expect(mockSetTransactionName).toHaveBeenCalledWith('GET /test-path');
+ });
+
+ it('should skip HTTPError with 4xx status codes', async () => {
+ const error = new HTTPError({ status: 404, message: 'Not found' });
+
+ await captureErrorHook(error, mockErrorContext);
+
+ expect(SentryCore.captureException).not.toHaveBeenCalled();
+ });
+
+ it('should skip HTTPError with 3xx status codes', async () => {
+ const error = new HTTPError({ status: 302, message: 'Redirect' });
+
+ await captureErrorHook(error, mockErrorContext);
+
+ expect(SentryCore.captureException).not.toHaveBeenCalled();
+ });
+
+ it('should capture HTTPError with 5xx status codes', async () => {
+ const error = new HTTPError({ status: 500, message: 'Server error' });
+
+ await captureErrorHook(error, mockErrorContext);
+
+ expect(SentryCore.captureException).toHaveBeenCalledWith(
+ error,
+ expect.objectContaining({
+ mechanism: { handled: false, type: 'auto.function.nitro' },
+ }),
+ );
+ });
+
+ it('should skip when enableNitroErrorHandler is false', async () => {
+ (SentryCore.getClient as any).mockReturnValue({
+ getOptions: () => ({ enableNitroErrorHandler: false }),
+ });
+
+ const error = new Error('Test error');
+
+ await captureErrorHook(error, mockErrorContext);
+
+ expect(SentryCore.captureException).not.toHaveBeenCalled();
+ });
+
+ it('should call flushIfServerless after capturing', async () => {
+ const error = new Error('Test error');
+
+ await captureErrorHook(error, mockErrorContext);
+
+ expect(SentryCore.flushIfServerless).toHaveBeenCalled();
+ });
+
+ it('should handle missing event in error context', async () => {
+ const error = new Error('Test error');
+ const contextWithoutEvent = {
+ event: undefined,
+ };
+
+ await captureErrorHook(error, contextWithoutEvent);
+
+ expect(SentryCore.captureException).toHaveBeenCalledWith(
+ error,
+ expect.objectContaining({
+ captureContext: {
+ contexts: {
+ nitro: {},
+ },
+ },
+ }),
+ );
+ });
+
+ it('should include tags in structured context when available', async () => {
+ const error = new Error('Test error');
+ const contextWithTags = {
+ event: {
+ req: { method: 'POST', url: 'http://localhost/api/test' },
+ } as any,
+ tags: ['tag1', 'tag2'],
+ };
+
+ await captureErrorHook(error, contextWithTags);
+
+ expect(SentryCore.captureException).toHaveBeenCalledWith(
+ error,
+ expect.objectContaining({
+ captureContext: {
+ contexts: {
+ nitro: { method: 'POST', path: '/api/test', tags: ['tag1', 'tag2'] },
+ },
+ },
+ }),
+ );
+ });
+});
diff --git a/packages/nitro/tsconfig.test.json b/packages/nitro/tsconfig.test.json
index da5a816712e3..c41efeacd92f 100644
--- a/packages/nitro/tsconfig.test.json
+++ b/packages/nitro/tsconfig.test.json
@@ -1,7 +1,7 @@
{
"extends": "./tsconfig.json",
- "include": ["test/**/*"],
+ "include": ["test/**/*", "vite.config.ts"],
"compilerOptions": {
// should include all types from `./tsconfig.json` plus types for all test frameworks used
diff --git a/packages/nitro/vite.config.ts b/packages/nitro/vite.config.ts
new file mode 100644
index 000000000000..4c0db8cdc068
--- /dev/null
+++ b/packages/nitro/vite.config.ts
@@ -0,0 +1,11 @@
+import baseConfig from '../../vite/vite.config';
+
+export default {
+ ...baseConfig,
+ test: {
+ typecheck: {
+ enabled: true,
+ tsconfig: './tsconfig.test.json',
+ },
+ },
+};
diff --git a/yarn.lock b/yarn.lock
index e7a0b2959e60..a5d30c2a5b29 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -19101,6 +19101,14 @@ h3@^1.10.0, h3@^1.12.0, h3@^1.15.3, h3@^1.15.5:
ufo "^1.6.3"
uncrypto "^0.1.3"
+h3@^2.0.1-rc.13:
+ version "2.0.1-rc.13"
+ resolved "https://registry.yarnpkg.com/h3/-/h3-2.0.1-rc.13.tgz#f11df1dfaa91ac47422a880a9f7f4a26f5e2262f"
+ integrity sha512-1g+GVBDFygRwLEw6CIbFME99M1QiHZfE0bCzXbqOKEDHz23L/RlMJZkIwQl/mJMAauA9G3oO9pSL4tKDpvgagQ==
+ dependencies:
+ rou3 "^0.7.12"
+ srvx "^0.11.1"
+
handle-thing@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/handle-thing/-/handle-thing-2.0.1.tgz#857f79ce359580c340d43081cc648970d0bb234e"
@@ -24402,6 +24410,11 @@ osenv@^0.1.3:
os-homedir "^1.0.0"
os-tmpdir "^1.0.0"
+otel-tracing-channel@^0.2.0:
+ version "0.2.0"
+ resolved "https://registry.yarnpkg.com/otel-tracing-channel/-/otel-tracing-channel-0.2.0.tgz#55c8dafa55dafaa9daf64dd501a4b5d8e58c3f29"
+ integrity sha512-m+JtCKi05Ou2MpSsAHFqSCBjc2QDlnmXtOasZXvDnU56uBr4UeClXWKvBK8MsGwNCbGUBqwOOPDbjS7+D9A8lw==
+
own-keys@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/own-keys/-/own-keys-1.0.1.tgz#e4006910a2bf913585289676eebd6f390cf51358"
@@ -28571,6 +28584,11 @@ sqlstring@^2.3.2:
resolved "https://registry.yarnpkg.com/sqlstring/-/sqlstring-2.3.3.tgz#2ddc21f03bce2c387ed60680e739922c65751d0c"
integrity sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==
+srvx@^0.11.1:
+ version "0.11.1"
+ resolved "https://registry.yarnpkg.com/srvx/-/srvx-0.11.1.tgz#1137b2e38c6babfe4dade129d09c70e5ea9278d7"
+ integrity sha512-hs+BDmweGNT0DHlcsgB52BOKOn32v1g+CtyCavqstcz19KUDZsAoLVLFV0y03xAOd0KiXbgsguPcCivR6Lj5PA==
+
srvx@^0.11.2:
version "0.11.4"
resolved "https://registry.yarnpkg.com/srvx/-/srvx-0.11.4.tgz#0d1dd962c2320f84fc7872f2500b21c84c3d1b97"