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) { 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..7d02c913edd9 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/nitro/test.ts @@ -0,0 +1,115 @@ +import { + CACHE_HIT, + CACHE_KEY, + DB_OPERATION_NAME, + DB_SYSTEM_NAME, + HTTP_RESPONSE_STATUS_CODE, + HTTP_ROUTE, + SENTRY_OP, + SENTRY_ORIGIN, +} from '@sentry/conventions/attributes'; +import { CACHE_GET, CACHE_PUT, HTTP_SERVER } from '@sentry/conventions/op'; +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/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/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 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/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 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, 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/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..0c2bb407dd1b --- /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' as const; + +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' as const; + +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/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..101b99d11ddc --- /dev/null +++ b/packages/server-utils/src/orchestrion/config/nitro.ts @@ -0,0 +1,26 @@ +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. + * + * 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 + * `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' }), + registrationOnly({ name: 'unstorage', versionRange: '>=2.0.0-0', filePath: 'dist/index.mjs' }), +] satisfies InstrumentationConfig[]; 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, 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"