diff --git a/.size-limit.js b/.size-limit.js index 80ad8c9f5f4f..fc0ca8883dbb 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -406,7 +406,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '145 KB', + limit: '146 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -452,7 +452,7 @@ module.exports = [ path: 'packages/node/build/esm/index.js', import: createImport('init'), gzip: true, - limit: '123 KB', + limit: '125 KB', disablePlugins: ['@size-limit/esbuild'], ignore: [...builtinModules, ...nodePrefixedBuiltinModules], modifyWebpackConfig: function (config) { diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index b67a20a788f8..4636813f13b6 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 5d0c37614a3e..79b17ba98312 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -121,6 +121,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 d5b1d11ee4e5..204142e85bf8 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -143,6 +143,8 @@ export { mongooseIntegration, mysqlIntegration, mysql2Integration, + nitroIntegration, + nitroServerTimingIntegration, redisIntegration, tediousIntegration, postgresIntegration, diff --git a/packages/cloudflare/src/baseSdk.ts b/packages/cloudflare/src/baseSdk.ts index 94bdb6c17be3..cb6b95e6c7be 100644 --- a/packages/cloudflare/src/baseSdk.ts +++ b/packages/cloudflare/src/baseSdk.ts @@ -37,7 +37,10 @@ import { defaultStackParser } from './vendor/stacktrace'; function getRegisteredChannelIntegrations(): Integration[] { const registered = GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.integrations; - return registered ? [...registered.values()].map(factory => factory()) : []; + // A module holds a single factory, or a Set of them (once it registers more than one integration). + return registered + ? [...registered.values()].flatMap(entry => (entry instanceof Set ? [...entry] : [entry])).map(factory => factory()) + : []; } /** @@ -148,10 +151,9 @@ export function initWithDefaultIntegrations( // integration on the live client here. `addIntegration` dedupes by // integration name, so already-installed integrations are no-ops. client.on('orchestrion.module-injected', moduleName => { - const factory = GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.integrations?.get(moduleName); - if (factory) { - client.addIntegration(factory()); - } + const entry = GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.integrations?.get(moduleName); + const factories = entry instanceof Set ? entry : entry ? [entry] : []; + factories.forEach(factory => client.addIntegration(factory())); }); return client; diff --git a/packages/cloudflare/test/sdk.test.ts b/packages/cloudflare/test/sdk.test.ts index 40dbca79fb84..5c720fde27e8 100644 --- a/packages/cloudflare/test/sdk.test.ts +++ b/packages/cloudflare/test/sdk.test.ts @@ -357,4 +357,37 @@ describe('getDefaultIntegrations', () => { expect(client?.getIntegrationByName('Mysql')).toBeDefined(); }); + + test('installs all integrations for a module registered after init via the module-injected event', async () => { + const { mysqlIntegration, lruMemoizerIntegration } = await import('@sentry/server-utils'); + // `cacheClient: false` so this gets its own client rather than one a previous `init()` cached. + const client = init({ cacheClient: false }); + expect(client?.getIntegrationByName('Mysql')).toBeUndefined(); + expect(client?.getIntegrationByName('LruMemoizer')).toBeUndefined(); + + // A module registering more than one integration (like `h3` → span + Server-Timing) evaluates + // after init: store the Set of factories on the marker, then emit the event. + globalThis.__SENTRY_ORCHESTRION__ = { + bundler: new Set(['some-module']), + integrations: new Map([['some-module', new Set([mysqlIntegration, lruMemoizerIntegration])]]), + }; + client?.emit('orchestrion.module-injected', 'some-module'); + + expect(client?.getIntegrationByName('Mysql')).toBeDefined(); + expect(client?.getIntegrationByName('LruMemoizer')).toBeDefined(); + }); + + test('installs all integrations a single module registers', async () => { + const { mysqlIntegration, lruMemoizerIntegration } = await import('@sentry/server-utils'); + // One module registering more than one integration (like `h3` → span + Server-Timing). + globalThis.__SENTRY_ORCHESTRION__ = { + bundler: new Set(['some-module']), + integrations: new Map([['some-module', new Set([mysqlIntegration, lruMemoizerIntegration])]]), + }; + + const names = getDefaultIntegrations({}).map(i => i.name); + + expect(names).toContain('Mysql'); + expect(names).toContain('LruMemoizer'); + }); }); diff --git a/packages/core/src/utils/worldwide.ts b/packages/core/src/utils/worldwide.ts index 597174c3f94d..074a34dd11c5 100644 --- a/packages/core/src/utils/worldwide.ts +++ b/packages/core/src/utils/worldwide.ts @@ -80,12 +80,15 @@ export type InternalGlobal = { /** * Channel-subscriber integration factories stored by the snippet the * bundler transform splices into each instrumented module, keyed by module - * name. A factory shared by several packages (e.g. pg/pg-pool) appears - * under several keys; integration-name deduplication collapses them at - * setup. A bundler-only SDK (e.g. `@sentry/cloudflare`) reads these at - * `init()` and instantiates them. + * name. A module holds a single factory, or a Set of them once it registers + * more than one integration (e.g. `h3` → span + Server-Timing) — the single + * shape is kept for the common case so an older reader stays compatible. A + * factory shared by several packages (e.g. pg/pg-pool) appears under several + * keys; integration-name deduplication collapses them at setup. A + * bundler-only SDK (e.g. `@sentry/cloudflare`) reads these at `init()` and + * instantiates them. */ - integrations?: Map Integration>; + integrations?: Map Integration) | Set<() => Integration>>; /** * Set once `registerDiagnosticsChannelInjection()` has run but could not * install the runtime module hooks — most commonly because diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index 8db992d91e6a..80ad34243f0c 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -146,6 +146,8 @@ export { mongooseIntegration, mysqlIntegration, mysql2Integration, + nitroIntegration, + nitroServerTimingIntegration, mistralAIIntegration, openAIIntegration, groqIntegration, 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}}'; } diff --git a/packages/elysia/src/index.ts b/packages/elysia/src/index.ts index cf35f6de4eb8..eb65852a8394 100644 --- a/packages/elysia/src/index.ts +++ b/packages/elysia/src/index.ts @@ -122,6 +122,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 d789d83c91a8..404df3b9e1f5 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -121,6 +121,8 @@ export { mongooseIntegration, mysqlIntegration, mysql2Integration, + nitroIntegration, + nitroServerTimingIntegration, redisIntegration, tediousIntegration, pinoIntegration, diff --git a/packages/nitro/package.json b/packages/nitro/package.json index dd4229942bf9..c36fdb300e27 100644 --- a/packages/nitro/package.json +++ b/packages/nitro/package.json @@ -39,8 +39,7 @@ "@sentry/conventions": "^0.24.0", "@sentry/core": "11.1.0", "@sentry/node": "11.1.0", - "@sentry/server-runtime-injection": "11.1.0", - "@sentry/server-utils": "11.1.0" + "@sentry/server-runtime-injection": "11.1.0" }, "devDependencies": { "nitro": "^3.0.260415-beta" 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/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 deed6c7c0b65..67c0c6e1280b 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -32,6 +32,8 @@ export { mysqlIntegration, mistralAIIntegration, mysql2Integration, + nitroIntegration, + nitroServerTimingIntegration, openAIIntegration, togetherAIIntegration, typesafeIntegration, diff --git a/packages/remix/src/server/index.ts b/packages/remix/src/server/index.ts index f18b713dac6b..bce7c4b021ca 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/server-utils/src/index.ts b/packages/server-utils/src/index.ts index c162dadb1629..d8236535b572 100644 --- a/packages/server-utils/src/index.ts +++ b/packages/server-utils/src/index.ts @@ -74,5 +74,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 df8d488b9d28..7937f04a8f05 100644 --- a/packages/server-utils/src/integrations/index.ts +++ b/packages/server-utils/src/integrations/index.ts @@ -29,6 +29,7 @@ import { postgresJsIntegration } from './postgres-js'; 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'; @@ -70,10 +71,22 @@ export function getTracingIntegrations(): Integration[] { postgresJsIntegration(), 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 066ec79bad45..076900d4d7f4 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_OPERATION, SENTRY_OP } from '@sentry/conventions/attributes'; import { CACHE_GET, CACHE_PUT, CACHE_REMOVE } from '@sentry/conventions/op'; import { @@ -9,12 +8,14 @@ import { isObjectLike, SEMANTIC_ATTRIBUTE_CACHE_HIT, SEMANTIC_ATTRIBUTE_CACHE_KEY, + _INTERNAL_safeDateNow as safeDateNow, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, 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'; @@ -75,16 +76,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]]; @@ -173,7 +174,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 bcfad9b3679b..ba332f91b91d 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 } from '@sentry/conventions/attributes'; import { HTTP_SERVER, MIDDLEWARE } from '@sentry/conventions/op'; import { @@ -17,15 +16,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 @@ -70,7 +64,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; @@ -88,12 +82,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); @@ -151,25 +145,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(); @@ -224,7 +212,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) { @@ -261,7 +249,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; + } +} diff --git a/packages/server-utils/src/orchestrion/bundler/moduleInjectedTransform.ts b/packages/server-utils/src/orchestrion/bundler/moduleInjectedTransform.ts index 737d9be98f40..ebed1c347ad8 100644 --- a/packages/server-utils/src/orchestrion/bundler/moduleInjectedTransform.ts +++ b/packages/server-utils/src/orchestrion/bundler/moduleInjectedTransform.ts @@ -1,6 +1,6 @@ import type { CustomTransform } from '../apmTypes'; import { parse } from 'meriyah'; -import { subscriberExportForModule } from '../config/channel-integration-definitions'; +import { subscriberExportsForModule } from '../config/channel-integration-definitions'; import { MODULE_REGISTRATION_TRANSFORM } from '../config/registration-only'; // Tracks Program nodes we already injected into, so a package with several @@ -67,24 +67,27 @@ export const ORCHESTRION_BUNDLER_MARKER_BANNER = */ function moduleInjectedSnippet( moduleName: string, - exportName: string | undefined, + exportNames: string[], esm: boolean, importSpecifier: string, ): string { - const bindings = exportName ? `orchestrionModuleInjected, ${exportName}` : 'orchestrionModuleInjected'; + const bindings = ['orchestrionModuleInjected', ...exportNames].join(', '); const importStmt = esm ? `import { ${bindings} } from ${JSON.stringify(importSpecifier)};` : `const { ${bindings} } = require(${JSON.stringify(importSpecifier)});`; - // `exportName` is itself an integration factory, invoked inside the arrow (`() => exportName()`), so - // `orchestrionModuleInjected`'s consumer still gets an Integration back from calling the stored + // Each `exportName` is itself an integration factory, invoked inside an arrow (`() => exportName()`), + // so `orchestrionModuleInjected`'s consumer still gets an Integration back from calling the stored // thunk. The point of the arrow is to defer the read of the `exportName` binding to when that thunk // runs (at `init()`), instead of reading it by reference the moment this snippet evaluates. A // provided-module integration (e.g. `flueIntegration`) is imported back into its own instrumented // package by `@sentry/*/vite`, so a direct reference here would close an import cycle and touch the // binding in its TDZ ("Cannot access '…' before initialization"); deferring the read breaks that. - const args = exportName ? `${JSON.stringify(moduleName)}, () => ${exportName}()` : JSON.stringify(moduleName); - return `${importStmt}\n${MODULE_INJECTED_SINK} = orchestrionModuleInjected(${args});`; + // A module with several integrations (e.g. `h3`) passes them all in one call, since the helper + // treats each call as the module's complete list. + const args = [JSON.stringify(moduleName), ...exportNames.map(exportName => `() => ${exportName}()`)].join(', '); + const calls = `${MODULE_INJECTED_SINK} = orchestrionModuleInjected(${args});`; + return `${importStmt}\n${calls}`; } /** @@ -133,8 +136,8 @@ export function moduleInjectedTransforms( const specifier = (typeof importSpecifier === 'function' ? importSpecifier() : importSpecifier) ?? DEFAULT_IMPORT_SPECIFIER; - const exportName = subscriberExportForModule(moduleName); - const statements = parse(moduleInjectedSnippet(moduleName, exportName, moduleType === 'esm', specifier), { + const exportNames = subscriberExportsForModule(moduleName); + const statements = parse(moduleInjectedSnippet(moduleName, exportNames, moduleType === 'esm', specifier), { module: moduleType === 'esm', next: true, }).body as ProgramNode['body']; 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 51d25ab28328..2bc3157eb96e 100644 --- a/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts +++ b/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts @@ -60,9 +60,15 @@ export const CHANNEL_INTEGRATION_DEFINITIONS = [ { exportName: 'kafkaIntegration', modules: ['kafkajs'] }, { exportName: 'redisIntegration', modules: ['redis', '@redis/client', 'ioredis'] }, { exportName: 'dataloaderIntegration', modules: ['dataloader'] }, + // Both anchored on `h3` — the module every HTTP-serving Nitro app loads — so a single module can + // register more than one integration. + { exportName: 'nitroIntegration', modules: ['h3'] }, + { exportName: 'nitroServerTimingIntegration', modules: ['h3'] }, ] as const satisfies ReadonlyArray<{ exportName: string; modules: readonly string[] }>; -/** Look up the subscriber export name for an instrumented package, if any. */ -export function subscriberExportForModule(moduleName: string): string | undefined { - return CHANNEL_INTEGRATION_DEFINITIONS.find(d => (d.modules as readonly string[]).includes(moduleName))?.exportName; +/** Look up the subscriber export names for an instrumented package (a module may register several). */ +export function subscriberExportsForModule(moduleName: string): string[] { + return CHANNEL_INTEGRATION_DEFINITIONS.filter(d => (d.modules as readonly string[]).includes(moduleName)).map( + d => d.exportName, + ); } diff --git a/packages/server-utils/src/orchestrion/config/index.ts b/packages/server-utils/src/orchestrion/config/index.ts index 9ed93c98c0bf..eabec35a4e78 100644 --- a/packages/server-utils/src/orchestrion/config/index.ts +++ b/packages/server-utils/src/orchestrion/config/index.ts @@ -30,6 +30,7 @@ import { mongooseConfig } from './mongoose'; import { mysql2Config } from './mysql2'; import { mysqlConfig } from './mysql'; import { nestjsConfig } from './nestjs'; +import { nitroConfig } from './nitro'; import { openaiConfig } from './openai'; import { pgConfig } from './pg'; import { postgresJsConfig } from './postgres'; @@ -86,6 +87,7 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ...mysql2Config, ...mysqlConfig, ...nestjsConfig, + ...nitroConfig, ...openaiConfig, ...pgConfig, ...postgresJsConfig, 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..9cf525eb510c --- /dev/null +++ b/packages/server-utils/src/orchestrion/config/nitro.ts @@ -0,0 +1,25 @@ +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 this entry file 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. + * + * Anchored on `h3` — the module every HTTP-serving Nitro app loads — which registers BOTH + * `nitroIntegration` (spans) and `nitroServerTimingIntegration` (trace-propagation headers); see + * `channel-integration-definitions.ts`. Using the guaranteed HTTP module (rather than, say, + * `unstorage`) avoids a silent failure to register on a Nitro app that happens not to load that other + * module. 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 matters: h3 ships the version Nitro 3 uses as a prerelease + * (`2.0.1-rc.*`). 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' }), +] satisfies InstrumentationConfig[]; diff --git a/packages/server-utils/src/utils/moduleInjected.ts b/packages/server-utils/src/utils/moduleInjected.ts index 599d6292d825..47e847975006 100644 --- a/packages/server-utils/src/utils/moduleInjected.ts +++ b/packages/server-utils/src/utils/moduleInjected.ts @@ -14,7 +14,7 @@ import { getClient, GLOBAL_OBJ } from '@sentry/core'; * imports it from the same entry as the subscriber factories. * * It records the module name on the global orchestrion marker, stores the - * module's channel-subscriber integration factory (when the module has one) + * module's channel-subscriber integration factories (when the module has any) * keyed by module name, and emits the `orchestrion.module-injected` client * event. Recording happens BEFORE the emit so listeners triggered by the event * can read the marker. @@ -28,7 +28,7 @@ import { getClient, GLOBAL_OBJ } from '@sentry/core'; * that evaluate later (e.g. a lazily-required driver after a per-request * `init()` already snapshotted the marker). */ -export function orchestrionModuleInjected(moduleName: string, integrationFn?: () => Integration): void { +export function orchestrionModuleInjected(moduleName: string, ...integrationFns: Array<() => Integration>): void { const marker = (GLOBAL_OBJ.__SENTRY_ORCHESTRION__ ??= {}); // Runtime guard, not just type narrowing: a banner from another SDK copy or @@ -37,8 +37,13 @@ export function orchestrionModuleInjected(moduleName: string, integrationFn?: () (marker.bundler ??= new Set()).add(moduleName); } - if (integrationFn) { - (marker.integrations ??= new Map()).set(moduleName, integrationFn); + // Each call carries the module's complete factory list, so it replaces rather than merges: every + // instrumented file of a package (e.g. pg, amqplib) passes fresh thunks for the same integrations, + // which identity can't dedupe. A single factory keeps the plain shape older readers expect; only a + // module with several integrations (e.g. `h3` → span + Server-Timing) is stored as a Set. + const [first, ...rest] = integrationFns; + if (first) { + (marker.integrations ??= new Map()).set(moduleName, rest.length ? new Set(integrationFns) : first); } getClient()?.emit('orchestrion.module-injected', moduleName); diff --git a/packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts b/packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts index 750ffeaa8d19..bc839fe16b64 100644 --- a/packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts +++ b/packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts @@ -7,7 +7,7 @@ import * as barrel from '../../src/index'; import { SENTRY_INSTRUMENTATIONS } from '../../src/orchestrion/config'; import { CHANNEL_INTEGRATION_DEFINITIONS, - subscriberExportForModule, + subscriberExportsForModule, } from '../../src/orchestrion/config/channel-integration-definitions'; import { moduleInjectedTransforms } from '../../src/orchestrion/bundler/moduleInjectedTransform'; import { orchestrionTransformOptions } from '../../src/orchestrion/bundler/options'; @@ -21,13 +21,15 @@ function makePackage(root: string, name: string, version: string, type?: 'module } describe('channel integration definitions', () => { - it('maps every module to a defined subscriber export', () => { - expect(subscriberExportForModule('mysql')).toBe('mysqlIntegration'); - expect(subscriberExportForModule('pg')).toBe('postgresIntegration'); - expect(subscriberExportForModule('pg-pool')).toBe('postgresIntegration'); - expect(subscriberExportForModule('@redis/client')).toBe('redisIntegration'); - expect(subscriberExportForModule('ioredis')).toBe('redisIntegration'); - expect(subscriberExportForModule('not-a-package')).toBeUndefined(); + it('maps every module to its defined subscriber exports', () => { + expect(subscriberExportsForModule('mysql')).toEqual(['mysqlIntegration']); + expect(subscriberExportsForModule('pg')).toEqual(['postgresIntegration']); + expect(subscriberExportsForModule('pg-pool')).toEqual(['postgresIntegration']); + expect(subscriberExportsForModule('@redis/client')).toEqual(['redisIntegration']); + expect(subscriberExportsForModule('ioredis')).toEqual(['redisIntegration']); + // A single module can map to several integrations. + expect(subscriberExportsForModule('h3')).toEqual(['nitroIntegration', 'nitroServerTimingIntegration']); + expect(subscriberExportsForModule('not-a-package')).toEqual([]); }); it('references only real named exports of @sentry/server-utils', () => { @@ -60,6 +62,7 @@ describe('module-injected transform', () => { makePackage(root, 'my-lib', '1.0.0', 'commonjs'); makePackage(root, 'ioredis', '5.11.0', 'commonjs', 'built'); makePackage(root, 'ai', '7.0.0', 'module', 'dist'); + makePackage(root, 'h3', '2.0.1-rc.1', 'module', 'dist'); }); afterAll(() => { @@ -178,6 +181,22 @@ describe('module-injected transform', () => { expect(result!.code).not.toContain('tr_ch_apm'); }); + it('registers every factory of a multi-integration module in a single call', () => { + const t = createCodeTransformer(orchestrionTransformOptions({})); + const result = t.transform('export function H3() {}\n', join(root, 'node_modules/h3/dist/h3.mjs')); + + expect(result).not.toBeNull(); + expect(result!.code).toMatch( + /import\s*\{\s*orchestrionModuleInjected,\s*nitroIntegration,\s*nitroServerTimingIntegration\s*\}\s*from\s*["']@sentry\/server-utils["']/, + ); + // One call with the module's complete list, so the module is announced once and the helper + // can replace its entry instead of accumulating thunks across files. + expect(result!.code).toContain( + 'orchestrionModuleInjected("h3", () => nitroIntegration(), () => nitroServerTimingIntegration())', + ); + expect(result!.code.match(/orchestrionModuleInjected\(/g)).toHaveLength(1); + }); + it('honors a custom import specifier (Turbopack passes an absolute path)', () => { const t = createCodeTransformer({ ...orchestrionTransformOptions({}), diff --git a/packages/server-utils/test/utils/moduleInjected.test.ts b/packages/server-utils/test/utils/moduleInjected.test.ts index 4e2b70bd59a9..bf71880223df 100644 --- a/packages/server-utils/test/utils/moduleInjected.test.ts +++ b/packages/server-utils/test/utils/moduleInjected.test.ts @@ -41,6 +41,30 @@ describe('orchestrionModuleInjected', () => { expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.integrations?.get('mysql')).toBe(fn); }); + it('stores a Set when a module registers several integrations in one call', () => { + const fn1 = factory('Nitro'); + const fn2 = factory('NitroServerTiming'); + orchestrionModuleInjected('h3', fn1, fn2); + expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.integrations?.get('h3')).toEqual(new Set([fn1, fn2])); + }); + + it('keeps a single factory when several files of one package register the same integration', () => { + // Each instrumented file of a package injects its own thunk for the same integration. + const fromClient = factory('Postgres'); + const fromPool = factory('Postgres'); + orchestrionModuleInjected('pg', fromClient); + orchestrionModuleInjected('pg', fromPool); + expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.integrations?.get('pg')).toBe(fromPool); + }); + + it('does not accumulate factories when several files register a multi-integration module', () => { + orchestrionModuleInjected('h3', factory('Nitro'), factory('NitroServerTiming')); + const fn1 = factory('Nitro'); + const fn2 = factory('NitroServerTiming'); + orchestrionModuleInjected('h3', fn1, fn2); + expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.integrations?.get('h3')).toEqual(new Set([fn1, fn2])); + }); + it('stores no factory when none is given', () => { orchestrionModuleInjected('mongodb'); expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.integrations).toBeUndefined(); diff --git a/packages/solidstart/src/server/index.ts b/packages/solidstart/src/server/index.ts index bc01f6bb9906..04b3adc0cfbb 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 cff338b62ed0..831f80ee655f 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,