From 2eaeef35f4b1fe370a5882c14e94b0d7a67e8b4f Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 30 Sep 2026 08:57:07 +0200 Subject: [PATCH 01/12] ref(nitro): Move channel-based instrumentation into @sentry/server-utils Extract Nitro's diagnostics-channel instrumentation out of `@sentry/nitro` into `@sentry/server-utils`, so any server SDK can provide it: - `nitroIntegration` (h3/srvx HTTP + middleware spans, unstorage cache spans) is a tracing integration, registered via `getTracingIntegrations()` and so available in node, deno and bun. - `nitroServerTimingIntegration` sets the `Server-Timing` trace-propagation headers. It is a default (non-tracing) integration so it also runs in tracing-without-performance mode, and can be opted out separately. Error capture stays in `@sentry/nitro`'s runtime plugin (its Nitro `error` hook), which a channel-based integration cannot replace without losing coverage. `@sentry/nitro` keeps getting all of this through the underlying `@sentry/node` defaults, so nothing changes for existing Nitro-on-Node apps. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../runtime/hooks/setServerTimingHeaders.ts | 27 --------- packages/nitro/src/runtime/plugins/server.ts | 5 -- packages/server-utils/src/index.ts | 1 + .../server-utils/src/integrations/index.ts | 17 +++++- .../nitro}/captureStorageEvents.ts | 17 +++--- .../nitro}/captureTracingEvents.ts | 38 ++++-------- .../src/integrations/nitro/index.ts | 50 ++++++++++++++++ .../src/integrations/nitro/nitroTypes.ts | 53 ++++++++++++++++ .../nitro/setServerTimingHeaders.ts | 60 +++++++++++++++++++ 9 files changed, 201 insertions(+), 67 deletions(-) delete mode 100644 packages/nitro/src/runtime/hooks/setServerTimingHeaders.ts rename packages/{nitro/src/runtime/hooks => server-utils/src/integrations/nitro}/captureStorageEvents.ts (90%) rename packages/{nitro/src/runtime/hooks => server-utils/src/integrations/nitro}/captureTracingEvents.ts (88%) create mode 100644 packages/server-utils/src/integrations/nitro/index.ts create mode 100644 packages/server-utils/src/integrations/nitro/nitroTypes.ts create mode 100644 packages/server-utils/src/integrations/nitro/setServerTimingHeaders.ts diff --git a/packages/nitro/src/runtime/hooks/setServerTimingHeaders.ts b/packages/nitro/src/runtime/hooks/setServerTimingHeaders.ts deleted file mode 100644 index 8263d4c55bef..000000000000 --- a/packages/nitro/src/runtime/hooks/setServerTimingHeaders.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { getTraceData } from '@sentry/core'; -import type { H3Event } from 'nitro/h3'; - -/** - * Sets Server-Timing response headers for trace propagation to the client. - * The browser SDK reads these via the Performance API to connect pageload traces. - */ -export function setServerTimingHeaders(event: H3Event): void { - if (event.context._sentryServerTimingSet) { - return; - } - - const headers = event.res?.headers; - if (!headers) { - return; - } - - const traceData = getTraceData(); - if (traceData['sentry-trace']) { - headers.append('Server-Timing', `sentry-trace;desc="${traceData['sentry-trace']}"`); - } - if (traceData.baggage) { - headers.append('Server-Timing', `baggage;desc="${traceData.baggage}"`); - } - - event.context._sentryServerTimingSet = true; -} diff --git a/packages/nitro/src/runtime/plugins/server.ts b/packages/nitro/src/runtime/plugins/server.ts index 9fd3f93a6f40..f018d3eb6869 100644 --- a/packages/nitro/src/runtime/plugins/server.ts +++ b/packages/nitro/src/runtime/plugins/server.ts @@ -1,11 +1,6 @@ import { definePlugin } from 'nitro'; import { captureErrorHook } from '../hooks/captureErrorHook'; -import { captureStorageEvents } from '../hooks/captureStorageEvents'; -import { captureTracingEvents } from '../hooks/captureTracingEvents'; export default definePlugin(nitroApp => { nitroApp.hooks.hook('error', captureErrorHook); - - captureTracingEvents(); - captureStorageEvents(); }); diff --git a/packages/server-utils/src/index.ts b/packages/server-utils/src/index.ts index 41eb3845cf1a..892e061f89c8 100644 --- a/packages/server-utils/src/index.ts +++ b/packages/server-utils/src/index.ts @@ -77,5 +77,6 @@ export type { ExpressIntegrationOptions } from './integrations/express/types'; export { expressErrorHandler, setupExpressErrorHandler } from './integrations/express/error-handler'; /* oxlint-enable typescript/no-deprecated */ export { firebaseIntegration } from './integrations/firebase'; +export { nitroIntegration, nitroServerTimingIntegration } from './integrations/nitro'; export { getTracingIntegrations, getErrorIntegrations } from './integrations'; diff --git a/packages/server-utils/src/integrations/index.ts b/packages/server-utils/src/integrations/index.ts index 5b044dd29430..50ee67c2d4b5 100644 --- a/packages/server-utils/src/integrations/index.ts +++ b/packages/server-utils/src/integrations/index.ts @@ -31,6 +31,7 @@ import { neonIntegration } from './neon'; import { firebaseIntegration } from './firebase'; import { expressIntegration } from './express'; import { fastifyIntegration } from './fastify'; +import { nitroIntegration, nitroServerTimingIntegration } from './nitro'; import { hapiIntegration } from './hapi'; import { honoIntegration } from './hono'; import { koaIntegration } from './koa'; @@ -74,10 +75,22 @@ export function getTracingIntegrations(): Integration[] { neonIntegration(), firebaseIntegration(), mcpServerIntegration(), + nitroIntegration(), ]; } -/** These are integrations that cover error capture, in addition to tracing. */ +/** + * These are default integrations that are registered regardless of whether tracing is enabled - + * either because they cover error capture in addition to tracing, or because they only handle trace + * propagation (which must work in tracing-without-performance mode too). + */ export function getErrorIntegrations(): Integration[] { - return [expressIntegration(), fastifyIntegration(), hapiIntegration(), honoIntegration(), koaIntegration()]; + return [ + expressIntegration(), + fastifyIntegration(), + hapiIntegration(), + honoIntegration(), + koaIntegration(), + nitroServerTimingIntegration(), + ]; } diff --git a/packages/nitro/src/runtime/hooks/captureStorageEvents.ts b/packages/server-utils/src/integrations/nitro/captureStorageEvents.ts similarity index 90% rename from packages/nitro/src/runtime/hooks/captureStorageEvents.ts rename to packages/server-utils/src/integrations/nitro/captureStorageEvents.ts index 2b768d93226d..d419951912fa 100644 --- a/packages/nitro/src/runtime/hooks/captureStorageEvents.ts +++ b/packages/server-utils/src/integrations/nitro/captureStorageEvents.ts @@ -1,4 +1,3 @@ -import * as dc from 'node:diagnostics_channel'; import { CACHE_HIT, CACHE_KEY, CACHE_OPERATION, SENTRY_OP, SENTRY_ORIGIN } from '@sentry/conventions/attributes'; import { CACHE_GET, CACHE_PUT, CACHE_REMOVE } from '@sentry/conventions/op'; import { @@ -7,11 +6,13 @@ import { GLOBAL_OBJ, hasSpanStreamingEnabled, isObjectLike, + _INTERNAL_safeDateNow as safeDateNow, startInactiveSpan, } from '@sentry/core'; import { flushIfServerless } from '@sentry/core/server'; -import { bindTracingChannelToSpan } from '@sentry/server-utils'; -import type { TraceContext } from 'unstorage/tracing'; +import { bindTracingChannelToSpan } from '../../tracing-channel'; +import * as diagnosticsChannel from '../../utils/diagnosticsChannel'; +import type { UnstorageTraceContext } from './nitroTypes'; const ORIGIN = 'auto.cache.nitro'; @@ -72,16 +73,16 @@ export function captureStorageEvents(): void { } function setupStorageTracingChannel(operation: TracedOperation): void { - const keys = (data: TraceContext): string[] => data.keys ?? []; - const mountBase = (data: TraceContext): string => (data.base ?? '').replace(/:$/, ''); + const keys = (data: UnstorageTraceContext): string[] => data.keys ?? []; + const mountBase = (data: UnstorageTraceContext): string => (data.base ?? '').replace(/:$/, ''); // Bail if this is not available - if (!dc.tracingChannel) { + if (!diagnosticsChannel.tracingChannel) { return; } bindTracingChannelToSpan( - dc.tracingChannel(`unstorage.${operation}`), + diagnosticsChannel.tracingChannel(`unstorage.${operation}`), data => { const cacheKeys = keys(data); const cacheOperationName = CACHE_OPERATION_NAMES[OPERATION_SPAN_OPS[operation]]; @@ -170,7 +171,7 @@ function validateCacheEntry( return false; } - if (Date.now() > (entry.expires || 0)) { + if (safeDateNow() > (entry.expires || 0)) { return false; } diff --git a/packages/nitro/src/runtime/hooks/captureTracingEvents.ts b/packages/server-utils/src/integrations/nitro/captureTracingEvents.ts similarity index 88% rename from packages/nitro/src/runtime/hooks/captureTracingEvents.ts rename to packages/server-utils/src/integrations/nitro/captureTracingEvents.ts index 2752e2f63d79..2431d4115af8 100644 --- a/packages/nitro/src/runtime/hooks/captureTracingEvents.ts +++ b/packages/server-utils/src/integrations/nitro/captureTracingEvents.ts @@ -1,4 +1,3 @@ -import * as dc from 'node:diagnostics_channel'; import { SENTRY_OP, SENTRY_SEGMENT_NAME_SOURCE, SENTRY_ORIGIN } from '@sentry/conventions/attributes'; import { HTTP_SERVER, MIDDLEWARE } from '@sentry/conventions/op'; import { @@ -16,15 +15,10 @@ import { type Span, startInactiveSpan, } from '@sentry/core'; -import { - bindTracingChannelToSpan, - setHttpServerSpanRouteAttribute, - type TracingChannelPayloadWithSpan, -} from '@sentry/server-utils'; -import type { TracingRequestEvent as H3TracingRequestEvent } from 'h3/tracing'; -import type { H3Event } from 'nitro/h3'; -import type { RequestEvent as SrvxRequestEvent } from 'srvx/tracing'; -import { setServerTimingHeaders } from './setServerTimingHeaders'; +import { bindTracingChannelToSpan, type TracingChannelPayloadWithSpan } from '../../tracing-channel'; +import * as diagnosticsChannel from '../../utils/diagnosticsChannel'; +import { setHttpServerSpanRouteAttribute } from '../../utils/setHttpServerSpanRouteAttribute'; +import type { H3TracingRequestEvent, NitroH3Event, SrvxRequestEvent } from './nitroTypes'; /** * Global object with the trace channels @@ -69,7 +63,7 @@ function applyResponseStatus(span: Span, data: TracingChannelPayloadWithSpan<{ r /** * Extracts the parameterized route pattern from the h3 event context. */ -function getParameterizedRoute(event: H3Event): string | undefined { +function getParameterizedRoute(event: NitroH3Event): string | undefined { const matchedRoute = event.context?.matchedRoute; if (!matchedRoute) { return undefined; @@ -87,12 +81,12 @@ function getParameterizedRoute(event: H3Event): string | undefined { function setupH3TracingChannels(): void { // Bail if this is not available - if (!dc.tracingChannel) { + if (!diagnosticsChannel.tracingChannel) { return; } - const { channel: h3Channel } = bindTracingChannelToSpan( - dc.tracingChannel('h3.request'), + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel('h3.request'), data => { const parsedUrl = parseStringToURLObject(data.event.url.href); const routePattern = getParameterizedRoute(data.event); @@ -144,25 +138,19 @@ function setupH3TracingChannels(): void { }, }, ); - - h3Channel.subscribe({ - start: data => { - setServerTimingHeaders(data.event); - }, - }); } function setupSrvxTracingChannels(): void { - if (!dc.tracingChannel) { + if (!diagnosticsChannel.tracingChannel) { return; } // 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 requestParentSpans = new WeakMap(); bindTracingChannelToSpan( - dc.tracingChannel('srvx.request'), + diagnosticsChannel.tracingChannel('srvx.request'), data => { const parsedUrl = data.request._url ? parseStringToURLObject(data.request._url.href) : undefined; const client = getClient(); @@ -217,7 +205,7 @@ function setupSrvxTracingChannels(): void { ); bindTracingChannelToSpan( - dc.tracingChannel('srvx.middleware'), + diagnosticsChannel.tracingChannel('srvx.middleware'), data => { // For the first middleware, capture the current parent span per-request if (data.middleware?.index === 0) { @@ -254,7 +242,7 @@ function setupSrvxTracingChannels(): void { /** * Sets the parameterized route attributes on the span. */ -function setParameterizedRouteAttributes(span: Span, event: H3Event): void { +function setParameterizedRouteAttributes(span: Span, event: NitroH3Event): void { const matchedRoutePath = getParameterizedRoute(event); if (!matchedRoutePath) { return; diff --git a/packages/server-utils/src/integrations/nitro/index.ts b/packages/server-utils/src/integrations/nitro/index.ts new file mode 100644 index 000000000000..0ae9415c86e9 --- /dev/null +++ b/packages/server-utils/src/integrations/nitro/index.ts @@ -0,0 +1,50 @@ +import type { IntegrationFn } from '@sentry/core'; +import { defineIntegration } from '@sentry/core'; +import { captureStorageEvents } from './captureStorageEvents'; +import { captureTracingEvents } from './captureTracingEvents'; +import { captureServerTimingHeaders } from './setServerTimingHeaders'; + +const INTEGRATION_NAME = 'Nitro'; + +const _nitroIntegration = (() => { + return { + name: INTEGRATION_NAME, + setupOnce() { + captureTracingEvents(); + captureStorageEvents(); + }, + }; +}) satisfies IntegrationFn; + +/** + * Instruments a Nitro server via its `node:diagnostics_channel` tracing channels: HTTP server and + * middleware spans (h3/srvx) and cache spans (unstorage). It subscribes to those channels, so it is + * inert unless a Nitro app (or a framework that enables the same tracing channels) is running. + * + * Only creates spans, so it is registered as a tracing integration (active only when tracing is + * enabled). Trace-propagation headers are handled separately by {@link nitroServerTimingIntegration}, + * and error capture by the `@sentry/nitro` runtime plugin's Nitro `error` hook. + */ +export const nitroIntegration = defineIntegration(_nitroIntegration); + +const SERVER_TIMING_INTEGRATION_NAME = 'NitroServerTiming'; + +const _nitroServerTimingIntegration = (() => { + return { + name: SERVER_TIMING_INTEGRATION_NAME, + setupOnce() { + captureServerTimingHeaders(); + }, + }; +}) satisfies IntegrationFn; + +/** + * Sets `Server-Timing` response headers carrying the current `sentry-trace`/`baggage` on every + * response served through Nitro's h3 request channel, so the browser SDK can connect a pageload + * trace to the backend trace. + * + * Registered as a default (non-tracing) integration because trace propagation must work even in + * tracing-without-performance mode. It only appends headers when the h3 request channel fires, so it + * is inert outside Nitro. Opt out by excluding it from `integrations`. + */ +export const nitroServerTimingIntegration = defineIntegration(_nitroServerTimingIntegration); diff --git a/packages/server-utils/src/integrations/nitro/nitroTypes.ts b/packages/server-utils/src/integrations/nitro/nitroTypes.ts new file mode 100644 index 000000000000..4736a2dd1ec3 --- /dev/null +++ b/packages/server-utils/src/integrations/nitro/nitroTypes.ts @@ -0,0 +1,53 @@ +/** + * Vendored subset of the `nitro`/`h3`/`srvx`/`unstorage` tracing types used by the Sentry Nitro + * instrumentation. + * + * The instrumentation lives in `@sentry/server-utils`, a dependency of every server SDK — including + * apps that do not use Nitro. We therefore declare no dependency on those packages at all: the + * instrumentation only ever subscribes to `node:diagnostics_channel` channels, so these minimal + * structural types stand in for the real `nitro/h3`, `h3/tracing`, `srvx/tracing` and + * `unstorage/tracing` types at the boundary. + * + * ATTENTION: keep these permissive — the payloads are produced by the frameworks, so these types + * must stay assignable from the real ones. Only the fields the instrumentation reads are declared. + */ + +/** Minimal shape of an `h3` event, as read from the `h3.request` tracing channel. */ +export interface NitroH3Event { + url: { href: string }; + req: { method?: string }; + res?: { headers: { append: (name: string, value: string) => void } }; + context?: { + matchedRoute?: { route?: string }; + params?: unknown; + /** Set by the Server-Timing instrumentation so trace headers are only appended once per request. */ + _sentryServerTimingSet?: boolean; + }; +} + +/** Payload of the `h3.request` tracing channel (`h3/tracing`'s `TracingRequestEvent`). */ +export interface H3TracingRequestEvent { + type: 'middleware' | 'route'; + event: NitroH3Event; +} + +/** Payload of the `srvx.request`/`srvx.middleware` tracing channels (`srvx/tracing`'s `RequestEvent`). */ +export interface SrvxRequestEvent { + server: { options: { port?: number } }; + request: { + _url?: { href: string; pathname: string }; + method: string; + headers: Headers; + }; + middleware?: { + index: number; + handler: { name?: string }; + }; +} + +/** Payload of the `unstorage.*` tracing channels (`unstorage/tracing`'s `TraceContext`). */ +export interface UnstorageTraceContext { + keys?: string[]; + base?: string; + driver?: { name?: string }; +} diff --git a/packages/server-utils/src/integrations/nitro/setServerTimingHeaders.ts b/packages/server-utils/src/integrations/nitro/setServerTimingHeaders.ts new file mode 100644 index 000000000000..ef2a1bb5c80c --- /dev/null +++ b/packages/server-utils/src/integrations/nitro/setServerTimingHeaders.ts @@ -0,0 +1,60 @@ +import { getTraceData, GLOBAL_OBJ } from '@sentry/core'; +import * as diagnosticsChannel from '../../utils/diagnosticsChannel'; +import type { H3TracingRequestEvent, NitroH3Event } from './nitroTypes'; + +const globalWithServerTiming = GLOBAL_OBJ as typeof GLOBAL_OBJ & { + __SENTRY_NITRO_SERVER_TIMING_INSTRUMENTED__: boolean; +}; + +/** + * Subscribes to the h3 request tracing channel to set Server-Timing trace headers on every response. + * + * This is deliberately independent of span sampling: `getTraceData()` returns valid trace + * propagation data even in tracing-without-performance mode, and the browser SDK reads these headers + * (via the Performance API) to connect a pageload to the backend trace regardless of whether the + * server records spans. Span creation itself lives in the separate `nitroIntegration`, which only + * activates when tracing is enabled. + */ +export function captureServerTimingHeaders(): void { + if (globalWithServerTiming.__SENTRY_NITRO_SERVER_TIMING_INSTRUMENTED__) { + return; + } + + // Bail if this is not available (e.g. Node without tracing channel support) + if (!diagnosticsChannel.tracingChannel) { + return; + } + + diagnosticsChannel.tracingChannel('h3.request').start.subscribe(message => { + setServerTimingHeaders((message as H3TracingRequestEvent).event); + }); + + globalWithServerTiming.__SENTRY_NITRO_SERVER_TIMING_INSTRUMENTED__ = true; +} + +/** + * Sets Server-Timing response headers for trace propagation to the client. + * The browser SDK reads these via the Performance API to connect pageload traces. + */ +function setServerTimingHeaders(event: NitroH3Event): void { + if (event.context?._sentryServerTimingSet) { + return; + } + + const headers = event.res?.headers; + if (!headers) { + return; + } + + const traceData = getTraceData(); + if (traceData['sentry-trace']) { + headers.append('Server-Timing', `sentry-trace;desc="${traceData['sentry-trace']}"`); + } + if (traceData.baggage) { + headers.append('Server-Timing', `baggage;desc="${traceData.baggage}"`); + } + + if (event.context) { + event.context._sentryServerTimingSet = true; + } +} From bcb71c6315dcf0da1243db930fd45125c1d9f78b Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 30 Sep 2026 09:07:34 +0200 Subject: [PATCH 02/12] feat(nitro): Auto-inject Nitro integrations on bundler-only SDKs via orchestrion Add registration-only orchestrion configs so the Nitro integrations are auto-registered on bundler-only SDKs like `@sentry/cloudflare`, which discover a bundled module via the module-injected event and instantiate its factory. The snippet maps one integration per module, so two anchors are used, both core Nitro dependencies evaluated at startup: `h3` (dist/h3.mjs) registers `nitroIntegration` (spans) and `unstorage` (dist/index.mjs) registers `nitroServerTimingIntegration`. These are registration-only (no channels are injected, the libraries publish their own), so they are excluded from the runtime loader and are a no-op on node/deno/bun, where the integrations are registered statically. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../config/channel-integration-definitions.ts | 2 ++ .../src/orchestrion/config/index.ts | 2 ++ .../src/orchestrion/config/nitro.ts | 20 +++++++++++++++++++ 3 files changed, 24 insertions(+) create mode 100644 packages/server-utils/src/orchestrion/config/nitro.ts diff --git a/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts b/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts index d3583a3977ec..685bc288e52b 100644 --- a/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts +++ b/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts @@ -63,6 +63,8 @@ export const CHANNEL_INTEGRATION_DEFINITIONS = [ { exportName: 'kafkaIntegration', modules: ['kafkajs'] }, { exportName: 'redisIntegration', modules: ['redis', '@redis/client', 'ioredis'] }, { exportName: 'dataloaderIntegration', modules: ['dataloader'] }, + { exportName: 'nitroIntegration', modules: ['h3'] }, + { exportName: 'nitroServerTimingIntegration', modules: ['unstorage'] }, ] as const satisfies ReadonlyArray<{ exportName: string; modules: readonly string[] }>; /** Look up the subscriber export name for an instrumented package, if any. */ diff --git a/packages/server-utils/src/orchestrion/config/index.ts b/packages/server-utils/src/orchestrion/config/index.ts index 0cf0e363728f..e60810315420 100644 --- a/packages/server-utils/src/orchestrion/config/index.ts +++ b/packages/server-utils/src/orchestrion/config/index.ts @@ -32,6 +32,7 @@ import { mysql2Config } from './mysql2'; import { mysqlConfig } from './mysql'; import { neonConfig } from './neon'; import { nestjsConfig } from './nestjs'; +import { nitroConfig } from './nitro'; import { openaiConfig } from './openai'; import { pgConfig } from './pg'; import { postgresJsConfig } from './postgres'; @@ -89,6 +90,7 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ...mysqlConfig, ...neonConfig, ...nestjsConfig, + ...nitroConfig, ...openaiConfig, ...pgConfig, ...piDurableConfig, diff --git a/packages/server-utils/src/orchestrion/config/nitro.ts b/packages/server-utils/src/orchestrion/config/nitro.ts new file mode 100644 index 000000000000..382984915658 --- /dev/null +++ b/packages/server-utils/src/orchestrion/config/nitro.ts @@ -0,0 +1,20 @@ +import type { InstrumentationConfig } from '../apmTypes'; +import { registrationOnly } from './registration-only'; + +/** + * Nitro's instrumentation consumes tracing channels that h3, srvx and unstorage publish natively, so + * no channels are injected: transforming these entry files only registers the Nitro integrations at + * evaluation time, which is what installs them on a bundler-only SDK like `@sentry/cloudflare`. On + * `@sentry/node` (and deno/bun) the integrations are registered statically, so this is a no-op there. + * + * Two anchors because the registration snippet maps a single integration per module (see + * `channel-integration-definitions.ts`): `h3` — which loads whenever the server runs — registers the + * span instrumentation, and `unstorage` — a core Nitro storage dependency — registers the + * Server-Timing trace-propagation headers. Both are core Nitro dependencies evaluated at startup, so + * a real Nitro app triggers both. The anchor only triggers registration; each integration then + * subscribes to all of its own channels, independent of which module loaded it. + */ +export const nitroConfig = [ + registrationOnly({ name: 'h3', versionRange: '>=2.0.0-0', filePath: 'dist/h3.mjs' }), + registrationOnly({ name: 'unstorage', versionRange: '>=2.0.0-0', filePath: 'dist/index.mjs' }), +] satisfies InstrumentationConfig[]; From f0ec56ffd685590b9471112b391de5f9e515a36d Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 30 Sep 2026 09:19:53 +0200 Subject: [PATCH 03/12] docs(nitro): Explain the prerelease version-range idiom in the orchestrion config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document why the h3/unstorage registration-only ranges use the `-0` suffix: the orchestrion matcher is the vendored `semifies` (not node-semver), where a range carrying a prerelease tag matches prereleases across patch tuples — so `>=2.0.0-0` matches the `2.0.1-rc.*` / `2.0.0-alpha.*` versions Nitro 3 ships. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/server-utils/src/orchestrion/config/nitro.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/server-utils/src/orchestrion/config/nitro.ts b/packages/server-utils/src/orchestrion/config/nitro.ts index 382984915658..ea3ac429290f 100644 --- a/packages/server-utils/src/orchestrion/config/nitro.ts +++ b/packages/server-utils/src/orchestrion/config/nitro.ts @@ -13,6 +13,12 @@ import { registrationOnly } from './registration-only'; * Server-Timing trace-propagation headers. Both are core Nitro dependencies evaluated at startup, so * a real Nitro app triggers both. The anchor only triggers registration; each integration then * subscribes to all of its own channels, independent of which module loaded it. + * + * The `-0` in the version range is load-bearing: h3 and unstorage ship the versions Nitro 3 uses as + * prereleases (h3 `2.0.1-rc.*`, unstorage `2.0.0-alpha.*`). The orchestrion matcher is the vendored + * `semifies` (used by both the runtime loader and the bundler plugins), where a range carrying a + * prerelease tag enables prerelease matching across patch tuples — so `>=2.0.0-0` matches + * `2.0.1-rc.*`. (Plain node-semver would not: it drops prereleases of a higher patch tuple.) */ export const nitroConfig = [ registrationOnly({ name: 'h3', versionRange: '>=2.0.0-0', filePath: 'dist/h3.mjs' }), From d18b88cc9094e04b4e874b00a2050ba6d71b305d Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 30 Sep 2026 09:22:28 +0200 Subject: [PATCH 04/12] docs(nitro): Reword the version-range comment Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/server-utils/src/orchestrion/config/nitro.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server-utils/src/orchestrion/config/nitro.ts b/packages/server-utils/src/orchestrion/config/nitro.ts index ea3ac429290f..101b99d11ddc 100644 --- a/packages/server-utils/src/orchestrion/config/nitro.ts +++ b/packages/server-utils/src/orchestrion/config/nitro.ts @@ -14,7 +14,7 @@ import { registrationOnly } from './registration-only'; * a real Nitro app triggers both. The anchor only triggers registration; each integration then * subscribes to all of its own channels, independent of which module loaded it. * - * The `-0` in the version range is load-bearing: h3 and unstorage ship the versions Nitro 3 uses as + * The `-0` in the version range matters: h3 and unstorage ship the versions Nitro 3 uses as * prereleases (h3 `2.0.1-rc.*`, unstorage `2.0.0-alpha.*`). The orchestrion matcher is the vendored * `semifies` (used by both the runtime loader and the bundler plugins), where a range carrying a * prerelease tag enables prerelease matching across patch tuples — so `>=2.0.0-0` matches From 2dd3477c35de97c1a81ab614e662d5cb014979b9 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 30 Sep 2026 09:33:35 +0200 Subject: [PATCH 05/12] test(nitro): Add cloudflare-nitro e2e app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a raw Nitro 3 app deployed to Cloudflare (Workers, `cloudflare_module` preset) that exercises the orchestrion registration-only path on a bundler-only SDK: the app imports no Nitro instrumentation, and `nitroIntegration` / `nitroServerTimingIntegration` are auto-injected via the orchestrion transform over h3/unstorage and instantiated by `@sentry/cloudflare` at init. Tests assert `auto.http.nitro.*` spans, `auto.cache.nitro` cache spans, and the `Server-Timing` trace-propagation header — i.e. the whole auto-injection path, including that the tracing channels bind under Cloudflare's async-context strategy. Init mirrors what @sentry/nuxt does for Nitro-on-Cloudflare (no first-class raw-Nitro Cloudflare helper exists): a Nitro server plugin wraps `nitroApp.fetch` with `wrapRequestHandler`, and the orchestrion rollup/rolldown plugin is wired into the Nitro build config. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../cloudflare-nitro/.gitignore | 7 +++ .../cloudflare-nitro/package.json | 35 +++++++++++++ .../cloudflare-nitro/playwright.config.mjs | 14 ++++++ .../cloudflare-nitro/server/api/index.ts | 5 ++ .../cloudflare-nitro/server/api/test-cache.ts | 23 +++++++++ .../cloudflare-nitro/server/api/test-error.ts | 5 ++ .../server/api/test-transaction.ts | 5 ++ .../cloudflare-nitro/server/plugins/sentry.ts | 49 +++++++++++++++++++ .../cloudflare-nitro/start-event-proxy.mjs | 6 +++ .../cloudflare-nitro/tests/cache.test.ts | 27 ++++++++++ .../tests/server-timing.test.ts | 12 +++++ .../tests/transactions.test.ts | 36 ++++++++++++++ .../cloudflare-nitro/tsconfig.json | 12 +++++ .../cloudflare-nitro/vite.config.ts | 36 ++++++++++++++ .../cloudflare-nitro/wrangler.jsonc | 11 +++++ 15 files changed, 283 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-nitro/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-nitro/package.json create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-nitro/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-nitro/server/api/index.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-nitro/server/api/test-cache.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-nitro/server/api/test-error.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-nitro/server/api/test-transaction.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-nitro/server/plugins/sentry.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-nitro/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-nitro/tests/cache.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-nitro/tests/server-timing.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-nitro/tests/transactions.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-nitro/tsconfig.json create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-nitro/vite.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-nitro/wrangler.jsonc diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-nitro/.gitignore b/dev-packages/e2e-tests/test-applications/cloudflare-nitro/.gitignore new file mode 100644 index 000000000000..d2858d86f66b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-nitro/.gitignore @@ -0,0 +1,7 @@ +node_modules +.output +.nitro +.wrangler +dist +test-results +pnpm-lock.yaml diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-nitro/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-nitro/package.json new file mode 100644 index 000000000000..1b05747ed008 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-nitro/package.json @@ -0,0 +1,35 @@ +{ + "name": "cloudflare-nitro", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "preview": "wrangler dev --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --port 3030 --log-level=$(test $CI && echo 'none' || echo 'log')", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml .output .nitro .wrangler", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test" + }, + "dependencies": { + "@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz", + "@sentry/server-utils": "file:../../packed/sentry-server-utils-packed.tgz" + }, + "devDependencies": { + "@cloudflare/workers-types": "^5.20260727.1", + "@playwright/test": "~1.63.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "nitro": "^3.0.260522-beta", + "rolldown": "latest", + "vite": "latest", + "wrangler": "^4.114.0" + }, + "pnpm": { + "overrides": { + "unstorage": "2.0.0-alpha.7" + } + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-nitro/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/cloudflare-nitro/playwright.config.mjs new file mode 100644 index 000000000000..5deda7e232e6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-nitro/playwright.config.mjs @@ -0,0 +1,14 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +// `vite build` produces the Cloudflare worker under `.output` (with the orchestrion transform +// applied); `pnpm preview` serves it with `wrangler dev`. +export default getPlaywrightConfig( + { + startCommand: 'pnpm preview', + port: 3030, + }, + { + workers: '100%', + retries: 0, + }, +); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-nitro/server/api/index.ts b/dev-packages/e2e-tests/test-applications/cloudflare-nitro/server/api/index.ts new file mode 100644 index 000000000000..a9fca21eecfb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-nitro/server/api/index.ts @@ -0,0 +1,5 @@ +import { defineHandler } from 'nitro/h3'; + +export default defineHandler(() => { + return { status: 'ok' }; +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-nitro/server/api/test-cache.ts b/dev-packages/e2e-tests/test-applications/cloudflare-nitro/server/api/test-cache.ts new file mode 100644 index 000000000000..2eb376230f8e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-nitro/server/api/test-cache.ts @@ -0,0 +1,23 @@ +import { defineCachedFunction } from 'nitro/cache'; +import { defineHandler, getQuery } from 'nitro/h3'; + +const getCachedUser = defineCachedFunction( + async (userId: string) => { + return { id: userId, name: `User ${userId}`, timestamp: Date.now() }; + }, + { + maxAge: 60, + name: 'getCachedUser', + getKey: (userId: string) => `user:${userId}`, + }, +); + +export default defineHandler(async event => { + const userId = String(getQuery(event).user ?? '123'); + + // First call is a cache miss (a `cache.put`), the second is a hit (a `cache.get`). + const first = await getCachedUser(userId); + const second = await getCachedUser(userId); + + return { first, second }; +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-nitro/server/api/test-error.ts b/dev-packages/e2e-tests/test-applications/cloudflare-nitro/server/api/test-error.ts new file mode 100644 index 000000000000..170efb1977ab --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-nitro/server/api/test-error.ts @@ -0,0 +1,5 @@ +import { defineHandler } from 'nitro/h3'; + +export default defineHandler(() => { + throw new Error('This is a test error'); +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-nitro/server/api/test-transaction.ts b/dev-packages/e2e-tests/test-applications/cloudflare-nitro/server/api/test-transaction.ts new file mode 100644 index 000000000000..264f02a64328 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-nitro/server/api/test-transaction.ts @@ -0,0 +1,5 @@ +import { defineHandler } from 'nitro/h3'; + +export default defineHandler(() => { + return { result: 'transaction' }; +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-nitro/server/plugins/sentry.ts b/dev-packages/e2e-tests/test-applications/cloudflare-nitro/server/plugins/sentry.ts new file mode 100644 index 000000000000..95c0f5ed2c81 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-nitro/server/plugins/sentry.ts @@ -0,0 +1,49 @@ +import { getDefaultIntegrations, setAsyncLocalStorageAsyncContextStrategy } from '@sentry/cloudflare'; +import { wrapRequestHandler } from '@sentry/cloudflare/request'; +import { definePlugin } from 'nitro'; + +/** + * Initializes the Cloudflare SDK for a raw Nitro app. + * + * There is no first-class `@sentry/nitro` Cloudflare helper yet, so this replicates what + * `@sentry/nuxt`'s `sentryCloudflareNitroPlugin` does, adapted to Nitro 3: it wraps `nitroApp.fetch` + * (Nitro 3 has no `localFetch`) with `wrapRequestHandler`. Nitro's `cloudflare_module` preset + * augments the incoming request with `request.runtime.cloudflare = { env, context }` + * (see the preset's `augmentReq`), which is where the DSN and the execution context come from. + * + * The `nitroIntegration` / `nitroServerTimingIntegration` span + header instrumentation is not set + * up here — it is auto-injected by the orchestrion transform (see `vite.config.ts`) and instantiated + * by `getDefaultIntegrations()` from the global orchestrion marker. + */ +export default definePlugin(nitroApp => { + const originalFetch = nitroApp.fetch; + + nitroApp.fetch = ( + request: Request & { runtime?: { cloudflare?: { env?: Record; context?: unknown } } }, + ) => { + setAsyncLocalStorageAsyncContextStrategy(); + + const cloudflare = request.runtime?.cloudflare ?? {}; + const env = (cloudflare.env ?? (globalThis as { __env__?: Record }).__env__ ?? {}) as { + E2E_TEST_DSN?: string; + }; + + const options = { + dsn: env.E2E_TEST_DSN, + environment: 'qa', + tunnel: 'http://localhost:3031/', + tracesSampleRate: 1.0, + }; + + return wrapRequestHandler( + { + // `wrapRequestHandler` defaults to the Node-free integration set; the `nodejs_compat` flag is + // on for the Cloudflare preset, so opt into the full set to match a normal Cloudflare init. + options: { ...options, defaultIntegrations: getDefaultIntegrations(options) }, + request, + context: cloudflare.context as ExecutionContext | undefined, + }, + () => originalFetch(request), + ); + }; +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-nitro/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/cloudflare-nitro/start-event-proxy.mjs new file mode 100644 index 000000000000..bfd71243b8db --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-nitro/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'cloudflare-nitro', +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-nitro/tests/cache.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-nitro/tests/cache.test.ts new file mode 100644 index 000000000000..3b79a5f0caa0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-nitro/tests/cache.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from '@playwright/test'; +import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; + +// Nitro's `defineCachedFunction` goes through unstorage, whose tracing channels `nitroIntegration` +// subscribes to — so cache spans on Cloudflare are further proof the auto-injected integration is +// active (its storage subscriber, not just the HTTP one). +function collectCacheSpans() { + return collectStreamedSpans( + 'cloudflare-nitro', + spans => + spans.some(span => span.is_segment && span.attributes['url.path']?.value === '/api/test-cache') && + spans.some(span => String(span.attributes['sentry.origin']?.value ?? '') === 'auto.cache.nitro'), + ); +} + +test('emits cache spans from the auto-injected storage instrumentation', async ({ request }) => { + const spansPromise = collectCacheSpans(); + + const res = await request.get('/api/test-cache'); + expect(res.status()).toBe(200); + + const spans = await spansPromise; + + const cacheSpans = spans.filter(span => span.attributes['sentry.origin']?.value === 'auto.cache.nitro'); + expect(cacheSpans.length).toBeGreaterThanOrEqual(1); + expect(getSpanOp(cacheSpans[0]!)).toMatch(/^cache\./); +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-nitro/tests/server-timing.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-nitro/tests/server-timing.test.ts new file mode 100644 index 000000000000..299f6b1e2b48 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-nitro/tests/server-timing.test.ts @@ -0,0 +1,12 @@ +import { expect, test } from '@playwright/test'; + +// `nitroServerTimingIntegration` is the second auto-injected integration (anchored on `unstorage`). +// It appends the trace-propagation data as a `Server-Timing` response header, so asserting on the +// header directly is enough — no span collection needed, and it works with tracing on or off. +test('auto-injects the Server-Timing trace-propagation header', async ({ request }) => { + const res = await request.get('/api/test-transaction'); + expect(res.status()).toBe(200); + + const serverTiming = res.headers()['server-timing'] ?? ''; + expect(serverTiming).toContain('sentry-trace'); +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-nitro/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-nitro/tests/transactions.test.ts new file mode 100644 index 000000000000..6797aef8fd29 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-nitro/tests/transactions.test.ts @@ -0,0 +1,36 @@ +import { expect, test } from '@playwright/test'; +import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; + +// The point of this test: on Cloudflare, `nitroIntegration` is not imported by the app — it is +// auto-injected by the orchestrion transform over `h3`/`unstorage` and instantiated from the global +// marker at `init()`. So seeing `auto.http.nitro.*` spans proves that path works end to end. +function collectRequestSpans() { + return collectStreamedSpans( + 'cloudflare-nitro', + spans => + spans.some(span => span.is_segment && span.attributes['url.path']?.value === '/api/test-transaction') && + spans.some(span => String(span.attributes['sentry.origin']?.value ?? '').startsWith('auto.http.nitro')), + ); +} + +test('auto-injects nitro instrumentation and emits http.server + nitro spans', async ({ request }) => { + const spansPromise = collectRequestSpans(); + + const res = await request.get('/api/test-transaction'); + expect(res.status()).toBe(200); + + const spans = await spansPromise; + + const segmentSpan = spans.find( + span => span.is_segment && span.attributes['url.path']?.value === '/api/test-transaction', + ); + expect(segmentSpan).toBeDefined(); + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + + const nitroSpans = spans.filter( + span => + span.trace_id === segmentSpan!.trace_id && + String(span.attributes['sentry.origin']?.value ?? '').startsWith('auto.http.nitro'), + ); + expect(nitroSpans.length).toBeGreaterThanOrEqual(1); +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-nitro/tsconfig.json b/dev-packages/e2e-tests/test-applications/cloudflare-nitro/tsconfig.json new file mode 100644 index 000000000000..072c968ab244 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-nitro/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["@cloudflare/workers-types"] + }, + "include": ["server/**/*.ts", "vite.config.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-nitro/vite.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-nitro/vite.config.ts new file mode 100644 index 000000000000..03158d79204b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-nitro/vite.config.ts @@ -0,0 +1,36 @@ +import { INSTRUMENTED_MODULE_NAMES } from '@sentry/server-utils/orchestrion/config'; +import { sentryOrchestrionPlugin } from '@sentry/server-utils/orchestrion/rollup'; +import { nitro } from 'nitro/vite'; +import { defineConfig } from 'vite'; + +// A raw Nitro app targeting Cloudflare. There is no `@sentry/nitro` Cloudflare init helper, so this +// mirrors what `@sentry/nuxt` does for Nitro-on-Cloudflare by hand: +// 1. Bundle for the `cloudflare_module` preset with `nodeCompat` (Nitro's Cloudflare presets need +// the `nodejs_compat` flag, which is also what makes `node:diagnostics_channel` available). +// 2. Run the orchestrion transform over Nitro's server bundle and force the instrumented packages +// to be inlined (an externalized dependency never passes through the transform). This is what +// injects the registration snippet into `h3`/`unstorage`, which registers `nitroIntegration` +// and `nitroServerTimingIntegration` on the Cloudflare SDK at `init()`. +export default defineConfig({ + plugins: [ + nitro({ + preset: 'cloudflare_module', + cloudflare: { + nodeCompat: true, + deployConfig: false, + }, + // Nitro 3 builds the server bundle with rolldown when driven through `nitro/vite`, so the + // transform goes in `rolldownConfig`. `rollupConfig` is kept too so this also works if the + // build ever falls back to the rollup builder — the orchestrion plugin supports both. + rolldownConfig: { + plugins: [sentryOrchestrionPlugin({})], + }, + rollupConfig: { + plugins: [sentryOrchestrionPlugin({})], + }, + externals: { + inline: [...INSTRUMENTED_MODULE_NAMES], + }, + }), + ], +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-nitro/wrangler.jsonc b/dev-packages/e2e-tests/test-applications/cloudflare-nitro/wrangler.jsonc new file mode 100644 index 000000000000..72cf82f22a03 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-nitro/wrangler.jsonc @@ -0,0 +1,11 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "cloudflare-nitro", + "main": "./.output/server/index.mjs", + "compatibility_date": "2026-05-20", + "compatibility_flags": ["nodejs_compat"], + "assets": { + "directory": "./.output/public", + "binding": "ASSETS", + }, +} From 2f3ae320061d670f8c6e96f6af81eb56b0a4b031 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 30 Sep 2026 10:05:01 +0200 Subject: [PATCH 06/12] Revert "test(nitro): Add cloudflare-nitro e2e app" This reverts commit ab0b58ffada02a9504454e400b79ad22b72a1634. --- .../cloudflare-nitro/.gitignore | 7 --- .../cloudflare-nitro/package.json | 35 ------------- .../cloudflare-nitro/playwright.config.mjs | 14 ------ .../cloudflare-nitro/server/api/index.ts | 5 -- .../cloudflare-nitro/server/api/test-cache.ts | 23 --------- .../cloudflare-nitro/server/api/test-error.ts | 5 -- .../server/api/test-transaction.ts | 5 -- .../cloudflare-nitro/server/plugins/sentry.ts | 49 ------------------- .../cloudflare-nitro/start-event-proxy.mjs | 6 --- .../cloudflare-nitro/tests/cache.test.ts | 27 ---------- .../tests/server-timing.test.ts | 12 ----- .../tests/transactions.test.ts | 36 -------------- .../cloudflare-nitro/tsconfig.json | 12 ----- .../cloudflare-nitro/vite.config.ts | 36 -------------- .../cloudflare-nitro/wrangler.jsonc | 11 ----- 15 files changed, 283 deletions(-) delete mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-nitro/.gitignore delete mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-nitro/package.json delete mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-nitro/playwright.config.mjs delete mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-nitro/server/api/index.ts delete mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-nitro/server/api/test-cache.ts delete mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-nitro/server/api/test-error.ts delete mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-nitro/server/api/test-transaction.ts delete mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-nitro/server/plugins/sentry.ts delete mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-nitro/start-event-proxy.mjs delete mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-nitro/tests/cache.test.ts delete mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-nitro/tests/server-timing.test.ts delete mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-nitro/tests/transactions.test.ts delete mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-nitro/tsconfig.json delete mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-nitro/vite.config.ts delete mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-nitro/wrangler.jsonc diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-nitro/.gitignore b/dev-packages/e2e-tests/test-applications/cloudflare-nitro/.gitignore deleted file mode 100644 index d2858d86f66b..000000000000 --- a/dev-packages/e2e-tests/test-applications/cloudflare-nitro/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -node_modules -.output -.nitro -.wrangler -dist -test-results -pnpm-lock.yaml diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-nitro/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-nitro/package.json deleted file mode 100644 index 1b05747ed008..000000000000 --- a/dev-packages/e2e-tests/test-applications/cloudflare-nitro/package.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "name": "cloudflare-nitro", - "version": "1.0.0", - "private": true, - "type": "module", - "scripts": { - "build": "vite build", - "preview": "wrangler dev --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --port 3030 --log-level=$(test $CI && echo 'none' || echo 'log')", - "test": "playwright test", - "clean": "npx rimraf node_modules pnpm-lock.yaml .output .nitro .wrangler", - "test:build": "pnpm install && pnpm build", - "test:assert": "pnpm test" - }, - "dependencies": { - "@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz", - "@sentry/server-utils": "file:../../packed/sentry-server-utils-packed.tgz" - }, - "devDependencies": { - "@cloudflare/workers-types": "^5.20260727.1", - "@playwright/test": "~1.63.0", - "@sentry-internal/test-utils": "link:../../../test-utils", - "nitro": "^3.0.260522-beta", - "rolldown": "latest", - "vite": "latest", - "wrangler": "^4.114.0" - }, - "pnpm": { - "overrides": { - "unstorage": "2.0.0-alpha.7" - } - }, - "volta": { - "extends": "../../package.json" - } -} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-nitro/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/cloudflare-nitro/playwright.config.mjs deleted file mode 100644 index 5deda7e232e6..000000000000 --- a/dev-packages/e2e-tests/test-applications/cloudflare-nitro/playwright.config.mjs +++ /dev/null @@ -1,14 +0,0 @@ -import { getPlaywrightConfig } from '@sentry-internal/test-utils'; - -// `vite build` produces the Cloudflare worker under `.output` (with the orchestrion transform -// applied); `pnpm preview` serves it with `wrangler dev`. -export default getPlaywrightConfig( - { - startCommand: 'pnpm preview', - port: 3030, - }, - { - workers: '100%', - retries: 0, - }, -); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-nitro/server/api/index.ts b/dev-packages/e2e-tests/test-applications/cloudflare-nitro/server/api/index.ts deleted file mode 100644 index a9fca21eecfb..000000000000 --- a/dev-packages/e2e-tests/test-applications/cloudflare-nitro/server/api/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { defineHandler } from 'nitro/h3'; - -export default defineHandler(() => { - return { status: 'ok' }; -}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-nitro/server/api/test-cache.ts b/dev-packages/e2e-tests/test-applications/cloudflare-nitro/server/api/test-cache.ts deleted file mode 100644 index 2eb376230f8e..000000000000 --- a/dev-packages/e2e-tests/test-applications/cloudflare-nitro/server/api/test-cache.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { defineCachedFunction } from 'nitro/cache'; -import { defineHandler, getQuery } from 'nitro/h3'; - -const getCachedUser = defineCachedFunction( - async (userId: string) => { - return { id: userId, name: `User ${userId}`, timestamp: Date.now() }; - }, - { - maxAge: 60, - name: 'getCachedUser', - getKey: (userId: string) => `user:${userId}`, - }, -); - -export default defineHandler(async event => { - const userId = String(getQuery(event).user ?? '123'); - - // First call is a cache miss (a `cache.put`), the second is a hit (a `cache.get`). - const first = await getCachedUser(userId); - const second = await getCachedUser(userId); - - return { first, second }; -}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-nitro/server/api/test-error.ts b/dev-packages/e2e-tests/test-applications/cloudflare-nitro/server/api/test-error.ts deleted file mode 100644 index 170efb1977ab..000000000000 --- a/dev-packages/e2e-tests/test-applications/cloudflare-nitro/server/api/test-error.ts +++ /dev/null @@ -1,5 +0,0 @@ -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/cloudflare-nitro/server/api/test-transaction.ts b/dev-packages/e2e-tests/test-applications/cloudflare-nitro/server/api/test-transaction.ts deleted file mode 100644 index 264f02a64328..000000000000 --- a/dev-packages/e2e-tests/test-applications/cloudflare-nitro/server/api/test-transaction.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { defineHandler } from 'nitro/h3'; - -export default defineHandler(() => { - return { result: 'transaction' }; -}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-nitro/server/plugins/sentry.ts b/dev-packages/e2e-tests/test-applications/cloudflare-nitro/server/plugins/sentry.ts deleted file mode 100644 index 95c0f5ed2c81..000000000000 --- a/dev-packages/e2e-tests/test-applications/cloudflare-nitro/server/plugins/sentry.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { getDefaultIntegrations, setAsyncLocalStorageAsyncContextStrategy } from '@sentry/cloudflare'; -import { wrapRequestHandler } from '@sentry/cloudflare/request'; -import { definePlugin } from 'nitro'; - -/** - * Initializes the Cloudflare SDK for a raw Nitro app. - * - * There is no first-class `@sentry/nitro` Cloudflare helper yet, so this replicates what - * `@sentry/nuxt`'s `sentryCloudflareNitroPlugin` does, adapted to Nitro 3: it wraps `nitroApp.fetch` - * (Nitro 3 has no `localFetch`) with `wrapRequestHandler`. Nitro's `cloudflare_module` preset - * augments the incoming request with `request.runtime.cloudflare = { env, context }` - * (see the preset's `augmentReq`), which is where the DSN and the execution context come from. - * - * The `nitroIntegration` / `nitroServerTimingIntegration` span + header instrumentation is not set - * up here — it is auto-injected by the orchestrion transform (see `vite.config.ts`) and instantiated - * by `getDefaultIntegrations()` from the global orchestrion marker. - */ -export default definePlugin(nitroApp => { - const originalFetch = nitroApp.fetch; - - nitroApp.fetch = ( - request: Request & { runtime?: { cloudflare?: { env?: Record; context?: unknown } } }, - ) => { - setAsyncLocalStorageAsyncContextStrategy(); - - const cloudflare = request.runtime?.cloudflare ?? {}; - const env = (cloudflare.env ?? (globalThis as { __env__?: Record }).__env__ ?? {}) as { - E2E_TEST_DSN?: string; - }; - - const options = { - dsn: env.E2E_TEST_DSN, - environment: 'qa', - tunnel: 'http://localhost:3031/', - tracesSampleRate: 1.0, - }; - - return wrapRequestHandler( - { - // `wrapRequestHandler` defaults to the Node-free integration set; the `nodejs_compat` flag is - // on for the Cloudflare preset, so opt into the full set to match a normal Cloudflare init. - options: { ...options, defaultIntegrations: getDefaultIntegrations(options) }, - request, - context: cloudflare.context as ExecutionContext | undefined, - }, - () => originalFetch(request), - ); - }; -}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-nitro/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/cloudflare-nitro/start-event-proxy.mjs deleted file mode 100644 index bfd71243b8db..000000000000 --- a/dev-packages/e2e-tests/test-applications/cloudflare-nitro/start-event-proxy.mjs +++ /dev/null @@ -1,6 +0,0 @@ -import { startEventProxyServer } from '@sentry-internal/test-utils'; - -startEventProxyServer({ - port: 3031, - proxyServerName: 'cloudflare-nitro', -}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-nitro/tests/cache.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-nitro/tests/cache.test.ts deleted file mode 100644 index 3b79a5f0caa0..000000000000 --- a/dev-packages/e2e-tests/test-applications/cloudflare-nitro/tests/cache.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; - -// Nitro's `defineCachedFunction` goes through unstorage, whose tracing channels `nitroIntegration` -// subscribes to — so cache spans on Cloudflare are further proof the auto-injected integration is -// active (its storage subscriber, not just the HTTP one). -function collectCacheSpans() { - return collectStreamedSpans( - 'cloudflare-nitro', - spans => - spans.some(span => span.is_segment && span.attributes['url.path']?.value === '/api/test-cache') && - spans.some(span => String(span.attributes['sentry.origin']?.value ?? '') === 'auto.cache.nitro'), - ); -} - -test('emits cache spans from the auto-injected storage instrumentation', async ({ request }) => { - const spansPromise = collectCacheSpans(); - - const res = await request.get('/api/test-cache'); - expect(res.status()).toBe(200); - - const spans = await spansPromise; - - const cacheSpans = spans.filter(span => span.attributes['sentry.origin']?.value === 'auto.cache.nitro'); - expect(cacheSpans.length).toBeGreaterThanOrEqual(1); - expect(getSpanOp(cacheSpans[0]!)).toMatch(/^cache\./); -}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-nitro/tests/server-timing.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-nitro/tests/server-timing.test.ts deleted file mode 100644 index 299f6b1e2b48..000000000000 --- a/dev-packages/e2e-tests/test-applications/cloudflare-nitro/tests/server-timing.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { expect, test } from '@playwright/test'; - -// `nitroServerTimingIntegration` is the second auto-injected integration (anchored on `unstorage`). -// It appends the trace-propagation data as a `Server-Timing` response header, so asserting on the -// header directly is enough — no span collection needed, and it works with tracing on or off. -test('auto-injects the Server-Timing trace-propagation header', async ({ request }) => { - const res = await request.get('/api/test-transaction'); - expect(res.status()).toBe(200); - - const serverTiming = res.headers()['server-timing'] ?? ''; - expect(serverTiming).toContain('sentry-trace'); -}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-nitro/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-nitro/tests/transactions.test.ts deleted file mode 100644 index 6797aef8fd29..000000000000 --- a/dev-packages/e2e-tests/test-applications/cloudflare-nitro/tests/transactions.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; - -// The point of this test: on Cloudflare, `nitroIntegration` is not imported by the app — it is -// auto-injected by the orchestrion transform over `h3`/`unstorage` and instantiated from the global -// marker at `init()`. So seeing `auto.http.nitro.*` spans proves that path works end to end. -function collectRequestSpans() { - return collectStreamedSpans( - 'cloudflare-nitro', - spans => - spans.some(span => span.is_segment && span.attributes['url.path']?.value === '/api/test-transaction') && - spans.some(span => String(span.attributes['sentry.origin']?.value ?? '').startsWith('auto.http.nitro')), - ); -} - -test('auto-injects nitro instrumentation and emits http.server + nitro spans', async ({ request }) => { - const spansPromise = collectRequestSpans(); - - const res = await request.get('/api/test-transaction'); - expect(res.status()).toBe(200); - - const spans = await spansPromise; - - const segmentSpan = spans.find( - span => span.is_segment && span.attributes['url.path']?.value === '/api/test-transaction', - ); - expect(segmentSpan).toBeDefined(); - expect(getSpanOp(segmentSpan!)).toBe('http.server'); - - const nitroSpans = spans.filter( - span => - span.trace_id === segmentSpan!.trace_id && - String(span.attributes['sentry.origin']?.value ?? '').startsWith('auto.http.nitro'), - ); - expect(nitroSpans.length).toBeGreaterThanOrEqual(1); -}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-nitro/tsconfig.json b/dev-packages/e2e-tests/test-applications/cloudflare-nitro/tsconfig.json deleted file mode 100644 index 072c968ab244..000000000000 --- a/dev-packages/e2e-tests/test-applications/cloudflare-nitro/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "compilerOptions": { - "target": "ESNext", - "module": "ESNext", - "moduleResolution": "bundler", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "types": ["@cloudflare/workers-types"] - }, - "include": ["server/**/*.ts", "vite.config.ts"] -} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-nitro/vite.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-nitro/vite.config.ts deleted file mode 100644 index 03158d79204b..000000000000 --- a/dev-packages/e2e-tests/test-applications/cloudflare-nitro/vite.config.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { INSTRUMENTED_MODULE_NAMES } from '@sentry/server-utils/orchestrion/config'; -import { sentryOrchestrionPlugin } from '@sentry/server-utils/orchestrion/rollup'; -import { nitro } from 'nitro/vite'; -import { defineConfig } from 'vite'; - -// A raw Nitro app targeting Cloudflare. There is no `@sentry/nitro` Cloudflare init helper, so this -// mirrors what `@sentry/nuxt` does for Nitro-on-Cloudflare by hand: -// 1. Bundle for the `cloudflare_module` preset with `nodeCompat` (Nitro's Cloudflare presets need -// the `nodejs_compat` flag, which is also what makes `node:diagnostics_channel` available). -// 2. Run the orchestrion transform over Nitro's server bundle and force the instrumented packages -// to be inlined (an externalized dependency never passes through the transform). This is what -// injects the registration snippet into `h3`/`unstorage`, which registers `nitroIntegration` -// and `nitroServerTimingIntegration` on the Cloudflare SDK at `init()`. -export default defineConfig({ - plugins: [ - nitro({ - preset: 'cloudflare_module', - cloudflare: { - nodeCompat: true, - deployConfig: false, - }, - // Nitro 3 builds the server bundle with rolldown when driven through `nitro/vite`, so the - // transform goes in `rolldownConfig`. `rollupConfig` is kept too so this also works if the - // build ever falls back to the rollup builder — the orchestrion plugin supports both. - rolldownConfig: { - plugins: [sentryOrchestrionPlugin({})], - }, - rollupConfig: { - plugins: [sentryOrchestrionPlugin({})], - }, - externals: { - inline: [...INSTRUMENTED_MODULE_NAMES], - }, - }), - ], -}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-nitro/wrangler.jsonc b/dev-packages/e2e-tests/test-applications/cloudflare-nitro/wrangler.jsonc deleted file mode 100644 index 72cf82f22a03..000000000000 --- a/dev-packages/e2e-tests/test-applications/cloudflare-nitro/wrangler.jsonc +++ /dev/null @@ -1,11 +0,0 @@ -{ - "$schema": "node_modules/wrangler/config-schema.json", - "name": "cloudflare-nitro", - "main": "./.output/server/index.mjs", - "compatibility_date": "2026-05-20", - "compatibility_flags": ["nodejs_compat"], - "assets": { - "directory": "./.output/public", - "binding": "ASSETS", - }, -} From 791ec038e96d09c5242fe79ac5e26b5fe9565a2a Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 30 Sep 2026 10:10:03 +0200 Subject: [PATCH 07/12] test(deno): Decouple event snapshots from the default integration list The `mod.test.ts` snapshots captured the full `sdk.integrations` list, so any change to the default integrations (such as the new nitro integrations) broke them even though the list is incidental to what these tests verify. Normalize `sdk.integrations` to a placeholder, like the other volatile fields. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../deno/test/__snapshots__/mod.test.ts.snap | 63 +------------------ packages/deno/test/normalize.ts | 6 ++ 2 files changed, 9 insertions(+), 60 deletions(-) diff --git a/packages/deno/test/__snapshots__/mod.test.ts.snap b/packages/deno/test/__snapshots__/mod.test.ts.snap index 3500fc9330c7..a38158657b59 100644 --- a/packages/deno/test/__snapshots__/mod.test.ts.snap +++ b/packages/deno/test/__snapshots__/mod.test.ts.snap @@ -47,26 +47,7 @@ snapshot[`captureMessage 1`] = ` platform: "javascript", sdk: { integrations: [ - "EventFilters", - "RequestData", - "FunctionToString", - "LinkedErrors", - "Dedupe", - "ConversationId", - "Breadcrumbs", - "Console", - "DenoContext", - "DenoServe", - "Fetch", - "DenoHttp", - "ContextLines", - "NormalizePaths", - "GlobalHandlers", - "Express", - "Fastify", - "Hapi", - "Hono", - "Koa", + "{{integrations}}", ], name: "sentry.javascript.deno", packages: [ @@ -136,26 +117,7 @@ snapshot[`captureMessage twice 1`] = ` platform: "javascript", sdk: { integrations: [ - "EventFilters", - "RequestData", - "FunctionToString", - "LinkedErrors", - "Dedupe", - "ConversationId", - "Breadcrumbs", - "Console", - "DenoContext", - "DenoServe", - "Fetch", - "DenoHttp", - "ContextLines", - "NormalizePaths", - "GlobalHandlers", - "Express", - "Fastify", - "Hapi", - "Hono", - "Koa", + "{{integrations}}", ], name: "sentry.javascript.deno", packages: [ @@ -232,26 +194,7 @@ snapshot[`captureMessage twice 2`] = ` platform: "javascript", sdk: { integrations: [ - "EventFilters", - "RequestData", - "FunctionToString", - "LinkedErrors", - "Dedupe", - "ConversationId", - "Breadcrumbs", - "Console", - "DenoContext", - "DenoServe", - "Fetch", - "DenoHttp", - "ContextLines", - "NormalizePaths", - "GlobalHandlers", - "Express", - "Fastify", - "Hapi", - "Hono", - "Koa", + "{{integrations}}", ], name: "sentry.javascript.deno", packages: [ diff --git a/packages/deno/test/normalize.ts b/packages/deno/test/normalize.ts index d6cddaa82c98..15af97f30b7b 100644 --- a/packages/deno/test/normalize.ts +++ b/packages/deno/test/normalize.ts @@ -79,6 +79,12 @@ function normalizeEvent(event: Event): Event { } } + // The default integration list is incidental to these snapshots, so collapse it to a placeholder. + // This keeps the snapshots stable when integrations are added to or removed from the defaults. + if (event.sdk?.integrations) { + event.sdk.integrations = ['{{integrations}}']; + } + if (event.contexts?.app?.app_start_time) { event.contexts.app.app_start_time = '{{time}}'; } From a7ed9265da8188ff6607d43fd45f39d8c4bbde61 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 30 Sep 2026 14:26:01 +0200 Subject: [PATCH 08/12] chore: Bump @sentry/node size limits for the nitro integrations Stacked on develop's hono size bump: the nitro integrations add on top, so @sentry/node needs 146 KB and the no-channel-injection variant 125 KB. Co-Authored-By: Claude Opus 4.8 (1M context) --- .size-limit.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index b276522b06e3..3b02d11ae07f 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -419,7 +419,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '156 KB', + limit: '157 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -465,7 +465,7 @@ module.exports = [ path: 'packages/node/build/esm/index.js', import: createImport('init'), gzip: true, - limit: '135 KB', + limit: '137 KB', disablePlugins: ['@size-limit/esbuild'], ignore: [...builtinModules, ...nodePrefixedBuiltinModules], modifyWebpackConfig: function (config) { From 1ac547f87ef2be8d967270076c3175bc6b476e21 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 30 Sep 2026 16:05:54 +0200 Subject: [PATCH 09/12] feat(nitro): Add nitroIntegration as an explicit default integration `nitroIntegration` is part of the Node tracing integrations, but the Nitro SDK should always include it regardless of that gating. Add it explicitly to the Nitro SDK's default integrations (deduped by name when both are added), and re-export `nitroIntegration`/`nitroServerTimingIntegration` from `@sentry/node`. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/nitro/src/sdk.ts | 6 ++++-- packages/node/src/index.ts | 2 ++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/nitro/src/sdk.ts b/packages/nitro/src/sdk.ts index d67c0594aa2b..4fddb1c9b399 100644 --- a/packages/nitro/src/sdk.ts +++ b/packages/nitro/src/sdk.ts @@ -1,7 +1,7 @@ import type { Integration } from '@sentry/core'; import { applySdkMetadata } from '@sentry/core'; import type { NodeClient, NodeOptions } from '@sentry/node'; -import { getDefaultIntegrations as getDefaultNodeIntegrations, init as nodeInit } from '@sentry/node'; +import { getDefaultIntegrations as getDefaultNodeIntegrations, init as nodeInit, nitroIntegration } from '@sentry/node'; /** * Initializes the Nitro SDK @@ -28,5 +28,7 @@ export function init(options: NodeOptions | undefined = {}): NodeClient | undefi * @returns The default integrations for the Nitro SDK. */ export function getDefaultIntegrations(options: NodeOptions): Integration[] | undefined { - return [...getDefaultNodeIntegrations(options)]; + // `nitroIntegration` is also part of the Node tracing integrations, but add it explicitly so it is + // always present for the Nitro SDK regardless of that gating (deduped by name when both are added). + return [...getDefaultNodeIntegrations(options), nitroIntegration()]; } diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index df5d309ee9e6..d36686441cac 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -33,6 +33,8 @@ export { mysqlIntegration, mistralAIIntegration, mysql2Integration, + nitroIntegration, + nitroServerTimingIntegration, openAIIntegration, togetherAIIntegration, typesafeIntegration, From cce2f0e5f8c16de4e833a91936d63f063c9652a9 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 1 Oct 2026 09:17:41 +0200 Subject: [PATCH 10/12] feat(nitro): Re-export the nitro integrations from all node-based SDKs nitroIntegration and nitroServerTimingIntegration are exported from @sentry/node, so every SDK with an explicit re-export list needs them too (caught by the node-exports-test-app consistency check). Deno and Bun also enable them by default via getTracingIntegrations(). Co-Authored-By: Claude Opus 5.5 --- packages/astro/src/index.server.ts | 2 ++ packages/aws-serverless/src/index.ts | 2 ++ packages/bun/src/index.ts | 2 ++ packages/deno/src/index.ts | 2 ++ packages/elysia/src/index.ts | 2 ++ packages/google-cloud-serverless/src/index.ts | 2 ++ packages/remix/src/server/index.ts | 2 ++ packages/solidstart/src/server/index.ts | 2 ++ packages/sveltekit/src/server/index.ts | 2 ++ 9 files changed, 18 insertions(+) diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index dc77f7d5f6b9..90b31917d4a8 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -87,6 +87,8 @@ export { mongoIntegration, mongooseIntegration, mysql2Integration, + nitroIntegration, + nitroServerTimingIntegration, mysqlIntegration, nativeNodeFetchIntegration, NodeClient, diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index cd302d5523f0..5c7f7f22393c 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -124,6 +124,8 @@ export { mongooseIntegration, mysqlIntegration, mysql2Integration, + nitroIntegration, + nitroServerTimingIntegration, redisIntegration, tediousIntegration, pinoIntegration, diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index c0cc191e2850..2ccd4b24f315 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -146,6 +146,8 @@ export { mongooseIntegration, mysqlIntegration, mysql2Integration, + nitroIntegration, + nitroServerTimingIntegration, redisIntegration, tediousIntegration, postgresIntegration, diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index c47dc1628c74..26a534649107 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -149,6 +149,8 @@ export { mongooseIntegration, mysqlIntegration, mysql2Integration, + nitroIntegration, + nitroServerTimingIntegration, mistralAIIntegration, openAIIntegration, groqIntegration, diff --git a/packages/elysia/src/index.ts b/packages/elysia/src/index.ts index 865ace1fc978..dc9e0d9296cf 100644 --- a/packages/elysia/src/index.ts +++ b/packages/elysia/src/index.ts @@ -125,6 +125,8 @@ export { mongooseIntegration, mysqlIntegration, mysql2Integration, + nitroIntegration, + nitroServerTimingIntegration, redisIntegration, tediousIntegration, postgresIntegration, diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index 5bbf486e2342..d0817ea843cf 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -124,6 +124,8 @@ export { mongooseIntegration, mysqlIntegration, mysql2Integration, + nitroIntegration, + nitroServerTimingIntegration, redisIntegration, tediousIntegration, pinoIntegration, diff --git a/packages/remix/src/server/index.ts b/packages/remix/src/server/index.ts index d35dceb18969..177dc0e3bc91 100644 --- a/packages/remix/src/server/index.ts +++ b/packages/remix/src/server/index.ts @@ -70,6 +70,8 @@ export { mongoIntegration, mongooseIntegration, mysql2Integration, + nitroIntegration, + nitroServerTimingIntegration, mysqlIntegration, nativeNodeFetchIntegration, NodeClient, diff --git a/packages/solidstart/src/server/index.ts b/packages/solidstart/src/server/index.ts index fc4f29d21181..7f362af77b02 100644 --- a/packages/solidstart/src/server/index.ts +++ b/packages/solidstart/src/server/index.ts @@ -73,6 +73,8 @@ export { mongoIntegration, mongooseIntegration, mysql2Integration, + nitroIntegration, + nitroServerTimingIntegration, mysqlIntegration, nativeNodeFetchIntegration, NodeClient, diff --git a/packages/sveltekit/src/server/index.ts b/packages/sveltekit/src/server/index.ts index d01ea0d03fc2..04efcf64b4fc 100644 --- a/packages/sveltekit/src/server/index.ts +++ b/packages/sveltekit/src/server/index.ts @@ -71,6 +71,8 @@ export { mongoIntegration, mongooseIntegration, mysql2Integration, + nitroIntegration, + nitroServerTimingIntegration, mysqlIntegration, nativeNodeFetchIntegration, NodeClient, From eb9579af9e52caffaef0d2e761e5c31da7bff77b Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Mon, 12 Oct 2026 15:16:19 +0200 Subject: [PATCH 11/12] Apply batched suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jan Peer Stöcklmair --- packages/server-utils/src/integrations/nitro/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/server-utils/src/integrations/nitro/index.ts b/packages/server-utils/src/integrations/nitro/index.ts index 0ae9415c86e9..0c2bb407dd1b 100644 --- a/packages/server-utils/src/integrations/nitro/index.ts +++ b/packages/server-utils/src/integrations/nitro/index.ts @@ -4,7 +4,7 @@ import { captureStorageEvents } from './captureStorageEvents'; import { captureTracingEvents } from './captureTracingEvents'; import { captureServerTimingHeaders } from './setServerTimingHeaders'; -const INTEGRATION_NAME = 'Nitro'; +const INTEGRATION_NAME = 'Nitro' as const; const _nitroIntegration = (() => { return { @@ -27,7 +27,7 @@ const _nitroIntegration = (() => { */ export const nitroIntegration = defineIntegration(_nitroIntegration); -const SERVER_TIMING_INTEGRATION_NAME = 'NitroServerTiming'; +const SERVER_TIMING_INTEGRATION_NAME = 'NitroServerTiming' as const; const _nitroServerTimingIntegration = (() => { return { From 1d35ca982d1f34ac4462960d5a45de6d209ad0ab Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Mon, 12 Oct 2026 15:55:00 +0200 Subject: [PATCH 12/12] test(nitro): Add node integration tests for the nitro integrations Drives h3 v2, srvx and unstorage with their tracing plugins enabled, which publish the same diagnostics channels as a Nitro app, so the integrations are covered on Node without a Nitro build. Co-Authored-By: Claude Opus 5.5 --- .../node-integration-tests/package.json | 3 + .../suites/tracing/nitro/instrument.mjs | 9 ++ .../suites/tracing/nitro/scenario.mjs | 23 ++++ .../suites/tracing/nitro/test.ts | 106 ++++++++++++++++++ yarn.lock | 26 ++--- 5 files changed, 154 insertions(+), 13 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/nitro/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/nitro/scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/nitro/test.ts diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index e7d9dface49b..1636db2880ee 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -72,6 +72,7 @@ "generic-pool": "^3.9.0", "graphql": "^16.11.0", "groq-sdk": "1.6.0", + "h3": "2.0.1-rc.22", "graphql-tag": "^2.12.7", "hono": "^4.13.7", "http-terminator": "^3.2.0", @@ -102,7 +103,9 @@ "reflect-metadata": "0.2.2", "together-ai": "0.54.0", "rxjs": "^7.8.2", + "srvx": "^0.11.22", "tedious": "^19.2.1", + "unstorage": "2.0.0-alpha.7", "winston": "^3.17.0", "ws": "^8.20.1", "yargs": "^16.2.0" diff --git a/dev-packages/node-integration-tests/suites/tracing/nitro/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/nitro/instrument.mjs new file mode 100644 index 000000000000..46a27dd03b74 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/nitro/instrument.mjs @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/nitro/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/nitro/scenario.mjs new file mode 100644 index 000000000000..2c0ec50d5705 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/nitro/scenario.mjs @@ -0,0 +1,23 @@ +import { sendPortToRunner } from '@sentry-internal/node-integration-tests'; +import { H3, serve } from 'h3'; +import { tracingPlugin as h3TracingPlugin } from 'h3/tracing'; +import { tracingPlugin as srvxTracingPlugin } from 'srvx/tracing'; +import { createStorage } from 'unstorage'; +import { withTracing } from 'unstorage/tracing'; + +// Enables the same diagnostics channels a Nitro app publishes, without a Nitro build. +const storage = withTracing(createStorage()); + +const app = new H3({ plugins: [h3TracingPlugin()] }); + +app.get('/hello/:name', event => `Hello, ${event.context.params.name}!`); + +app.get('/cache', async () => { + await storage.setItem('cache:key', 'value'); + return storage.getItem('cache:key'); +}); + +const server = serve(app, { port: 0, silent: true, plugins: [srvxTracingPlugin()] }); + +await server.ready(); +sendPortToRunner(new URL(server.url).port); diff --git a/dev-packages/node-integration-tests/suites/tracing/nitro/test.ts b/dev-packages/node-integration-tests/suites/tracing/nitro/test.ts new file mode 100644 index 000000000000..499815daca0d --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/nitro/test.ts @@ -0,0 +1,106 @@ +import { afterAll, describe, expect, vi } from 'vitest'; +import { supports } from '../../../utils'; +import { cleanupChildProcesses, createEsmTests } from '../../../utils/runner'; + +// h3 v2 and srvx are ESM-only and require Node >= 20. +describe.runIf(supports({ min: 20 }))('nitro integrations', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('creates h3 and srvx spans and sets Server-Timing headers', async () => { + let traceId: string | undefined; + + const runner = createRunner() + .expect({ + span: container => { + const segment = container.items.find(span => span.is_segment); + const srvxSpan = container.items.find( + span => span.attributes['sentry.origin']?.value === 'auto.http.nitro.srvx', + ); + const h3Span = container.items.find( + span => span.attributes['sentry.origin']?.value === 'auto.http.nitro.h3', + ); + + traceId = segment?.trace_id; + + expect(segment?.name).toBe('GET /hello/:name'); + expect(segment?.attributes['http.route']).toEqual({ type: 'string', value: '/hello/:name' }); + + // Bun publishes no `http.server.request.start`, so there the srvx span is the segment itself. + expect(srvxSpan).toMatchObject({ + status: 'ok', + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'http.server' }, + 'http.response.status_code': { type: 'integer', value: 200 }, + }), + }); + + expect(h3Span).toMatchObject({ + name: 'GET /hello/:name', + parent_span_id: srvxSpan?.span_id, + status: 'ok', + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'http.server' }, + 'http.route': { type: 'string', value: '/hello/:name' }, + }), + }); + }, + }) + .start(); + + const port = await vi.waitFor(() => runner.getPort() ?? Promise.reject(new Error('port not ready')), { + timeout: 30_000, + }); + const response = await fetch(`http://localhost:${port}/hello/world`); + expect(await response.text()).toBe('Hello, world!'); + + await runner.completed(); + + const serverTiming = response.headers.get('server-timing'); + expect(serverTiming).toContain(`sentry-trace;desc="${traceId}-`); + expect(serverTiming).toContain('baggage;desc="'); + }); + + test('creates cache spans for unstorage operations', async () => { + const runner = createRunner() + .expect({ + span: container => { + const segment = container.items.find(span => span.is_segment); + const cacheSpans = container.items.filter( + span => span.attributes['sentry.origin']?.value === 'auto.cache.nitro', + ); + + expect(segment?.name).toBe('GET /cache'); + expect(cacheSpans).toEqual([ + expect.objectContaining({ + name: 'cache.put', + status: 'ok', + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'cache.put' }, + 'cache.key': { type: 'string', value: 'cache:key' }, + 'db.operation.name': { type: 'string', value: 'setItem' }, + 'db.system.name': { type: 'string', value: 'memory' }, + }), + }), + expect.objectContaining({ + name: 'cache.get', + status: 'ok', + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'cache.get' }, + 'cache.key': { type: 'string', value: 'cache:key' }, + 'cache.hit': { type: 'boolean', value: true }, + 'db.operation.name': { type: 'string', value: 'getItem' }, + }), + }), + ]); + }, + }) + .start(); + + expect(await runner.makeRequest('get', '/cache')).toBe('value'); + await runner.completed(); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index fa2ba2499ab2..08d66a9e18dc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16785,6 +16785,14 @@ h3@1.15.3: ufo "^1.6.1" uncrypto "^0.1.3" +h3@2.0.1-rc.22, h3@^2.0.1-rc.16, h3@^2.0.1-rc.20: + version "2.0.1-rc.22" + resolved "https://registry.yarnpkg.com/h3/-/h3-2.0.1-rc.22.tgz#12c94d2f99e9afa42b490ebfda6163035e98be2a" + integrity sha512-Esv0DMIuPkCTSWCA0vO73vcTqwzH1wjSrAO1TXNu/K3up1sZHa9EKMapbmxCDYBeymC3fVTk4qxp7ogQWQ+KgA== + dependencies: + rou3 "^0.8.1" + srvx "^0.11.15" + h3@^1.10.0, h3@^1.15.10, h3@^1.15.11: version "1.15.11" resolved "https://registry.yarnpkg.com/h3/-/h3-1.15.11.tgz#831179fc6b4bc06de8ad1077e7a5c7d63b796577" @@ -16800,14 +16808,6 @@ h3@^1.10.0, h3@^1.15.10, h3@^1.15.11: ufo "^1.6.3" uncrypto "^0.1.3" -h3@^2.0.1-rc.16, h3@^2.0.1-rc.20: - version "2.0.1-rc.22" - resolved "https://registry.yarnpkg.com/h3/-/h3-2.0.1-rc.22.tgz#12c94d2f99e9afa42b490ebfda6163035e98be2a" - integrity sha512-Esv0DMIuPkCTSWCA0vO73vcTqwzH1wjSrAO1TXNu/K3up1sZHa9EKMapbmxCDYBeymC3fVTk4qxp7ogQWQ+KgA== - dependencies: - rou3 "^0.8.1" - srvx "^0.11.15" - handle-thing@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/handle-thing/-/handle-thing-2.0.1.tgz#857f79ce359580c340d43081cc648970d0bb234e" @@ -27129,6 +27129,11 @@ unplugin@^3.0.0, unplugin@^3.3.0: picomatch "^4.0.4" webpack-virtual-modules "^0.6.2" +unstorage@2.0.0-alpha.7, unstorage@^2.0.0-alpha.6, unstorage@^2.0.0-alpha.7: + version "2.0.0-alpha.7" + resolved "https://registry.yarnpkg.com/unstorage/-/unstorage-2.0.0-alpha.7.tgz#803ea90176683bf2175bb01065cb07df6d65280a" + integrity sha512-ELPztchk2zgFJnakyodVY3vJWGW9jy//keJ32IOJVGUMyaPydwcA1FtVvWqT0TNRch9H+cMNEGllfVFfScImog== + unstorage@^1.16.0, unstorage@^1.17.5: version "1.17.5" resolved "https://registry.yarnpkg.com/unstorage/-/unstorage-1.17.5.tgz#e76c82fdc1d2c04cb0e2c0a1de08aa08b2253f51" @@ -27143,11 +27148,6 @@ unstorage@^1.16.0, unstorage@^1.17.5: ofetch "^1.5.1" ufo "^1.6.3" -unstorage@^2.0.0-alpha.6, unstorage@^2.0.0-alpha.7: - version "2.0.0-alpha.7" - resolved "https://registry.yarnpkg.com/unstorage/-/unstorage-2.0.0-alpha.7.tgz#803ea90176683bf2175bb01065cb07df6d65280a" - integrity sha512-ELPztchk2zgFJnakyodVY3vJWGW9jy//keJ32IOJVGUMyaPydwcA1FtVvWqT0TNRch9H+cMNEGllfVFfScImog== - untun@^0.1.3: version "0.1.3" resolved "https://registry.yarnpkg.com/untun/-/untun-0.1.3.tgz#5d10dee37a3a5737ff03d158be877dae0a0e58a6"