From 123bbfd39b7e56aadfb5c76e1e47d61d4c1e0ad9 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 31 Dec 2025 15:02:12 +0200 Subject: [PATCH 01/32] feat: added h3 channel --- .../nitro/src/instruments/instrumentServer.ts | 12 +++++ packages/nitro/src/module.ts | 5 +- packages/nitro/src/runtime/README.md | 5 ++ packages/nitro/src/runtime/plugins/server.ts | 49 +++++++++++++++++++ 4 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 packages/nitro/src/instruments/instrumentServer.ts create mode 100644 packages/nitro/src/runtime/README.md create mode 100644 packages/nitro/src/runtime/plugins/server.ts 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/plugins/server.ts b/packages/nitro/src/runtime/plugins/server.ts new file mode 100644 index 000000000000..b1670fa5504a --- /dev/null +++ b/packages/nitro/src/runtime/plugins/server.ts @@ -0,0 +1,49 @@ +import { captureException, GLOBAL_OBJ, SPAN_STATUS_ERROR, startSpanManual } from '@sentry/core'; +import type { TracingRequestEvent as H3TracingRequestEvent } from 'h3/tracing'; +import { definePlugin } from 'nitro'; +import { tracingChannel } from 'otel-tracing-channel'; + +const globalWithTraceChannels = GLOBAL_OBJ as typeof GLOBAL_OBJ & { + __SENTRY_NITRO_H3_CHANNEL__: ReturnType>; + __SENTRY_NITRO_SRVX_CHANNEL__: ReturnType; +}; + +export default definePlugin(() => { + setupH3TracingChannel(); + // setupSrvxTracingChannel(); +}); + +function setupH3TracingChannel(): void { + // Already registered, don't register again + if (globalWithTraceChannels.__SENTRY_NITRO_H3_CHANNEL__) { + return; + } + + const h3Channel = tracingChannel('h3.request.handler', data => { + return startSpanManual( + { + name: `${data.event.req.method} ${data.event.url.pathname}`, + op: 'h3.request.handler', + }, + s => s, + ); + }); + + const NOOP = (): void => {}; + + h3Channel.subscribe({ + start: NOOP, + asyncStart: NOOP, + end: NOOP, + asyncEnd: data => { + data.span?.end(); + }, + error: data => { + captureException(data.error); + data.span?.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + data.span?.end(); + }, + }); + + globalWithTraceChannels.__SENTRY_NITRO_H3_CHANNEL__ = h3Channel; +} From c5d3cbef591269a64ce0571f606d8576e51ed999 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 31 Dec 2025 15:37:55 +0200 Subject: [PATCH 02/32] feat: added srvx channel and enhanced attr collection --- packages/nitro/src/runtime/plugins/server.ts | 158 +++++++++++++++++-- 1 file changed, 144 insertions(+), 14 deletions(-) diff --git a/packages/nitro/src/runtime/plugins/server.ts b/packages/nitro/src/runtime/plugins/server.ts index b1670fa5504a..14edfe76f7b5 100644 --- a/packages/nitro/src/runtime/plugins/server.ts +++ b/packages/nitro/src/runtime/plugins/server.ts @@ -1,49 +1,179 @@ -import { captureException, GLOBAL_OBJ, SPAN_STATUS_ERROR, startSpanManual } from '@sentry/core'; +import { + captureException, + getActiveSpan, + getClient, + getHttpSpanDetailsFromUrlObject, + GLOBAL_OBJ, + httpHeadersToSpanAttributes, + parseStringToURLObject, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + type Span, + SPAN_STATUS_ERROR, + startSpanManual, +} from '@sentry/core'; import type { TracingRequestEvent as H3TracingRequestEvent } from 'h3/tracing'; import { definePlugin } from 'nitro'; import { tracingChannel } from 'otel-tracing-channel'; +import type { RequestEvent as SrvxRequestEvent } from 'srvx/tracing'; +/** + * Global object with the trace channels + */ const globalWithTraceChannels = GLOBAL_OBJ as typeof GLOBAL_OBJ & { __SENTRY_NITRO_H3_CHANNEL__: ReturnType>; - __SENTRY_NITRO_SRVX_CHANNEL__: ReturnType; + __SENTRY_NITRO_SRVX_FETCH_CHANNEL__: ReturnType>; + __SENTRY_NITRO_SRVX_MIDDLEWARE_CHANNEL__: ReturnType>; }; +/** + * No-op function to satisfy the tracing channel subscribe callbacks + */ +const NOOP = (): void => {}; + export default definePlugin(() => { - setupH3TracingChannel(); - // setupSrvxTracingChannel(); + setupH3TracingChannels(); + setupSrvxTracingChannels(); }); -function setupH3TracingChannel(): void { +function onTraceEnd(data: { span?: Span }): void { + 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(); +} + +function setupH3TracingChannels(): void { // Already registered, don't register again if (globalWithTraceChannels.__SENTRY_NITRO_H3_CHANNEL__) { return; } const h3Channel = tracingChannel('h3.request.handler', data => { + const parsedUrl = parseStringToURLObject(data.event.url.href); + const [spanName, urlAttributes] = getHttpSpanDetailsFromUrlObject(parsedUrl, 'server', 'auto.http.nitro.h3', { + method: data.event.req.method, + }); + return startSpanManual( { - name: `${data.event.req.method} ${data.event.url.pathname}`, - op: 'h3.request.handler', + name: spanName, + attributes: { + ...urlAttributes, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: data?.type === 'middleware' ? 'middleware.nitro' : 'http.server', + }, }, s => s, ); }); - const NOOP = (): void => {}; - h3Channel.subscribe({ start: NOOP, asyncStart: NOOP, end: NOOP, + asyncEnd: onTraceEnd, + error: onTraceError, + }); + + globalWithTraceChannels.__SENTRY_NITRO_H3_CHANNEL__ = h3Channel; +} + +function setupSrvxTracingChannels(): void { + if ( + globalWithTraceChannels.__SENTRY_NITRO_SRVX_FETCH_CHANNEL__ || + globalWithTraceChannels.__SENTRY_NITRO_SRVX_MIDDLEWARE_CHANNEL__ + ) { + return; + } + + // 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.fetch', 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_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 => { - data.span?.end(); + // data.span?.setAttribute('http.response.status_code', data.result.); + 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 => { - captureException(data.error); - data.span?.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - data.span?.end(); + onTraceError(data); + // Reset parent span reference on error too + requestParentSpan = null; }, }); - globalWithTraceChannels.__SENTRY_NITRO_H3_CHANNEL__ = h3Channel; + 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.middleware', { + 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_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, + }); + + globalWithTraceChannels.__SENTRY_NITRO_SRVX_FETCH_CHANNEL__ = fetchChannel; + globalWithTraceChannels.__SENTRY_NITRO_SRVX_MIDDLEWARE_CHANNEL__ = middlewareChannel; } From 39b076517a6698bab7708758f783b0001aa02446 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 27 Jan 2026 09:30:50 -0500 Subject: [PATCH 03/32] fix: ensure runtime plugins are present in the dist --- packages/nitro/rollup.npm.config.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nitro/rollup.npm.config.mjs b/packages/nitro/rollup.npm.config.mjs index f92d004777ad..58f5b095997e 100644 --- a/packages/nitro/rollup.npm.config.mjs +++ b/packages/nitro/rollup.npm.config.mjs @@ -3,7 +3,7 @@ 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/], }, From da1fe6a11c9ab0754220c5d2b17f8bfb01829c5e Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 5 Feb 2026 16:51:37 -0500 Subject: [PATCH 04/32] fix: tracing channel name --- packages/nitro/src/runtime/plugins/server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nitro/src/runtime/plugins/server.ts b/packages/nitro/src/runtime/plugins/server.ts index 14edfe76f7b5..134cd503ba7c 100644 --- a/packages/nitro/src/runtime/plugins/server.ts +++ b/packages/nitro/src/runtime/plugins/server.ts @@ -51,7 +51,7 @@ function setupH3TracingChannels(): void { return; } - const h3Channel = tracingChannel('h3.request.handler', data => { + const h3Channel = tracingChannel('h3.fetch', data => { const parsedUrl = parseStringToURLObject(data.event.url.href); const [spanName, urlAttributes] = getHttpSpanDetailsFromUrlObject(parsedUrl, 'server', 'auto.http.nitro.h3', { method: data.event.req.method, From 44069ff1b6ce0f2ee12cba7a05661855e07bad41 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 6 Feb 2026 16:23:11 -0500 Subject: [PATCH 05/32] ref: use only one global flag for channel installation --- packages/nitro/src/runtime/plugins/server.ts | 26 +++++--------------- 1 file changed, 6 insertions(+), 20 deletions(-) diff --git a/packages/nitro/src/runtime/plugins/server.ts b/packages/nitro/src/runtime/plugins/server.ts index 134cd503ba7c..bc7374a69545 100644 --- a/packages/nitro/src/runtime/plugins/server.ts +++ b/packages/nitro/src/runtime/plugins/server.ts @@ -20,9 +20,7 @@ import type { RequestEvent as SrvxRequestEvent } from 'srvx/tracing'; * Global object with the trace channels */ const globalWithTraceChannels = GLOBAL_OBJ as typeof GLOBAL_OBJ & { - __SENTRY_NITRO_H3_CHANNEL__: ReturnType>; - __SENTRY_NITRO_SRVX_FETCH_CHANNEL__: ReturnType>; - __SENTRY_NITRO_SRVX_MIDDLEWARE_CHANNEL__: ReturnType>; + __SENTRY_NITRO_HTTP_CHANNELS_INSTRUMENTED__: boolean; }; /** @@ -31,8 +29,13 @@ const globalWithTraceChannels = GLOBAL_OBJ as typeof GLOBAL_OBJ & { const NOOP = (): void => {}; export default definePlugin(() => { + if (globalWithTraceChannels.__SENTRY_NITRO_HTTP_CHANNELS_INSTRUMENTED__) { + return; + } + setupH3TracingChannels(); setupSrvxTracingChannels(); + globalWithTraceChannels.__SENTRY_NITRO_HTTP_CHANNELS_INSTRUMENTED__ = true; }); function onTraceEnd(data: { span?: Span }): void { @@ -46,11 +49,6 @@ function onTraceError(data: { span?: Span; error: unknown }): void { } function setupH3TracingChannels(): void { - // Already registered, don't register again - if (globalWithTraceChannels.__SENTRY_NITRO_H3_CHANNEL__) { - return; - } - const h3Channel = tracingChannel('h3.fetch', data => { const parsedUrl = parseStringToURLObject(data.event.url.href); const [spanName, urlAttributes] = getHttpSpanDetailsFromUrlObject(parsedUrl, 'server', 'auto.http.nitro.h3', { @@ -76,18 +74,9 @@ function setupH3TracingChannels(): void { asyncEnd: onTraceEnd, error: onTraceError, }); - - globalWithTraceChannels.__SENTRY_NITRO_H3_CHANNEL__ = h3Channel; } function setupSrvxTracingChannels(): void { - if ( - globalWithTraceChannels.__SENTRY_NITRO_SRVX_FETCH_CHANNEL__ || - globalWithTraceChannels.__SENTRY_NITRO_SRVX_MIDDLEWARE_CHANNEL__ - ) { - return; - } - // 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; @@ -173,7 +162,4 @@ function setupSrvxTracingChannels(): void { asyncEnd: onTraceEnd, error: onTraceError, }); - - globalWithTraceChannels.__SENTRY_NITRO_SRVX_FETCH_CHANNEL__ = fetchChannel; - globalWithTraceChannels.__SENTRY_NITRO_SRVX_MIDDLEWARE_CHANNEL__ = middlewareChannel; } From 1efb3b5912e901f6f8a2f4b19733d3dafdffbf9a Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 9 Feb 2026 11:23:03 -0500 Subject: [PATCH 06/32] feat: handle http errors --- .../src/runtime/hooks/captureErrorHook.ts | 77 ++++++++ packages/nitro/src/runtime/plugins/server.ts | 7 +- .../runtime/hooks/captureErrorHook.test.ts | 168 ++++++++++++++++++ packages/nitro/tsconfig.test.json | 2 +- packages/nitro/vite.config.ts | 11 ++ 5 files changed, 263 insertions(+), 2 deletions(-) create mode 100644 packages/nitro/src/runtime/hooks/captureErrorHook.ts create mode 100644 packages/nitro/test/runtime/hooks/captureErrorHook.test.ts create mode 100644 packages/nitro/vite.config.ts 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/plugins/server.ts b/packages/nitro/src/runtime/plugins/server.ts index bc7374a69545..d4712fabbda7 100644 --- a/packages/nitro/src/runtime/plugins/server.ts +++ b/packages/nitro/src/runtime/plugins/server.ts @@ -15,6 +15,7 @@ import type { TracingRequestEvent as H3TracingRequestEvent } from 'h3/tracing'; import { definePlugin } from 'nitro'; import { tracingChannel } from 'otel-tracing-channel'; import type { RequestEvent as SrvxRequestEvent } from 'srvx/tracing'; +import { captureErrorHook } from '../hooks/captureErrorHook'; /** * Global object with the trace channels @@ -28,11 +29,15 @@ const globalWithTraceChannels = GLOBAL_OBJ as typeof GLOBAL_OBJ & { */ const NOOP = (): void => {}; -export default definePlugin(() => { +export default definePlugin(nitroApp => { if (globalWithTraceChannels.__SENTRY_NITRO_HTTP_CHANNELS_INSTRUMENTED__) { return; } + // FIXME: Nitro hooks are not typed it seems + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + nitroApp.hooks.hook('error', captureErrorHook); + setupH3TracingChannels(); setupSrvxTracingChannels(); globalWithTraceChannels.__SENTRY_NITRO_HTTP_CHANNELS_INSTRUMENTED__ = true; 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', + }, + }, +}; From 97a5c629bd5e83ca930b11ed5ec413b19ac377c9 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 9 Feb 2026 11:33:02 -0500 Subject: [PATCH 07/32] feat: added http status code handling --- packages/nitro/src/runtime/plugins/server.ts | 22 +++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/nitro/src/runtime/plugins/server.ts b/packages/nitro/src/runtime/plugins/server.ts index d4712fabbda7..888dbc18c7ee 100644 --- a/packages/nitro/src/runtime/plugins/server.ts +++ b/packages/nitro/src/runtime/plugins/server.ts @@ -7,6 +7,7 @@ import { httpHeadersToSpanAttributes, parseStringToURLObject, SEMANTIC_ATTRIBUTE_SENTRY_OP, + setHttpStatus, type Span, SPAN_STATUS_ERROR, startSpanManual, @@ -43,8 +44,24 @@ export default definePlugin(nitroApp => { globalWithTraceChannels.__SENTRY_NITRO_HTTP_CHANNELS_INSTRUMENTED__ = true; }); -function onTraceEnd(data: { span?: Span }): void { - data.span?.end(); +/** + * 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 { @@ -120,7 +137,6 @@ function setupSrvxTracingChannels(): void { asyncStart: () => {}, end: () => {}, asyncEnd: data => { - // data.span?.setAttribute('http.response.status_code', data.result.); onTraceEnd(data); // Reset parent span reference after the fetch handler completes From f694ebeb66fb21d6eb50df9e68cff930d6c4de7b Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 9 Feb 2026 12:06:20 -0500 Subject: [PATCH 08/32] feat: force enable tracing for the user --- packages/nitro/src/config.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/nitro/src/config.ts b/packages/nitro/src/config.ts index 0a945bcdd82e..57e903f127a6 100644 --- a/packages/nitro/src/config.ts +++ b/packages/nitro/src/config.ts @@ -25,6 +25,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()); From 48a8c5461f35bae7c0411ec1ebbaa6e64c30677d Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 9 Feb 2026 12:15:33 -0500 Subject: [PATCH 09/32] fix: tracing config may have changed --- packages/nitro/src/config.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/nitro/src/config.ts b/packages/nitro/src/config.ts index 57e903f127a6..2cee840372b0 100644 --- a/packages/nitro/src/config.ts +++ b/packages/nitro/src/config.ts @@ -25,10 +25,13 @@ export function setupSentryNitroModule( _moduleOptions?: SentryNitroOptions, _serverConfigFile?: string, ): NitroConfig { - // @ts-expect-error Nitro tracing config is not out yet + // @ts-expect-error Nitro tracing config is not out yet - enable tracing channels for h3 and srvx if (!config.tracing) { + // Explicitly set the full object instead of `true` to avoid relying on Nitro's + // internal normalization (resolveTracingOptions), which may not run in all environments. // @ts-expect-error Nitro tracing config is not out yet - config.tracing = true; + // config.tracing = true; + config.tracing = { srvx: true, h3: true }; } config.modules = config.modules || []; From 57173541fa81051920c18e3959d0a9db5544119d Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 9 Feb 2026 13:53:44 -0500 Subject: [PATCH 10/32] fix: correctly enable the config --- packages/nitro/src/config.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/nitro/src/config.ts b/packages/nitro/src/config.ts index 2cee840372b0..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,13 +23,10 @@ export function setupSentryNitroModule( _moduleOptions?: SentryNitroOptions, _serverConfigFile?: string, ): NitroConfig { - // @ts-expect-error Nitro tracing config is not out yet - enable tracing channels for h3 and srvx + // @ts-expect-error Nitro tracing config is not out yet if (!config.tracing) { - // Explicitly set the full object instead of `true` to avoid relying on Nitro's - // internal normalization (resolveTracingOptions), which may not run in all environments. // @ts-expect-error Nitro tracing config is not out yet - // config.tracing = true; - config.tracing = { srvx: true, h3: true }; + config.tracing = true; } config.modules = config.modules || []; From 361aae683dc1ca785085ef2fe6af8f12dc36df5a Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 9 Feb 2026 14:14:48 -0500 Subject: [PATCH 11/32] fix: configure externals correctly --- packages/nitro/rollup.npm.config.mjs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/nitro/rollup.npm.config.mjs b/packages/nitro/rollup.npm.config.mjs index 58f5b095997e..35b018b2d99c 100644 --- a/packages/nitro/rollup.npm.config.mjs +++ b/packages/nitro/rollup.npm.config.mjs @@ -5,9 +5,8 @@ export default [ makeBaseNPMConfig({ entrypoints: ['src/index.ts', 'src/runtime/plugins/server.ts'], packageSpecificConfig: { - external: [/^nitro/], + external: [/^nitro/, 'otel-tracing-channel', /^h3/, /^srvx/], }, }), - { emitCjs: false }, ), ]; From 945ba10c8227a3a58530035c9c38f74accaa5530 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 9 Feb 2026 14:24:12 -0500 Subject: [PATCH 12/32] test: added e2e tests --- .../test-applications/nitro-3/.npmrc | 2 + .../test-applications/nitro-3/instrument.mjs | 8 +++ .../test-applications/nitro-3/nitro.config.ts | 8 +++ .../test-applications/nitro-3/package.json | 26 ++++++++ .../nitro-3/playwright.config.mjs | 7 +++ .../test-applications/nitro-3/routes/index.ts | 5 ++ .../nitro-3/routes/test-error.ts | 5 ++ .../nitro-3/routes/test-param/[id].ts | 6 ++ .../nitro-3/routes/test-transaction.ts | 5 ++ .../nitro-3/start-event-proxy.mjs | 6 ++ .../nitro-3/tests/errors.test.ts | 45 ++++++++++++++ .../nitro-3/tests/transactions.test.ts | 61 +++++++++++++++++++ .../test-applications/nitro-3/tsconfig.json | 8 +++ 13 files changed, 192 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/nitro-3/.npmrc create mode 100644 dev-packages/e2e-tests/test-applications/nitro-3/instrument.mjs create mode 100644 dev-packages/e2e-tests/test-applications/nitro-3/nitro.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/nitro-3/package.json create mode 100644 dev-packages/e2e-tests/test-applications/nitro-3/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/nitro-3/routes/index.ts create mode 100644 dev-packages/e2e-tests/test-applications/nitro-3/routes/test-error.ts create mode 100644 dev-packages/e2e-tests/test-applications/nitro-3/routes/test-param/[id].ts create mode 100644 dev-packages/e2e-tests/test-applications/nitro-3/routes/test-transaction.ts create mode 100644 dev-packages/e2e-tests/test-applications/nitro-3/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/nitro-3/tests/errors.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nitro-3/tests/transactions.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nitro-3/tsconfig.json 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/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/nitro.config.ts b/dev-packages/e2e-tests/test-applications/nitro-3/nitro.config.ts new file mode 100644 index 000000000000..35db07602783 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/nitro.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'nitro'; +import { withSentryConfig } from '@sentry/nitro'; + +export default withSentryConfig( + defineConfig({ + serverDir: './', + }), +); 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..70fb49540565 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/package.json @@ -0,0 +1,26 @@ +{ + "name": "nitro-3", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "nitro build", + "start": "PORT=3030 NODE_OPTIONS='--import ./instrument.mjs' npx srvx --prod .output/", + "clean": "npx rimraf node_modules pnpm-lock.yaml .output", + "test": "playwright test", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test" + }, + "dependencies": { + "@sentry/nitro": "latest || *" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "nitro": "https://pkg.pr.new/nitrojs/nitro@4001", + "rolldown": "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/routes/index.ts b/dev-packages/e2e-tests/test-applications/nitro-3/routes/index.ts new file mode 100644 index 000000000000..a9fca21eecfb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/routes/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/routes/test-error.ts b/dev-packages/e2e-tests/test-applications/nitro-3/routes/test-error.ts new file mode 100644 index 000000000000..170efb1977ab --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/routes/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/routes/test-param/[id].ts b/dev-packages/e2e-tests/test-applications/nitro-3/routes/test-param/[id].ts new file mode 100644 index 000000000000..ef67525b36ba --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/routes/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/routes/test-transaction.ts b/dev-packages/e2e-tests/test-applications/nitro-3/routes/test-transaction.ts new file mode 100644 index 000000000000..b488b371310d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/routes/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/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..6083f4342497 --- /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('/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('/non-existent-route'); + + expect(errorReceived).toBe(false); +}); 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..fe49e3e84040 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/tests/transactions.test.ts @@ -0,0 +1,61 @@ +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 /test-transaction'; + }); + + await request.get('/test-transaction'); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: 'GET /test-transaction', + type: 'transaction', + }), + ); + + expect(transactionEvent.contexts?.trace).toEqual( + expect.objectContaining({ + op: expect.stringContaining('http'), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }), + ); +}); + +test('Sets correct HTTP status code on transaction', async ({ request }) => { + const transactionEventPromise = waitForTransaction('nitro-3', transactionEvent => { + return transactionEvent?.transaction === 'GET /test-transaction'; + }); + + await request.get('/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('Sends a transaction event for a parameterized route', async ({ request }) => { + const transactionEventPromise = waitForTransaction('nitro-3', transactionEvent => { + return transactionEvent?.transaction === 'GET /test-param/123'; + }); + + await request.get('/test-param/123'); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + type: 'transaction', + }), + ); +}); 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..1099389dd3ad --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": ["nitro/tsconfig"], + "compilerOptions": { + "paths": { + "~/*": ["./*"] + } + } +} From 5c71f67fa1a9fef19a1170ab8ace1ce51dd58d0a Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 9 Feb 2026 14:56:18 -0500 Subject: [PATCH 13/32] test: test isolation scope --- .../test-applications/nitro-3/package.json | 6 +++-- .../nitro-3/routes/test-isolation/[id].ts | 10 ++++++++ .../nitro-3/tests/isolation.test.ts | 25 +++++++++++++++++++ 3 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nitro-3/routes/test-isolation/[id].ts create mode 100644 dev-packages/e2e-tests/test-applications/nitro-3/tests/isolation.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/package.json b/dev-packages/e2e-tests/test-applications/nitro-3/package.json index 70fb49540565..32b5b45c9f97 100644 --- a/dev-packages/e2e-tests/test-applications/nitro-3/package.json +++ b/dev-packages/e2e-tests/test-applications/nitro-3/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "build": "nitro build", - "start": "PORT=3030 NODE_OPTIONS='--import ./instrument.mjs' npx srvx --prod .output/", + "start": "PORT=3030 NODE_OPTIONS='--import ./instrument.mjs' srvx --prod .output/", "clean": "npx rimraf node_modules pnpm-lock.yaml .output", "test": "playwright test", "test:build": "pnpm install && pnpm build", @@ -17,8 +17,10 @@ "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" + "rolldown": "latest", + "srvx": "^0.11.2" }, "volta": { "extends": "../../package.json" diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/routes/test-isolation/[id].ts b/dev-packages/e2e-tests/test-applications/nitro-3/routes/test-isolation/[id].ts new file mode 100644 index 000000000000..a8c2cd7a99f5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/routes/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/tests/isolation.test.ts b/dev-packages/e2e-tests/test-applications/nitro-3/tests/isolation.test.ts new file mode 100644 index 000000000000..c40e7c6a5f75 --- /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 /test-isolation/1'; + }); + + const errorPromise = waitForError('nitro-3', event => { + return !event.type && event.exception?.values?.some(v => v.value === 'Isolation test error'); + }); + + await request.get('/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(); +}); From 67c6a2cd9c902f7601886d405f2a68aa71857781 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 9 Feb 2026 15:52:12 -0500 Subject: [PATCH 14/32] feat: added server timing headers --- .../nitro-3/tests/transactions.test.ts | 9 + .../src/runtime/hooks/captureTracingEvents.ts | 183 +++++++++++++++++ .../runtime/hooks/setServerTimingHeaders.ts | 19 ++ packages/nitro/src/runtime/plugins/server.ts | 184 +----------------- 4 files changed, 218 insertions(+), 177 deletions(-) create mode 100644 packages/nitro/src/runtime/hooks/captureTracingEvents.ts create mode 100644 packages/nitro/src/runtime/hooks/setServerTimingHeaders.ts 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 index fe49e3e84040..4edb427e0ab1 100644 --- 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 @@ -59,3 +59,12 @@ test('Sends a transaction event for a parameterized route', async ({ request }) }), ); }); + +test('Sets Server-Timing response headers for trace propagation', async ({ request }) => { + const response = await request.get('/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/packages/nitro/src/runtime/hooks/captureTracingEvents.ts b/packages/nitro/src/runtime/hooks/captureTracingEvents.ts new file mode 100644 index 000000000000..1f882f630232 --- /dev/null +++ b/packages/nitro/src/runtime/hooks/captureTracingEvents.ts @@ -0,0 +1,183 @@ +import { + captureException, + getActiveSpan, + getClient, + getHttpSpanDetailsFromUrlObject, + GLOBAL_OBJ, + httpHeadersToSpanAttributes, + parseStringToURLObject, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + setHttpStatus, + type Span, + SPAN_STATUS_ERROR, + startSpanManual, +} 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'; + +/** + * 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(); +} + +function setupH3TracingChannels(): void { + const h3Channel = tracingChannel('h3.fetch', data => { + const parsedUrl = parseStringToURLObject(data.event.url.href); + const [spanName, urlAttributes] = getHttpSpanDetailsFromUrlObject(parsedUrl, 'server', 'auto.http.nitro.h3', { + method: data.event.req.method, + }); + + return startSpanManual( + { + name: spanName, + attributes: { + ...urlAttributes, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: data?.type === 'middleware' ? 'middleware.nitro' : 'http.server', + }, + }, + s => s, + ); + }); + + h3Channel.subscribe({ + start: NOOP, + asyncStart: NOOP, + end: NOOP, + asyncEnd: onTraceEnd, + 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.fetch', 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_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.middleware', { + 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_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..f9a05a2d5e72 --- /dev/null +++ b/packages/nitro/src/runtime/hooks/setServerTimingHeaders.ts @@ -0,0 +1,19 @@ +import { getTraceData } from '@sentry/core'; + +/** + * 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(response: unknown, _event: unknown): void { + if (response && typeof response === 'object' && 'headers' in response) { + const responseObj = response as Response; + const traceData = getTraceData(); + + if (traceData['sentry-trace']) { + responseObj.headers.append('Server-Timing', `sentry-trace;desc="${traceData['sentry-trace']}"`); + } + if (traceData.baggage) { + responseObj.headers.append('Server-Timing', `baggage;desc="${traceData.baggage}"`); + } + } +} diff --git a/packages/nitro/src/runtime/plugins/server.ts b/packages/nitro/src/runtime/plugins/server.ts index 888dbc18c7ee..0e1419c81f91 100644 --- a/packages/nitro/src/runtime/plugins/server.ts +++ b/packages/nitro/src/runtime/plugins/server.ts @@ -1,186 +1,16 @@ -import { - captureException, - getActiveSpan, - getClient, - getHttpSpanDetailsFromUrlObject, - GLOBAL_OBJ, - httpHeadersToSpanAttributes, - parseStringToURLObject, - SEMANTIC_ATTRIBUTE_SENTRY_OP, - setHttpStatus, - type Span, - SPAN_STATUS_ERROR, - startSpanManual, -} from '@sentry/core'; -import type { TracingRequestEvent as H3TracingRequestEvent } from 'h3/tracing'; import { definePlugin } from 'nitro'; -import { tracingChannel } from 'otel-tracing-channel'; -import type { RequestEvent as SrvxRequestEvent } from 'srvx/tracing'; import { captureErrorHook } from '../hooks/captureErrorHook'; - -/** - * Global object with the trace channels - */ -const globalWithTraceChannels = GLOBAL_OBJ as typeof GLOBAL_OBJ & { - __SENTRY_NITRO_HTTP_CHANNELS_INSTRUMENTED__: boolean; -}; - -/** - * No-op function to satisfy the tracing channel subscribe callbacks - */ -const NOOP = (): void => {}; +import { captureTracingEvents } from '../hooks/captureTracingEvents'; +import { setServerTimingHeaders } from '../hooks/setServerTimingHeaders'; export default definePlugin(nitroApp => { - if (globalWithTraceChannels.__SENTRY_NITRO_HTTP_CHANNELS_INSTRUMENTED__) { - return; - } - // FIXME: Nitro hooks are not typed it seems // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access nitroApp.hooks.hook('error', captureErrorHook); - setupH3TracingChannels(); - setupSrvxTracingChannels(); - globalWithTraceChannels.__SENTRY_NITRO_HTTP_CHANNELS_INSTRUMENTED__ = true; -}); - -/** - * 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(); -} - -function setupH3TracingChannels(): void { - const h3Channel = tracingChannel('h3.fetch', data => { - const parsedUrl = parseStringToURLObject(data.event.url.href); - const [spanName, urlAttributes] = getHttpSpanDetailsFromUrlObject(parsedUrl, 'server', 'auto.http.nitro.h3', { - method: data.event.req.method, - }); - - return startSpanManual( - { - name: spanName, - attributes: { - ...urlAttributes, - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: data?.type === 'middleware' ? 'middleware.nitro' : 'http.server', - }, - }, - s => s, - ); - }); - - h3Channel.subscribe({ - start: NOOP, - asyncStart: NOOP, - end: NOOP, - asyncEnd: onTraceEnd, - 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.fetch', 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_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.middleware', { - 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_OP]: 'middleware.nitro', - }, - parentSpan: requestParentSpan || undefined, - }, - span => span, - ); - }); + // FIXME: Nitro hooks are not typed it seems + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + nitroApp.hooks.hook('response', setServerTimingHeaders); - // Subscribe to events (span already created in bindStore) - middlewareChannel.subscribe({ - start: () => {}, - asyncStart: () => {}, - end: () => {}, - asyncEnd: onTraceEnd, - error: onTraceError, - }); -} + captureTracingEvents(); +}); From cacda7e3a4b06e5e1d316ff53f0982ea4daaa78d Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 9 Feb 2026 16:14:08 -0500 Subject: [PATCH 15/32] feat: use vite mode for better test coverage --- .../test-applications/nitro-3/index.html | 11 +++++++++++ .../test-applications/nitro-3/nitro.config.ts | 8 -------- .../test-applications/nitro-3/package.json | 7 ++++--- .../nitro-3/{routes => server/api}/index.ts | 0 .../nitro-3/{routes => server/api}/test-error.ts | 0 .../api}/test-isolation/[id].ts | 0 .../{routes => server/api}/test-param/[id].ts | 0 .../{routes => server/api}/test-transaction.ts | 0 .../test-applications/nitro-3/src/main.ts | 10 ++++++++++ .../nitro-3/tests/errors.test.ts | 4 ++-- .../nitro-3/tests/isolation.test.ts | 6 +++--- .../nitro-3/tests/trace-propagation.test.ts | 16 ++++++++++++++++ .../nitro-3/tests/transactions.test.ts | 16 ++++++++-------- .../test-applications/nitro-3/tsconfig.json | 10 ++++++++-- .../test-applications/nitro-3/vite.config.ts | 15 +++++++++++++++ 15 files changed, 77 insertions(+), 26 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nitro-3/index.html delete mode 100644 dev-packages/e2e-tests/test-applications/nitro-3/nitro.config.ts rename dev-packages/e2e-tests/test-applications/nitro-3/{routes => server/api}/index.ts (100%) rename dev-packages/e2e-tests/test-applications/nitro-3/{routes => server/api}/test-error.ts (100%) rename dev-packages/e2e-tests/test-applications/nitro-3/{routes => server/api}/test-isolation/[id].ts (100%) rename dev-packages/e2e-tests/test-applications/nitro-3/{routes => server/api}/test-param/[id].ts (100%) rename dev-packages/e2e-tests/test-applications/nitro-3/{routes => server/api}/test-transaction.ts (100%) create mode 100644 dev-packages/e2e-tests/test-applications/nitro-3/src/main.ts create mode 100644 dev-packages/e2e-tests/test-applications/nitro-3/tests/trace-propagation.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nitro-3/vite.config.ts 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/nitro.config.ts b/dev-packages/e2e-tests/test-applications/nitro-3/nitro.config.ts deleted file mode 100644 index 35db07602783..000000000000 --- a/dev-packages/e2e-tests/test-applications/nitro-3/nitro.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { defineConfig } from 'nitro'; -import { withSentryConfig } from '@sentry/nitro'; - -export default withSentryConfig( - defineConfig({ - serverDir: './', - }), -); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/package.json b/dev-packages/e2e-tests/test-applications/nitro-3/package.json index 32b5b45c9f97..cc137fb81458 100644 --- a/dev-packages/e2e-tests/test-applications/nitro-3/package.json +++ b/dev-packages/e2e-tests/test-applications/nitro-3/package.json @@ -4,14 +4,15 @@ "private": true, "type": "module", "scripts": { - "build": "nitro build", - "start": "PORT=3030 NODE_OPTIONS='--import ./instrument.mjs' srvx --prod .output/", + "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": { @@ -20,7 +21,7 @@ "@sentry/core": "latest || *", "nitro": "https://pkg.pr.new/nitrojs/nitro@4001", "rolldown": "latest", - "srvx": "^0.11.2" + "vite": "latest" }, "volta": { "extends": "../../package.json" diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/routes/index.ts b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/index.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/nitro-3/routes/index.ts rename to dev-packages/e2e-tests/test-applications/nitro-3/server/api/index.ts diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/routes/test-error.ts b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-error.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/nitro-3/routes/test-error.ts rename to dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-error.ts diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/routes/test-isolation/[id].ts b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-isolation/[id].ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/nitro-3/routes/test-isolation/[id].ts rename to dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-isolation/[id].ts diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/routes/test-param/[id].ts b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-param/[id].ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/nitro-3/routes/test-param/[id].ts rename to dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-param/[id].ts diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/routes/test-transaction.ts b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-transaction.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/nitro-3/routes/test-transaction.ts rename to dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-transaction.ts 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/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/nitro-3/tests/errors.test.ts index 6083f4342497..33925b335ae9 100644 --- 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 @@ -6,7 +6,7 @@ test('Sends an error event to Sentry', async ({ request }) => { return !event.type && !!event.exception?.values?.some(v => v.value === 'This is a test error'); }); - await request.get('/test-error'); + await request.get('/api/test-error'); const errorEvent = await errorEventPromise; @@ -39,7 +39,7 @@ test('Does not send 404 errors to Sentry', async ({ request }) => { return false; }); - await request.get('/non-existent-route'); + 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 index c40e7c6a5f75..8a32f25e2d45 100644 --- 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 @@ -3,14 +3,14 @@ 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 /test-isolation/1'; + return event?.transaction === 'GET /api/test-isolation/1'; }); const errorPromise = waitForError('nitro-3', event => { - return !event.type && event.exception?.values?.some(v => v.value === 'Isolation test error'); + return !event.type && !!event.exception?.values?.some(v => v.value === 'Isolation test error'); }); - await request.get('/test-isolation/1').catch(() => { + await request.get('/api/test-isolation/1').catch(() => { // noop - route throws }); 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 index 4edb427e0ab1..022af36988d4 100644 --- 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 @@ -3,16 +3,16 @@ 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 /test-transaction'; + return transactionEvent?.transaction === 'GET /api/test-transaction'; }); - await request.get('/test-transaction'); + await request.get('/api/test-transaction'); const transactionEvent = await transactionEventPromise; expect(transactionEvent).toEqual( expect.objectContaining({ - transaction: 'GET /test-transaction', + transaction: 'GET /api/test-transaction', type: 'transaction', }), ); @@ -28,10 +28,10 @@ test('Sends a transaction event for a successful route', async ({ request }) => test('Sets correct HTTP status code on transaction', async ({ request }) => { const transactionEventPromise = waitForTransaction('nitro-3', transactionEvent => { - return transactionEvent?.transaction === 'GET /test-transaction'; + return transactionEvent?.transaction === 'GET /api/test-transaction'; }); - await request.get('/test-transaction'); + await request.get('/api/test-transaction'); const transactionEvent = await transactionEventPromise; @@ -46,10 +46,10 @@ test('Sets correct HTTP status code on transaction', async ({ request }) => { test('Sends a transaction event for a parameterized route', async ({ request }) => { const transactionEventPromise = waitForTransaction('nitro-3', transactionEvent => { - return transactionEvent?.transaction === 'GET /test-param/123'; + return transactionEvent?.transaction === 'GET /api/test-param/123'; }); - await request.get('/test-param/123'); + await request.get('/api/test-param/123'); const transactionEvent = await transactionEventPromise; @@ -61,7 +61,7 @@ test('Sends a transaction event for a parameterized route', async ({ request }) }); test('Sets Server-Timing response headers for trace propagation', async ({ request }) => { - const response = await request.get('/test-transaction'); + const response = await request.get('/api/test-transaction'); const headers = response.headers(); expect(headers['server-timing']).toBeDefined(); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/tsconfig.json b/dev-packages/e2e-tests/test-applications/nitro-3/tsconfig.json index 1099389dd3ad..b9a951fbebb1 100644 --- a/dev-packages/e2e-tests/test-applications/nitro-3/tsconfig.json +++ b/dev-packages/e2e-tests/test-applications/nitro-3/tsconfig.json @@ -1,8 +1,14 @@ { - "extends": ["nitro/tsconfig"], "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', + }), + ), + ], +}); From 33822ddd3287bb922f94f14d9cafa1eaf1886dae Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 9 Feb 2026 16:47:14 -0500 Subject: [PATCH 16/32] fix: update channel names and always end the spans --- packages/nitro/src/runtime/hooks/captureTracingEvents.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/nitro/src/runtime/hooks/captureTracingEvents.ts b/packages/nitro/src/runtime/hooks/captureTracingEvents.ts index 1f882f630232..edc80c3430d6 100644 --- a/packages/nitro/src/runtime/hooks/captureTracingEvents.ts +++ b/packages/nitro/src/runtime/hooks/captureTracingEvents.ts @@ -57,8 +57,9 @@ 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(); } + + data.span?.end(); } function onTraceError(data: { span?: Span; error: unknown }): void { @@ -68,7 +69,7 @@ function onTraceError(data: { span?: Span; error: unknown }): void { } function setupH3TracingChannels(): void { - const h3Channel = tracingChannel('h3.fetch', data => { + const h3Channel = tracingChannel('h3.request', data => { const parsedUrl = parseStringToURLObject(data.event.url.href); const [spanName, urlAttributes] = getHttpSpanDetailsFromUrlObject(parsedUrl, 'server', 'auto.http.nitro.h3', { method: data.event.req.method, @@ -100,7 +101,7 @@ function setupSrvxTracingChannels(): void { // This ensures they all appear as siblings in the trace let requestParentSpan: Span | null = null; - const fetchChannel = tracingChannel('srvx.fetch', data => { + 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, From e4b8cd34d9b0499e600affa62a3fbc2422c2adef Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 9 Feb 2026 17:04:04 -0500 Subject: [PATCH 17/32] feat: ensure trace channel spans has correct origins --- .../nitro-3/server/middleware/test.ts | 6 ++++++ .../nitro-3/tests/middleware.test.ts | 20 +++++++++++++++++++ .../nitro-3/tests/transactions.test.ts | 14 ++++++------- .../src/runtime/hooks/captureTracingEvents.ts | 6 +++++- 4 files changed, 38 insertions(+), 8 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nitro-3/server/middleware/test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nitro-3/tests/middleware.test.ts 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..4749ec39da57 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/server/middleware/test.ts @@ -0,0 +1,6 @@ +import { defineHandler, setResponseHeader } from 'nitro/h3'; + +export default defineHandler(event => { + // Simple middleware that adds a custom header to verify it ran + setResponseHeader(event, 'x-sentry-test-middleware', 'executed'); +}); 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..16ffd1e9d715 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/tests/middleware.test.ts @@ -0,0 +1,20 @@ +import { expect, test } from '@playwright/test'; +import { 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); +}); 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 index 022af36988d4..d6d185a073ce 100644 --- 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 @@ -17,13 +17,13 @@ test('Sends a transaction event for a successful route', async ({ request }) => }), ); - expect(transactionEvent.contexts?.trace).toEqual( - expect.objectContaining({ - op: expect.stringContaining('http'), - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - }), - ); + // 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 }) => { diff --git a/packages/nitro/src/runtime/hooks/captureTracingEvents.ts b/packages/nitro/src/runtime/hooks/captureTracingEvents.ts index edc80c3430d6..c0a4bb24b8ab 100644 --- a/packages/nitro/src/runtime/hooks/captureTracingEvents.ts +++ b/packages/nitro/src/runtime/hooks/captureTracingEvents.ts @@ -7,6 +7,7 @@ import { httpHeadersToSpanAttributes, parseStringToURLObject, SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, setHttpStatus, type Span, SPAN_STATUS_ERROR, @@ -80,6 +81,7 @@ function setupH3TracingChannels(): void { name: spanName, attributes: { ...urlAttributes, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.nitro.h3', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: data?.type === 'middleware' ? 'middleware.nitro' : 'http.server', }, }, @@ -119,6 +121,7 @@ function setupSrvxTracingChannels(): void { 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, }, @@ -155,7 +158,7 @@ function setupSrvxTracingChannels(): void { } const parsedUrl = data.request._url ? parseStringToURLObject(data.request._url.href) : undefined; - const [, urlAttributes] = getHttpSpanDetailsFromUrlObject(parsedUrl, 'server', 'auto.http.nitro.srvx.middleware', { + const [, urlAttributes] = getHttpSpanDetailsFromUrlObject(parsedUrl, 'server', 'auto.http.nitro.srvx', { method: data.request.method, }); @@ -165,6 +168,7 @@ function setupSrvxTracingChannels(): void { 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, From f5c0e00c6153a2795c59bf91a3f5721e287dfc03 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 9 Feb 2026 17:15:35 -0500 Subject: [PATCH 18/32] test: add middleware error test --- .../nitro-3/server/middleware/test.ts | 8 +++++-- .../nitro-3/tests/middleware.test.ts | 22 ++++++++++++++++++- 2 files changed, 27 insertions(+), 3 deletions(-) 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 index 4749ec39da57..92d8f80c3756 100644 --- 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 @@ -1,6 +1,10 @@ -import { defineHandler, setResponseHeader } from 'nitro/h3'; +import { defineHandler, getQuery, setResponseHeader } from 'nitro/h3'; export default defineHandler(event => { - // Simple middleware that adds a custom header to verify it ran 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/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nitro-3/tests/middleware.test.ts index 16ffd1e9d715..eec281d28f98 100644 --- 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 @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; test('Creates middleware spans for requests', async ({ request }) => { const transactionEventPromise = waitForTransaction('nitro-3', event => { @@ -18,3 +18,23 @@ test('Creates middleware spans for requests', async ({ request }) => { ); 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'); +}); From 2050ad23e0ff0ce986ad9ed66b65d2cdbe1216d4 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 9 Feb 2026 17:37:13 -0500 Subject: [PATCH 19/32] feat: route parameterization --- .../nitro-3/tests/isolation.test.ts | 2 +- .../nitro-3/tests/transactions.test.ts | 12 +++- .../src/runtime/hooks/captureTracingEvents.ts | 59 +++++++++++++++++-- 3 files changed, 66 insertions(+), 7 deletions(-) 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 index 8a32f25e2d45..7234fa0948ca 100644 --- 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 @@ -3,7 +3,7 @@ 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/1'; + return event?.transaction === 'GET /api/test-isolation/:id'; }); const errorPromise = waitForError('nitro-3', event => { 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 index d6d185a073ce..48de9c4349df 100644 --- 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 @@ -44,9 +44,9 @@ test('Sets correct HTTP status code on transaction', async ({ request }) => { expect(transactionEvent.contexts?.trace?.status).toBe('ok'); }); -test('Sends a transaction event for a parameterized route', async ({ request }) => { +test('Uses parameterized route for transaction name', async ({ request }) => { const transactionEventPromise = waitForTransaction('nitro-3', transactionEvent => { - return transactionEvent?.transaction === 'GET /api/test-param/123'; + return transactionEvent?.transaction === 'GET /api/test-param/:id'; }); await request.get('/api/test-param/123'); @@ -55,9 +55,17 @@ test('Sends a transaction event for a parameterized route', async ({ request }) 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 }) => { diff --git a/packages/nitro/src/runtime/hooks/captureTracingEvents.ts b/packages/nitro/src/runtime/hooks/captureTracingEvents.ts index c0a4bb24b8ab..6fff118ac8ad 100644 --- a/packages/nitro/src/runtime/hooks/captureTracingEvents.ts +++ b/packages/nitro/src/runtime/hooks/captureTracingEvents.ts @@ -3,15 +3,18 @@ import { 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'; @@ -69,12 +72,37 @@ function onTraceError(data: { span?: Span; error: unknown }): void { 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 [spanName, urlAttributes] = getHttpSpanDetailsFromUrlObject(parsedUrl, 'server', 'auto.http.nitro.h3', { - method: data.event.req.method, - }); + const routePattern = getParameterizedRoute(data.event); + + const [spanName, urlAttributes] = getHttpSpanDetailsFromUrlObject( + parsedUrl, + 'server', + 'auto.http.nitro.h3', + { method: data.event.req.method }, + routePattern, + ); return startSpanManual( { @@ -93,7 +121,30 @@ function setupH3TracingChannels(): void { start: NOOP, asyncStart: NOOP, end: NOOP, - asyncEnd: onTraceEnd, + 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, }); } From 1c1849878b06a0c36e34c0249a6956294878dbc9 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 12 Feb 2026 13:03:55 -0500 Subject: [PATCH 20/32] fix: set headers before they get frozen --- .../src/runtime/hooks/captureTracingEvents.ts | 5 +++- .../runtime/hooks/setServerTimingHeaders.ts | 28 ++++++++++++------- packages/nitro/src/runtime/plugins/server.ts | 5 ---- 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/packages/nitro/src/runtime/hooks/captureTracingEvents.ts b/packages/nitro/src/runtime/hooks/captureTracingEvents.ts index 6fff118ac8ad..2dbabd1219aa 100644 --- a/packages/nitro/src/runtime/hooks/captureTracingEvents.ts +++ b/packages/nitro/src/runtime/hooks/captureTracingEvents.ts @@ -19,6 +19,7 @@ import { 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 @@ -118,7 +119,9 @@ function setupH3TracingChannels(): void { }); h3Channel.subscribe({ - start: NOOP, + start: (data: H3TracingRequestEvent) => { + setServerTimingHeaders(data.event); + }, asyncStart: NOOP, end: NOOP, asyncEnd: (data: H3TracingRequestEvent & { span?: Span; result?: unknown }) => { diff --git a/packages/nitro/src/runtime/hooks/setServerTimingHeaders.ts b/packages/nitro/src/runtime/hooks/setServerTimingHeaders.ts index f9a05a2d5e72..4573f8171c19 100644 --- a/packages/nitro/src/runtime/hooks/setServerTimingHeaders.ts +++ b/packages/nitro/src/runtime/hooks/setServerTimingHeaders.ts @@ -1,19 +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(response: unknown, _event: unknown): void { - if (response && typeof response === 'object' && 'headers' in response) { - const responseObj = response as Response; - const traceData = getTraceData(); +export function setServerTimingHeaders(event: H3TracingRequestEvent['event']): void { + if (event.context._sentryServerTimingSet) { + return; + } + + const headers = event.res?.headers; + if (!headers) { + return; + } - if (traceData['sentry-trace']) { - responseObj.headers.append('Server-Timing', `sentry-trace;desc="${traceData['sentry-trace']}"`); - } - if (traceData.baggage) { - responseObj.headers.append('Server-Timing', `baggage;desc="${traceData.baggage}"`); - } + 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 index 0e1419c81f91..a46880df000a 100644 --- a/packages/nitro/src/runtime/plugins/server.ts +++ b/packages/nitro/src/runtime/plugins/server.ts @@ -1,16 +1,11 @@ import { definePlugin } from 'nitro'; import { captureErrorHook } from '../hooks/captureErrorHook'; import { captureTracingEvents } from '../hooks/captureTracingEvents'; -import { setServerTimingHeaders } from '../hooks/setServerTimingHeaders'; 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); - // FIXME: Nitro hooks are not typed it seems - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - nitroApp.hooks.hook('response', setServerTimingHeaders); - captureTracingEvents(); }); From 0334770cce6bd9863fc027365a6f7cd2380d8f9f Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 15 Apr 2026 09:29:23 -0400 Subject: [PATCH 21/32] fix: update config name --- packages/nitro/src/config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/nitro/src/config.ts b/packages/nitro/src/config.ts index 9b22023735e3..81ed1c0a5c01 100644 --- a/packages/nitro/src/config.ts +++ b/packages/nitro/src/config.ts @@ -24,9 +24,9 @@ export function setupSentryNitroModule( _serverConfigFile?: string, ): NitroConfig { // @ts-expect-error Nitro tracing config is not out yet - if (!config.tracing) { + if (!config.tracingChannel) { // @ts-expect-error Nitro tracing config is not out yet - config.tracing = true; + config.tracingChannel = true; } config.modules = config.modules || []; From 88f66e785aa7d0fc2438023e0a337aae56dae4f2 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 15 Apr 2026 09:41:38 -0400 Subject: [PATCH 22/32] chore: pin versions properly --- .../test-applications/nitro-3/package.json | 2 +- packages/nitro/package.json | 4 +- packages/nitro/src/config.ts | 2 - yarn.lock | 216 +++++++++--------- 4 files changed, 117 insertions(+), 107 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/package.json b/dev-packages/e2e-tests/test-applications/nitro-3/package.json index cc137fb81458..ab92769115d1 100644 --- a/dev-packages/e2e-tests/test-applications/nitro-3/package.json +++ b/dev-packages/e2e-tests/test-applications/nitro-3/package.json @@ -19,7 +19,7 @@ "@playwright/test": "~1.56.0", "@sentry-internal/test-utils": "link:../../../test-utils", "@sentry/core": "latest || *", - "nitro": "https://pkg.pr.new/nitrojs/nitro@4001", + "nitro": "^3.0.260415-beta", "rolldown": "latest", "vite": "latest" }, diff --git a/packages/nitro/package.json b/packages/nitro/package.json index cdce5bff3685..5105e555acca 100644 --- a/packages/nitro/package.json +++ b/packages/nitro/package.json @@ -39,9 +39,11 @@ }, "dependencies": { "@sentry/core": "10.49.0", - "@sentry/node": "10.49.0" + "@sentry/node": "10.49.0", + "otel-tracing-channel": "^0.2.0" }, "devDependencies": { + "h3": "^2.0.1-rc.13", "nitro": "^3.0.260415-beta" }, "scripts": { diff --git a/packages/nitro/src/config.ts b/packages/nitro/src/config.ts index 81ed1c0a5c01..219eb453fb18 100644 --- a/packages/nitro/src/config.ts +++ b/packages/nitro/src/config.ts @@ -23,9 +23,7 @@ export function setupSentryNitroModule( _moduleOptions?: SentryNitroOptions, _serverConfigFile?: string, ): NitroConfig { - // @ts-expect-error Nitro tracing config is not out yet if (!config.tracingChannel) { - // @ts-expect-error Nitro tracing config is not out yet config.tracingChannel = true; } diff --git a/yarn.lock b/yarn.lock index 3422b0cb19ad..4226f7fefe82 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5308,10 +5308,10 @@ "@emnapi/runtime" "^1.4.3" "@tybys/wasm-util" "^0.10.0" -"@napi-rs/wasm-runtime@^1.1.4": - version "1.1.4" - resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz#a46bbfedc29751b7170c5d23bc1d8ee8c7e3c1e1" - integrity sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow== +"@napi-rs/wasm-runtime@^1.1.3": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz#1eeb8699770481306e5fcd84471f20fcb6177336" + integrity sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ== dependencies: "@tybys/wasm-util" "^0.10.1" @@ -6445,10 +6445,10 @@ resolved "https://registry.yarnpkg.com/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.76.0.tgz#3dbef82283f871c9cb59325c9daf4f740d11a6e9" integrity sha512-0jLzzmnu8/mqNhKBnNS2lFUbPEzRdj5ReiZwHGHpjma0+ullmmwP2AqSEqx3ssHDK9CpcEMdKOK2LsbCfhHKIA== -"@oxc-project/types@=0.126.0": - version "0.126.0" - resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.126.0.tgz#9d9fa6fe9af5bc6c45996c6d9b9a3b3a4cd500e5" - integrity sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ== +"@oxc-project/types@=0.124.0": + version "0.124.0" + resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.124.0.tgz#1dfd7b3fbb98febc2f91b505f48c940db73c8701" + integrity sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg== "@oxc-project/types@^0.76.0": version "0.76.0" @@ -7160,86 +7160,91 @@ dependencies: web-streams-polyfill "^3.1.1" -"@rolldown/binding-android-arm64@1.0.0-rc.16": - version "1.0.0-rc.16" - resolved "https://registry.yarnpkg.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.16.tgz#9af7872d363738e7a2aaa1c1be8cad57adf75798" - integrity sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA== - -"@rolldown/binding-darwin-arm64@1.0.0-rc.16": - version "1.0.0-rc.16" - resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.16.tgz#88f394f20c664ac2c51fe5d5d364b94bbf8ef430" - integrity sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ== - -"@rolldown/binding-darwin-x64@1.0.0-rc.16": - version "1.0.0-rc.16" - resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.16.tgz#d5350b1d3d13fddb1bc5abb00cadc07787a5d6fa" - integrity sha512-r/OmdR00HmD4i79Z//xO06uEPOq5hRXdhw7nzkxQxwSavs3PSHa1ijntdpOiZ2mzOQ3fVVu8C1M19FoNM+dMUQ== - -"@rolldown/binding-freebsd-x64@1.0.0-rc.16": - version "1.0.0-rc.16" - resolved "https://registry.yarnpkg.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.16.tgz#116fe2b906ef658e913bd1419775114dee97c35f" - integrity sha512-KcRE5w8h0OnjUatG8pldyD14/CQ5Phs1oxfR+3pKDjboHRo9+MkqQaiIZlZRpsxC15paeXme/I127tUa9TXJ6g== - -"@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.16": - version "1.0.0-rc.16" - resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.16.tgz#3a72b393936c580b40aa66230cdc30ac20fb0409" - integrity sha512-bT0guA1bpxEJ/ZhTRniQf7rNF8ybvXOuWbNIeLABaV5NGjx4EtOWBTSRGWFU9ZWVkPOZ+HNFP8RMcBokBiZ0Kg== - -"@rolldown/binding-linux-arm64-gnu@1.0.0-rc.16": - version "1.0.0-rc.16" - resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.16.tgz#3ec9b2dce7b5c29d37272fa3a1aee6159badfb76" - integrity sha512-+tHktCHWV8BDQSjemUqm/Jl/TPk3QObCTIjmdDy/nlupcujZghmKK2962LYrqFpWu+ai01AN/REOH3NEpqvYQg== - -"@rolldown/binding-linux-arm64-musl@1.0.0-rc.16": - version "1.0.0-rc.16" - resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.16.tgz#4103d75b7e7f2650d32fef0df01ff5441657b6ee" - integrity sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg== - -"@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.16": - version "1.0.0-rc.16" - resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.16.tgz#4bff51a9d0c4c5ec402ac10f41cef22d6a21889c" - integrity sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ== - -"@rolldown/binding-linux-s390x-gnu@1.0.0-rc.16": - version "1.0.0-rc.16" - resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.16.tgz#7b9399eda0b2e49c7e5d2b98172196565de3709f" - integrity sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ== - -"@rolldown/binding-linux-x64-gnu@1.0.0-rc.16": - version "1.0.0-rc.16" - resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.16.tgz#82b64f4c9aa018718c27a11fc5f8e9141f1c3276" - integrity sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg== - -"@rolldown/binding-linux-x64-musl@1.0.0-rc.16": - version "1.0.0-rc.16" - resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.16.tgz#710c4bf32715d5564fd7bb39bfbe9195f0e8b9a6" - integrity sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w== - -"@rolldown/binding-openharmony-arm64@1.0.0-rc.16": - version "1.0.0-rc.16" - resolved "https://registry.yarnpkg.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.16.tgz#ab5cc4736ff363c4fad67c017edf4634c036e82a" - integrity sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA== - -"@rolldown/binding-wasm32-wasi@1.0.0-rc.16": - version "1.0.0-rc.16" - resolved "https://registry.yarnpkg.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.16.tgz#906dec98ca584cec655a336fca870ac7095fbe93" - integrity sha512-3Q2KQxnC8IJOLqXmUMoYwyIPZU9hzRbnHaoV3Euz+VVnjZKcY8ktnNP8T9R4/GGQtb27C/UYKABxesKWb8lsvQ== +"@rolldown/binding-android-arm64@1.0.0-rc.15": + version "1.0.0-rc.15" + resolved "https://registry.yarnpkg.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz#ca20574c469ade7b941f90c9af5e83e7c67f06b7" + integrity sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA== + +"@rolldown/binding-darwin-arm64@1.0.0-rc.15": + version "1.0.0-rc.15" + resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz#ce2c5c7fc4958dfc94783dc09b3d09f3c2e1d072" + integrity sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg== + +"@rolldown/binding-darwin-x64@1.0.0-rc.15": + version "1.0.0-rc.15" + resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz#251ecdf1fdb751031cb6486907c105daaf9dab21" + integrity sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw== + +"@rolldown/binding-freebsd-x64@1.0.0-rc.15": + version "1.0.0-rc.15" + resolved "https://registry.yarnpkg.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz#dbcfe95f409bf671a77bd83bff0fdc877d217728" + integrity sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw== + +"@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15": + version "1.0.0-rc.15" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz#ea002b45445be6f9ed1883a834b335bc2ccd510f" + integrity sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA== + +"@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15": + version "1.0.0-rc.15" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz#12b96e7e7821a9dc2cd5c670ad56882987ed5c62" + integrity sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w== + +"@rolldown/binding-linux-arm64-musl@1.0.0-rc.15": + version "1.0.0-rc.15" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz#738b0f62f0b65bf676dfe48595017f1883859d1f" + integrity sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ== + +"@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15": + version "1.0.0-rc.15" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz#3088b9fbc2783033985b558316f87f39281bc533" + integrity sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ== + +"@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15": + version "1.0.0-rc.15" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz#ac0aa6f1b72e3151d56c43145a71c745cf862a9a" + integrity sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ== + +"@rolldown/binding-linux-x64-gnu@1.0.0-rc.15": + version "1.0.0-rc.15" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz#b8cf27aa5be6da641c22dad5665d0240551d2dec" + integrity sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA== + +"@rolldown/binding-linux-x64-musl@1.0.0-rc.15": + version "1.0.0-rc.15" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz#4531f9eca77963935026634ba9b61c2535340534" + integrity sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw== + +"@rolldown/binding-openharmony-arm64@1.0.0-rc.15": + version "1.0.0-rc.15" + resolved "https://registry.yarnpkg.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz#66ff691a65f9325171bced98e353b4cc4b0095c3" + integrity sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg== + +"@rolldown/binding-wasm32-wasi@1.0.0-rc.15": + version "1.0.0-rc.15" + resolved "https://registry.yarnpkg.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz#7db6c90aa510eef65d7d0f14e8ca23775e8e5eee" + integrity sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q== dependencies: "@emnapi/core" "1.9.2" "@emnapi/runtime" "1.9.2" - "@napi-rs/wasm-runtime" "^1.1.4" + "@napi-rs/wasm-runtime" "^1.1.3" -"@rolldown/binding-win32-arm64-msvc@1.0.0-rc.16": - version "1.0.0-rc.16" - resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.16.tgz#19dd3cf898727fad4f9209cf2aae829a789a9348" - integrity sha512-tj7XRemQcOcFwv7qhpUxMTBbI5mWMlE4c1Omhg5+h8GuLXzyj8HviYgR+bB2DMDgRqUE+jiDleqSCRjx4aYk/Q== +"@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15": + version "1.0.0-rc.15" + resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz#81f9097abbd4493cc13373b26f5a3da8461dbb47" + integrity sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA== -"@rolldown/binding-win32-x64-msvc@1.0.0-rc.16": - version "1.0.0-rc.16" - resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.16.tgz#94f8930ac50d62c5d9a1a14855125aa945a14234" - integrity sha512-PH5DRZT+F4f2PTXRXR8uJxnBq2po/xFtddyabTJVJs/ZYVHqXPEgNIr35IHTEa6bpa0Q8Awg+ymkTaGnKITw4g== +"@rolldown/binding-win32-x64-msvc@1.0.0-rc.15": + version "1.0.0-rc.15" + resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz#cef11bc89149f3a77771727be75490fbb13ae193" + integrity sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g== -"@rolldown/pluginutils@1.0.0-rc.16", "@rolldown/pluginutils@^1.0.0-beta.9": +"@rolldown/pluginutils@1.0.0-rc.15": + version "1.0.0-rc.15" + resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz#e75d7731593e195d23710f9ff49bf5c745c96682" + integrity sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g== + +"@rolldown/pluginutils@^1.0.0-beta.9": version "1.0.0-rc.16" resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.16.tgz#bc27c8f906309b57c6c10eddb21043fd8e86b87e" integrity sha512-45+YtqxLYKDWQouLKCrpIZhke+nXxhsw+qAHVzHDVwttyBlHNBVs2K25rDXrZzhpTp9w1FlAlvweV1H++fdZoA== @@ -18356,7 +18361,7 @@ 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.16, h3@^2.0.1-rc.20: +h3@^2.0.1-rc.13, h3@^2.0.1-rc.16, h3@^2.0.1-rc.20: version "2.0.1-rc.20" resolved "https://registry.yarnpkg.com/h3/-/h3-2.0.1-rc.20.tgz#51050db30afb0b6e69718d88cccc23666fbe8039" integrity sha512-28ljodXuUp0fZovdiSRq4G9OgrxCztrJe5VdYzXAB7ueRvI7pIUqLU14Xi3XqdYJ/khXjfpUOOD2EQa6CmBgsg== @@ -23645,6 +23650,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" @@ -26517,28 +26527,28 @@ roarr@^7.0.4: semver-compare "^1.0.0" rolldown@^1.0.0-rc.15, rolldown@^1.0.0-rc.8: - version "1.0.0-rc.16" - resolved "https://registry.yarnpkg.com/rolldown/-/rolldown-1.0.0-rc.16.tgz#47c1e6b088be3f531a9aacbdb8a90e2255f02702" - integrity sha512-rzi5WqKzEZw3SooTt7cgm4eqIoujPIyGcJNGFL7iPEuajQw7vxMHUkXylu4/vhCkJGXsgRmxqMKXUpT6FEgl0g== + version "1.0.0-rc.15" + resolved "https://registry.yarnpkg.com/rolldown/-/rolldown-1.0.0-rc.15.tgz#ea3526443b2dbe834e9f8f6c1fde6232ec687170" + integrity sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g== dependencies: - "@oxc-project/types" "=0.126.0" - "@rolldown/pluginutils" "1.0.0-rc.16" + "@oxc-project/types" "=0.124.0" + "@rolldown/pluginutils" "1.0.0-rc.15" optionalDependencies: - "@rolldown/binding-android-arm64" "1.0.0-rc.16" - "@rolldown/binding-darwin-arm64" "1.0.0-rc.16" - "@rolldown/binding-darwin-x64" "1.0.0-rc.16" - "@rolldown/binding-freebsd-x64" "1.0.0-rc.16" - "@rolldown/binding-linux-arm-gnueabihf" "1.0.0-rc.16" - "@rolldown/binding-linux-arm64-gnu" "1.0.0-rc.16" - "@rolldown/binding-linux-arm64-musl" "1.0.0-rc.16" - "@rolldown/binding-linux-ppc64-gnu" "1.0.0-rc.16" - "@rolldown/binding-linux-s390x-gnu" "1.0.0-rc.16" - "@rolldown/binding-linux-x64-gnu" "1.0.0-rc.16" - "@rolldown/binding-linux-x64-musl" "1.0.0-rc.16" - "@rolldown/binding-openharmony-arm64" "1.0.0-rc.16" - "@rolldown/binding-wasm32-wasi" "1.0.0-rc.16" - "@rolldown/binding-win32-arm64-msvc" "1.0.0-rc.16" - "@rolldown/binding-win32-x64-msvc" "1.0.0-rc.16" + "@rolldown/binding-android-arm64" "1.0.0-rc.15" + "@rolldown/binding-darwin-arm64" "1.0.0-rc.15" + "@rolldown/binding-darwin-x64" "1.0.0-rc.15" + "@rolldown/binding-freebsd-x64" "1.0.0-rc.15" + "@rolldown/binding-linux-arm-gnueabihf" "1.0.0-rc.15" + "@rolldown/binding-linux-arm64-gnu" "1.0.0-rc.15" + "@rolldown/binding-linux-arm64-musl" "1.0.0-rc.15" + "@rolldown/binding-linux-ppc64-gnu" "1.0.0-rc.15" + "@rolldown/binding-linux-s390x-gnu" "1.0.0-rc.15" + "@rolldown/binding-linux-x64-gnu" "1.0.0-rc.15" + "@rolldown/binding-linux-x64-musl" "1.0.0-rc.15" + "@rolldown/binding-openharmony-arm64" "1.0.0-rc.15" + "@rolldown/binding-wasm32-wasi" "1.0.0-rc.15" + "@rolldown/binding-win32-arm64-msvc" "1.0.0-rc.15" + "@rolldown/binding-win32-x64-msvc" "1.0.0-rc.15" rollup-plugin-cleanup@^3.2.1: version "3.2.1" From 76cb453f9dcda2ca9a2fbb7563b7fb6e8b2220de Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 15 Apr 2026 10:55:44 -0400 Subject: [PATCH 23/32] chore: add canary entry --- .github/workflows/canary.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index ac4e1df08841..bbfdba31161f 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -120,6 +120,9 @@ jobs: - test-application: 'nestjs-microservices' build-command: 'test:build-latest' label: 'nestjs-microservices (latest)' + - test-application: 'nitro-3' + build-command: 'test:build-canary' + label: 'nitro-3 (canary)' steps: - name: Check out current commit From 45229f9c7931690e60ac649389f388c299e01e74 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 16 Apr 2026 08:15:48 -0400 Subject: [PATCH 24/32] fix: added error capturing mechanisms --- .../e2e-tests/test-applications/nitro-3/tests/errors.test.ts | 2 +- packages/nitro/src/runtime/hooks/captureErrorHook.ts | 2 +- packages/nitro/src/runtime/hooks/captureTracingEvents.ts | 2 +- packages/nitro/test/runtime/hooks/captureErrorHook.test.ts | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) 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 index 33925b335ae9..8e419ac9ba62 100644 --- 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 @@ -19,7 +19,7 @@ test('Sends an error event to Sentry', async ({ request }) => { expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual( expect.objectContaining({ handled: false, - type: 'auto.function.nitro', + type: 'auto.function.nitro.captureErrorHook', }), ); diff --git a/packages/nitro/src/runtime/hooks/captureErrorHook.ts b/packages/nitro/src/runtime/hooks/captureErrorHook.ts index 7f56d8d74b2f..6fd7e29910cf 100644 --- a/packages/nitro/src/runtime/hooks/captureErrorHook.ts +++ b/packages/nitro/src/runtime/hooks/captureErrorHook.ts @@ -70,7 +70,7 @@ export async function captureErrorHook(error: Error, errorContext: CapturedError captureException(error, { captureContext: { contexts: { nitro: structuredContext } }, - mechanism: { handled: false, type: 'auto.function.nitro' }, + mechanism: { handled: false, type: 'auto.function.nitro.captureErrorHook' }, }); await flushIfServerless(); diff --git a/packages/nitro/src/runtime/hooks/captureTracingEvents.ts b/packages/nitro/src/runtime/hooks/captureTracingEvents.ts index 2dbabd1219aa..4fef4347d74a 100644 --- a/packages/nitro/src/runtime/hooks/captureTracingEvents.ts +++ b/packages/nitro/src/runtime/hooks/captureTracingEvents.ts @@ -68,7 +68,7 @@ function onTraceEnd(data: { span?: Span; result?: unknown }): void { } function onTraceError(data: { span?: Span; error: unknown }): void { - captureException(data.error); + captureException(data.error, { mechanism: { type: 'auto.http.nitro.onTraceError', handled: false } }); data.span?.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); data.span?.end(); } diff --git a/packages/nitro/test/runtime/hooks/captureErrorHook.test.ts b/packages/nitro/test/runtime/hooks/captureErrorHook.test.ts index 2f288a4719ef..804ef569a619 100644 --- a/packages/nitro/test/runtime/hooks/captureErrorHook.test.ts +++ b/packages/nitro/test/runtime/hooks/captureErrorHook.test.ts @@ -39,7 +39,7 @@ describe('captureErrorHook', () => { expect(SentryCore.captureException).toHaveBeenCalledWith( error, expect.objectContaining({ - mechanism: { handled: false, type: 'auto.function.nitro' }, + mechanism: { handled: false, type: 'auto.function.nitro.captureErrorHook' }, }), ); }); @@ -98,7 +98,7 @@ describe('captureErrorHook', () => { expect(SentryCore.captureException).toHaveBeenCalledWith( error, expect.objectContaining({ - mechanism: { handled: false, type: 'auto.function.nitro' }, + mechanism: { handled: false, type: 'auto.function.nitro.captureErrorHook' }, }), ); }); From 647e65aa1096094424279b77d6456f6d69222614 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 16 Apr 2026 08:24:45 -0400 Subject: [PATCH 25/32] chore: nitro hooks are typed properly now --- packages/nitro/src/runtime/plugins/server.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/nitro/src/runtime/plugins/server.ts b/packages/nitro/src/runtime/plugins/server.ts index a46880df000a..2feee84bcc55 100644 --- a/packages/nitro/src/runtime/plugins/server.ts +++ b/packages/nitro/src/runtime/plugins/server.ts @@ -3,8 +3,6 @@ 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(); From 9299ba9f368ad5bb78f8db4debe88d287e3a50f7 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 16 Apr 2026 08:25:57 -0400 Subject: [PATCH 26/32] fix: ensure we rename the root span as well --- .../nitro/src/runtime/hooks/captureErrorHook.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/nitro/src/runtime/hooks/captureErrorHook.ts b/packages/nitro/src/runtime/hooks/captureErrorHook.ts index 6fd7e29910cf..73bd15aa4d1b 100644 --- a/packages/nitro/src/runtime/hooks/captureErrorHook.ts +++ b/packages/nitro/src/runtime/hooks/captureErrorHook.ts @@ -1,4 +1,12 @@ -import { captureException, flushIfServerless, getClient, getCurrentScope } from '@sentry/core'; +import { + captureException, + flushIfServerless, + getActiveSpan, + getClient, + getCurrentScope, + getRootSpan, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, +} from '@sentry/core'; import { HTTPError } from 'h3'; import type { CapturedErrorContext } from 'nitro/types'; @@ -64,6 +72,10 @@ export async function captureErrorHook(error: Error, errorContext: CapturedError if (path) { getCurrentScope().setTransactionName(`${method} ${path}`); + const activeSpan = getActiveSpan(); + const activeRootSpan = activeSpan ? getRootSpan(activeSpan) : undefined; + activeRootSpan?.updateName(`${method} ${path}`); + activeRootSpan?.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); } const structuredContext = extractErrorContext(errorContext); From 2ba552a1cfa256be5004aadec9d9e2e95f2a2a41 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 16 Apr 2026 08:30:32 -0400 Subject: [PATCH 27/32] refactor: use WeakMap for per-request parent span management in tracing channels --- .../src/runtime/hooks/captureTracingEvents.ts | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/packages/nitro/src/runtime/hooks/captureTracingEvents.ts b/packages/nitro/src/runtime/hooks/captureTracingEvents.ts index 4fef4347d74a..e6b0ba34b1b0 100644 --- a/packages/nitro/src/runtime/hooks/captureTracingEvents.ts +++ b/packages/nitro/src/runtime/hooks/captureTracingEvents.ts @@ -153,9 +153,9 @@ function setupH3TracingChannels(): void { } 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; + // Store the parent span per-request so middleware and fetch share the same parent. + // WeakMap ensures per-request isolation in concurrent environments and automatic cleanup. + const requestParentSpans = new WeakMap(); const fetchChannel = tracingChannel('srvx.request', data => { const parsedUrl = data.request._url ? parseStringToURLObject(data.request._url.href) : undefined; @@ -180,7 +180,7 @@ function setupSrvxTracingChannels(): void { 'server.port': data.server.options.port, }, // Use the same parent span as middleware to make them siblings - parentSpan: requestParentSpan || undefined, + parentSpan: requestParentSpans.get(data.request) || undefined, }, span => span, ); @@ -194,21 +194,23 @@ function setupSrvxTracingChannels(): void { 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; + // Clean up parent span reference after the fetch handler completes. + requestParentSpans.delete(data.request); }, error: data => { onTraceError(data); - // Reset parent span reference on error too - requestParentSpan = null; + // Clean up parent span reference on error too + requestParentSpans.delete(data.request); }, }); const middlewareChannel = tracingChannel('srvx.middleware', data => { - // For the first middleware, capture the current parent span + // For the first middleware, capture the current parent span per-request if (data.middleware?.index === 0) { - requestParentSpan = getActiveSpan() || null; + const activeSpan = getActiveSpan(); + if (activeSpan) { + requestParentSpans.set(data.request, activeSpan); + } } const parsedUrl = data.request._url ? parseStringToURLObject(data.request._url.href) : undefined; @@ -225,7 +227,7 @@ function setupSrvxTracingChannels(): void { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.nitro.srvx', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'middleware.nitro', }, - parentSpan: requestParentSpan || undefined, + parentSpan: requestParentSpans.get(data.request) || undefined, }, span => span, ); From fef717145a5cfe8a404c6b65f006ab125e6551b3 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 16 Apr 2026 08:43:44 -0400 Subject: [PATCH 28/32] fix: set paramaterized route path attributes --- .../src/runtime/hooks/captureTracingEvents.ts | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/packages/nitro/src/runtime/hooks/captureTracingEvents.ts b/packages/nitro/src/runtime/hooks/captureTracingEvents.ts index e6b0ba34b1b0..cd65086122a4 100644 --- a/packages/nitro/src/runtime/hooks/captureTracingEvents.ts +++ b/packages/nitro/src/runtime/hooks/captureTracingEvents.ts @@ -114,7 +114,11 @@ function setupH3TracingChannels(): void { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: data?.type === 'middleware' ? 'middleware.nitro' : 'http.server', }, }, - s => s, + span => { + setParameterizedRouteAttributes(span, data.event); + + return span; + }, ); }); @@ -242,3 +246,35 @@ function setupSrvxTracingChannels(): void { error: onTraceError, }); } + +/** + * Sets the parameterized route attributes on the span. + */ +function setParameterizedRouteAttributes(span: Span, event: H3TracingRequestEvent['event']): void { + const rootSpan = getRootSpan(span); + if (!rootSpan) { + return; + } + + const matchedRoutePath = getParameterizedRoute(event); + if (!matchedRoutePath) { + return; + } + + rootSpan.setAttributes({ + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + 'http.route': matchedRoutePath, + }); + + const params = event.context?.params; + + if (params && typeof params === 'object') { + Object.entries(params).forEach(([key, value]) => { + // Based on this convention: https://getsentry.github.io/sentry-conventions/generated/attributes/url.html#urlpathparameterkey + rootSpan.setAttributes({ + [`url.path.parameter.${key}`]: String(value), + [`params.${key}`]: String(value), + }); + }); + } +} From 33b11e94415d20554b343276c721685e2e27ca53 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 16 Apr 2026 09:20:50 -0400 Subject: [PATCH 29/32] fix: use url parsing util instead --- packages/nitro/src/runtime/hooks/captureErrorHook.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/nitro/src/runtime/hooks/captureErrorHook.ts b/packages/nitro/src/runtime/hooks/captureErrorHook.ts index 73bd15aa4d1b..38ab4fd699fa 100644 --- a/packages/nitro/src/runtime/hooks/captureErrorHook.ts +++ b/packages/nitro/src/runtime/hooks/captureErrorHook.ts @@ -5,6 +5,7 @@ import { getClient, getCurrentScope, getRootSpan, + parseUrl, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, } from '@sentry/core'; import { HTTPError } from 'h3'; @@ -23,13 +24,7 @@ function extractErrorContext(errorContext: CapturedErrorContext | undefined): Re 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 - } + ctx.path = parseUrl(errorContext.event.req.url).path; } if (Array.isArray(errorContext.tags)) { From 2491ade1172bee5ea905026df4ba00557b411eb0 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 16 Apr 2026 11:53:03 -0400 Subject: [PATCH 30/32] fix: use vendored tracingChannel and fix span ending in nitro tracing hooks Replace otel-tracing-channel with @sentry/opentelemetry's vendored tracingChannel. Fix data.span -> data._sentrySpan references so tracing channel spans are actually ended. Export TracingChannelContextWithSpan type for consumers. Add e2e span nesting tests to verify context propagation through the full srvx -> h3 -> user code span tree. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../nitro-3/server/api/test-nesting.ts | 16 ++ .../nitro-3/tests/span-nesting.test.ts | 146 ++++++++++++++++++ packages/nitro/package.json | 2 +- packages/nitro/rollup.npm.config.mjs | 2 +- .../src/runtime/hooks/captureTracingEvents.ts | 24 +-- packages/opentelemetry/src/tracingChannel.ts | 12 +- yarn.lock | 5 - 7 files changed, 182 insertions(+), 25 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-nesting.ts create mode 100644 dev-packages/e2e-tests/test-applications/nitro-3/tests/span-nesting.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-nesting.ts b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-nesting.ts new file mode 100644 index 000000000000..687c6f3f1e9a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-nesting.ts @@ -0,0 +1,16 @@ +import { startSpan } from '@sentry/nitro'; +import { defineHandler } from 'nitro/h3'; + +export default defineHandler(() => { + startSpan({ name: 'db.select', op: 'db' }, () => { + // simulate a select query + }); + + startSpan({ name: 'db.insert', op: 'db' }, () => { + startSpan({ name: 'db.serialize', op: 'serialize' }, () => { + // simulate serializing data before insert + }); + }); + + return { status: 'ok', nesting: true }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/tests/span-nesting.test.ts b/dev-packages/e2e-tests/test-applications/nitro-3/tests/span-nesting.test.ts new file mode 100644 index 000000000000..090f8af36fb2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/tests/span-nesting.test.ts @@ -0,0 +1,146 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Span nesting: all spans share the same trace_id', async ({ request }) => { + const transactionEventPromise = waitForTransaction('nitro-3', event => { + return event?.transaction === 'GET /api/test-nesting'; + }); + + await request.get('/api/test-nesting'); + + const event = await transactionEventPromise; + const traceId = event.contexts?.trace?.trace_id; + + expect(traceId).toMatch(/[a-f0-9]{32}/); + + // Every child span must belong to the same trace + for (const span of event.spans ?? []) { + expect(span.trace_id).toBe(traceId); + } +}); + +test('Span nesting: h3 middleware spans are children of the srvx request span', async ({ request }) => { + const transactionEventPromise = waitForTransaction('nitro-3', event => { + return event?.transaction === 'GET /api/test-nesting'; + }); + + await request.get('/api/test-nesting'); + + const event = await transactionEventPromise; + + // Find the srvx request span + const srvxSpan = event.spans?.find(span => span.origin === 'auto.http.nitro.srvx' && span.op === 'http.server'); + expect(srvxSpan).toBeDefined(); + + // All h3 middleware spans should be children of the srvx span + const h3Spans = event.spans?.filter(span => span.origin === 'auto.http.nitro.h3'); + expect(h3Spans?.length).toBeGreaterThanOrEqual(1); + + for (const span of h3Spans ?? []) { + expect(span.parent_span_id).toBe(srvxSpan!.span_id); + } +}); + +test('Span nesting: manual startSpan calls inside route handler are children of the srvx request span', async ({ + request, +}) => { + const transactionEventPromise = waitForTransaction('nitro-3', event => { + return event?.transaction === 'GET /api/test-nesting'; + }); + + await request.get('/api/test-nesting'); + + const event = await transactionEventPromise; + + // Find the srvx request span — this is the parent of all h3 and manual spans + const srvxSpan = event.spans?.find(span => span.origin === 'auto.http.nitro.srvx' && span.op === 'http.server'); + expect(srvxSpan).toBeDefined(); + const srvxSpanId = srvxSpan!.span_id; + + // Find the manually created db spans + const dbSelectSpan = event.spans?.find(span => span.op === 'db' && span.description === 'db.select'); + const dbInsertSpan = event.spans?.find(span => span.op === 'db' && span.description === 'db.insert'); + expect(dbSelectSpan).toBeDefined(); + expect(dbInsertSpan).toBeDefined(); + + // FIXME: Once nitro's h3 tracing plugin emits a separate span for route handlers (type: "route"), + // the db spans should be children of the h3 route handler span, not the srvx span directly. + // Currently nitro bypasses h3's ~routes for file-based routing, so h3 only emits middleware spans. + // Both db spans should be children of the srvx request span + expect(dbSelectSpan!.parent_span_id).toBe(srvxSpanId); + expect(dbInsertSpan!.parent_span_id).toBe(srvxSpanId); + + // Both db spans should be siblings (same parent) + expect(dbSelectSpan!.parent_span_id).toBe(dbInsertSpan!.parent_span_id); + + // The serialize span should be nested inside the db.insert span + const serializeSpan = event.spans?.find(span => span.op === 'serialize' && span.description === 'db.serialize'); + expect(serializeSpan).toBeDefined(); + expect(serializeSpan!.parent_span_id).toBe(dbInsertSpan!.span_id); +}); + +// FIXME: Nitro's file-based routing bypasses h3's ~routes, so h3's tracing plugin never wraps +// route handlers with type: "route". Once this is fixed upstream or we add our own wrapping, +// uncomment these tests to verify the h3 route handler span exists and is the parent of manual spans. +// +// test('Span nesting: h3 route handler span is a child of the srvx request span', async ({ request }) => { +// const transactionEventPromise = waitForTransaction('nitro-3', event => { +// return event?.transaction === 'GET /api/test-nesting'; +// }); +// +// await request.get('/api/test-nesting'); +// +// const event = await transactionEventPromise; +// +// const srvxSpan = event.spans?.find(span => span.origin === 'auto.http.nitro.srvx' && span.op === 'http.server'); +// expect(srvxSpan).toBeDefined(); +// +// const h3HandlerSpan = event.spans?.find( +// span => span.origin === 'auto.http.nitro.h3' && span.op === 'http.server', +// ); +// expect(h3HandlerSpan).toBeDefined(); +// expect(h3HandlerSpan!.parent_span_id).toBe(srvxSpan!.span_id); +// }); +// +// test('Span nesting: manual startSpan calls are children of the h3 route handler span', async ({ request }) => { +// const transactionEventPromise = waitForTransaction('nitro-3', event => { +// return event?.transaction === 'GET /api/test-nesting'; +// }); +// +// await request.get('/api/test-nesting'); +// +// const event = await transactionEventPromise; +// +// const h3HandlerSpan = event.spans?.find( +// span => span.origin === 'auto.http.nitro.h3' && span.op === 'http.server', +// ); +// expect(h3HandlerSpan).toBeDefined(); +// +// const dbSelectSpan = event.spans?.find(span => span.op === 'db' && span.description === 'db.select'); +// const dbInsertSpan = event.spans?.find(span => span.op === 'db' && span.description === 'db.insert'); +// expect(dbSelectSpan!.parent_span_id).toBe(h3HandlerSpan!.span_id); +// expect(dbInsertSpan!.parent_span_id).toBe(h3HandlerSpan!.span_id); +// }); + +test('Span nesting: middleware spans start before manual spans in the span tree', async ({ request }) => { + const transactionEventPromise = waitForTransaction('nitro-3', event => { + return event?.transaction === 'GET /api/test-nesting'; + }); + + await request.get('/api/test-nesting'); + + const event = await transactionEventPromise; + + // Middleware spans should start before the manual db spans + const middlewareSpans = event.spans?.filter(span => span.op === 'middleware.nitro') ?? []; + const dbSpans = event.spans?.filter(span => span.op === 'db') ?? []; + + expect(middlewareSpans.length).toBeGreaterThanOrEqual(1); + expect(dbSpans.length).toBeGreaterThanOrEqual(1); + + const earliestMiddlewareStart = Math.min(...middlewareSpans.map(s => s.start_timestamp)); + const earliestDbStart = Math.min(...dbSpans.map(s => s.start_timestamp)); + + // Middleware should start before the db spans + expect(earliestMiddlewareStart).toBeLessThanOrEqual(earliestDbStart); +}); diff --git a/packages/nitro/package.json b/packages/nitro/package.json index 5105e555acca..2f5ee0d52fa8 100644 --- a/packages/nitro/package.json +++ b/packages/nitro/package.json @@ -40,7 +40,7 @@ "dependencies": { "@sentry/core": "10.49.0", "@sentry/node": "10.49.0", - "otel-tracing-channel": "^0.2.0" + "@sentry/opentelemetry": "10.49.0" }, "devDependencies": { "h3": "^2.0.1-rc.13", diff --git a/packages/nitro/rollup.npm.config.mjs b/packages/nitro/rollup.npm.config.mjs index 35b018b2d99c..55cecd0893d3 100644 --- a/packages/nitro/rollup.npm.config.mjs +++ b/packages/nitro/rollup.npm.config.mjs @@ -5,7 +5,7 @@ export default [ makeBaseNPMConfig({ entrypoints: ['src/index.ts', 'src/runtime/plugins/server.ts'], packageSpecificConfig: { - external: [/^nitro/, 'otel-tracing-channel', /^h3/, /^srvx/], + external: [/^nitro/, /^h3/, /^srvx/], }, }), ), diff --git a/packages/nitro/src/runtime/hooks/captureTracingEvents.ts b/packages/nitro/src/runtime/hooks/captureTracingEvents.ts index cd65086122a4..99cb211b2b27 100644 --- a/packages/nitro/src/runtime/hooks/captureTracingEvents.ts +++ b/packages/nitro/src/runtime/hooks/captureTracingEvents.ts @@ -16,8 +16,8 @@ import { startSpanManual, updateSpanName, } from '@sentry/core'; +import { tracingChannel, type TracingChannelContextWithSpan } from '@sentry/opentelemetry'; 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'; @@ -58,19 +58,19 @@ function getResponseStatusCode(result: unknown): number | undefined { return undefined; } -function onTraceEnd(data: { span?: Span; result?: unknown }): void { +function onTraceEnd(data: TracingChannelContextWithSpan<{ result?: unknown }>): void { const statusCode = getResponseStatusCode(data.result); - if (data.span && statusCode !== undefined) { - setHttpStatus(data.span, statusCode); + if (data._sentrySpan && statusCode !== undefined) { + setHttpStatus(data._sentrySpan, statusCode); } - data.span?.end(); + data._sentrySpan?.end(); } -function onTraceError(data: { span?: Span; error: unknown }): void { +function onTraceError(data: TracingChannelContextWithSpan<{ error: unknown }>): void { captureException(data.error, { mechanism: { type: 'auto.http.nitro.onTraceError', handled: false } }); - data.span?.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - data.span?.end(); + data._sentrySpan?.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + data._sentrySpan?.end(); } /** @@ -128,10 +128,10 @@ function setupH3TracingChannels(): void { }, asyncStart: NOOP, end: NOOP, - asyncEnd: (data: H3TracingRequestEvent & { span?: Span; result?: unknown }) => { + asyncEnd: (data: TracingChannelContextWithSpan) => { onTraceEnd(data); - if (!data.span) { + if (!data._sentrySpan) { return; } @@ -139,8 +139,8 @@ function setupH3TracingChannels(): void { // 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 rootSpan = getRootSpan(data._sentrySpan); + if (rootSpan && rootSpan !== data._sentrySpan) { const routePattern = getParameterizedRoute(data.event); if (routePattern) { const method = data.event.req.method || 'GET'; diff --git a/packages/opentelemetry/src/tracingChannel.ts b/packages/opentelemetry/src/tracingChannel.ts index 984986b7cdcb..5548201c5f4c 100644 --- a/packages/opentelemetry/src/tracingChannel.ts +++ b/packages/opentelemetry/src/tracingChannel.ts @@ -18,7 +18,7 @@ import { DEBUG_BUILD } from './debug-build'; */ export type OtelTracingChannelTransform = (data: TData) => Span; -type WithSpan = TData & { _sentrySpan?: Span }; +export type TracingChannelContextWithSpan = TContext & { _sentrySpan?: Span }; /** * A TracingChannel whose `subscribe` / `unsubscribe` accept partial subscriber @@ -26,7 +26,7 @@ type WithSpan = TData & { _sentrySpan?: Span }; */ export interface OtelTracingChannel< TData extends object = object, - TDataWithSpan extends object = WithSpan, + TDataWithSpan extends object = TracingChannelContextWithSpan, > extends Omit, 'subscribe' | 'unsubscribe'> { subscribe(subscribers: Partial>): void; unsubscribe(subscribers: Partial>): void; @@ -52,10 +52,10 @@ interface ContextApi { export function tracingChannel( channelNameOrInstance: string, transformStart: OtelTracingChannelTransform, -): OtelTracingChannel> { - const channel = nativeTracingChannel, WithSpan>( +): OtelTracingChannel> { + const channel = nativeTracingChannel, TracingChannelContextWithSpan>( channelNameOrInstance, - ) as unknown as OtelTracingChannel>; + ) as unknown as OtelTracingChannel>; let lookup: AsyncLocalStorageLookup | undefined; try { @@ -78,7 +78,7 @@ export function tracingChannel( // Bind the start channel so that each trace invocation runs the transform // and stores the resulting context (with span) in AsyncLocalStorage. // @ts-expect-error bindStore types don't account for AsyncLocalStorage of a different generic type - channel.start.bindStore(otelStorage, (data: WithSpan) => { + channel.start.bindStore(otelStorage, (data: TracingChannelContextWithSpan) => { const span = transformStart(data); // Store the span on data so downstream event handlers (asyncEnd, error, etc.) can access it. diff --git a/yarn.lock b/yarn.lock index 4226f7fefe82..a3f5b8545c58 100644 --- a/yarn.lock +++ b/yarn.lock @@ -23650,11 +23650,6 @@ 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" From 12ca3f58340fa1d480854e80e2ea97c025de7775 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 16 Apr 2026 12:23:55 -0400 Subject: [PATCH 31/32] fix: use subpath export --- packages/nitro/src/runtime/hooks/captureTracingEvents.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nitro/src/runtime/hooks/captureTracingEvents.ts b/packages/nitro/src/runtime/hooks/captureTracingEvents.ts index 99cb211b2b27..53de89e143ef 100644 --- a/packages/nitro/src/runtime/hooks/captureTracingEvents.ts +++ b/packages/nitro/src/runtime/hooks/captureTracingEvents.ts @@ -16,7 +16,7 @@ import { startSpanManual, updateSpanName, } from '@sentry/core'; -import { tracingChannel, type TracingChannelContextWithSpan } from '@sentry/opentelemetry'; +import { tracingChannel, type TracingChannelContextWithSpan } from '@sentry/opentelemetry/tracingChannel'; import type { TracingRequestEvent as H3TracingRequestEvent } from 'h3/tracing'; import type { RequestEvent as SrvxRequestEvent } from 'srvx/tracing'; import { setServerTimingHeaders } from './setServerTimingHeaders'; From 6b8d4d75c0c26147caf7812059526bc21c878651 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 16 Apr 2026 12:43:07 -0400 Subject: [PATCH 32/32] fix: externalize subexports --- packages/nitro/rollup.npm.config.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nitro/rollup.npm.config.mjs b/packages/nitro/rollup.npm.config.mjs index 55cecd0893d3..1e41829a3a3a 100644 --- a/packages/nitro/rollup.npm.config.mjs +++ b/packages/nitro/rollup.npm.config.mjs @@ -5,7 +5,7 @@ export default [ makeBaseNPMConfig({ entrypoints: ['src/index.ts', 'src/runtime/plugins/server.ts'], packageSpecificConfig: { - external: [/^nitro/, /^h3/, /^srvx/], + external: [/^nitro/, /^h3/, /^srvx/, /^@sentry\/opentelemetry/], }, }), ),