From 7d57ce012826e40b0e1318125ea805d2bda2e10c Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Wed, 27 May 2026 14:56:42 +0200 Subject: [PATCH] ref(node): Stop mutating OTel RPC metadata to set `http.route` Where Sentry's HTTP framework integrations previously wrote `route` onto the OTel RPC-metadata context object, they now set the `http.route` attribute directly on the root `http.server` span via the new `setHttpServerSpanRouteAttribute` helper in `@sentry/node`. The helper guards on the root span being `op=http.server` so it's safe to call from any framework instrumentation. `@sentry/node-core`'s HTTP server-span integration continues to read RPC metadata at response time, so third-party OTel instrumentations that set it still get their route picked up. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../connect/vendored/instrumentation.ts | 7 +++--- .../node/src/integrations/tracing/express.ts | 8 +++---- .../tracing/fastify/v3/instrumentation.ts | 7 +++--- .../fastify/vendored/instrumentation.ts | 8 +++---- .../tracing/hapi/vendored/instrumentation.ts | 7 ++---- .../tracing/koa/vendored/instrumentation.ts | 8 +++---- .../utils/setHttpServerSpanRouteAttribute.ts | 23 ++++++++++++++++++ .../src/server/createServerInstrumentation.ts | 6 ----- .../src/server/wrapSentryHandleRequest.ts | 9 ------- .../test/server/getMetaTagTransformer.test.ts | 5 ---- .../server/wrapSentryHandleRequest.test.ts | 24 +++---------------- 11 files changed, 43 insertions(+), 69 deletions(-) create mode 100644 packages/node/src/utils/setHttpServerSpanRouteAttribute.ts diff --git a/packages/node/src/integrations/tracing/connect/vendored/instrumentation.ts b/packages/node/src/integrations/tracing/connect/vendored/instrumentation.ts index 4e5277440da2..911b6753c92e 100644 --- a/packages/node/src/integrations/tracing/connect/vendored/instrumentation.ts +++ b/packages/node/src/integrations/tracing/connect/vendored/instrumentation.ts @@ -21,11 +21,11 @@ /* eslint-disable */ import { context, Span, SpanOptions } from '@opentelemetry/api'; -import { getRPCMetadata, RPCType } from '@opentelemetry/core'; import type { ServerResponse } from 'http'; import { AttributeNames, ConnectNames, ConnectTypes } from './enums/AttributeNames'; import { HandleFunction, NextFunction, Server, PatchedRequest, Use, UseArgs, UseArgs2 } from './internal-types'; import { SDK_VERSION } from '@sentry/core'; +import { setHttpServerSpanRouteAttribute } from '../../../../utils/setHttpServerSpanRouteAttribute'; import { InstrumentationBase, InstrumentationConfig, @@ -119,9 +119,8 @@ export class ConnectInstrumentation extends InstrumentationBase { replaceCurrentStackRoute(req, routeName); - const rpcMetadata = getRPCMetadata(context.active()); - if (routeName && rpcMetadata?.type === RPCType.HTTP) { - rpcMetadata.route = generateRoute(req); + if (routeName) { + setHttpServerSpanRouteAttribute(generateRoute(req)); } let spanName = ''; diff --git a/packages/node/src/integrations/tracing/express.ts b/packages/node/src/integrations/tracing/express.ts index c0f7cbc2414f..6730cd9adf5a 100644 --- a/packages/node/src/integrations/tracing/express.ts +++ b/packages/node/src/integrations/tracing/express.ts @@ -1,8 +1,6 @@ // Automatic istrumentation for Express using OTel import type { InstrumentationConfig } from '@opentelemetry/instrumentation'; import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation'; -import { context } from '@opentelemetry/api'; -import { getRPCMetadata, RPCType } from '@opentelemetry/core'; import { ensureIsWrapped, generateInstrumentOnce } from '@sentry/node-core'; import { @@ -17,6 +15,7 @@ import { } from '@sentry/core'; export { expressErrorHandler } from '@sentry/core'; import { DEBUG_BUILD } from '../../debug-build'; +import { setHttpServerSpanRouteAttribute } from '../../utils/setHttpServerSpanRouteAttribute'; const INTEGRATION_NAME = 'Express'; const SUPPORTED_VERSIONS = ['>=4.0.0 <6']; @@ -51,9 +50,8 @@ export class ExpressInstrumentation extends InstrumentationBase ({ ...this.getConfig(), onRouteResolved(route) { - const rpcMetadata = getRPCMetadata(context.active()); - if (route && rpcMetadata?.type === RPCType.HTTP) { - rpcMetadata.route = route; + if (route) { + setHttpServerSpanRouteAttribute(route); } }, })); diff --git a/packages/node/src/integrations/tracing/fastify/v3/instrumentation.ts b/packages/node/src/integrations/tracing/fastify/v3/instrumentation.ts index 9921ba536ba4..3a8936b37908 100644 --- a/packages/node/src/integrations/tracing/fastify/v3/instrumentation.ts +++ b/packages/node/src/integrations/tracing/fastify/v3/instrumentation.ts @@ -22,7 +22,6 @@ */ import { type Attributes, context, SpanStatusCode, trace } from '@opentelemetry/api'; -import { getRPCMetadata, RPCType } from '@opentelemetry/core'; import { InstrumentationBase, InstrumentationNodeModuleDefinition, @@ -37,6 +36,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, spanToJSON, } from '@sentry/core'; +import { setHttpServerSpanRouteAttribute } from '../../../../utils/setHttpServerSpanRouteAttribute'; import type { FastifyErrorCodes, FastifyInstance, @@ -99,12 +99,11 @@ export class FastifyInstrumentationV3 extends InstrumentationBase = { diff --git a/packages/node/src/integrations/tracing/hapi/vendored/instrumentation.ts b/packages/node/src/integrations/tracing/hapi/vendored/instrumentation.ts index 0fe2c0f14756..51f8475bc066 100644 --- a/packages/node/src/integrations/tracing/hapi/vendored/instrumentation.ts +++ b/packages/node/src/integrations/tracing/hapi/vendored/instrumentation.ts @@ -22,7 +22,7 @@ /* eslint-disable */ import * as api from '@opentelemetry/api'; -import { getRPCMetadata, RPCType } from '@opentelemetry/core'; +import { setHttpServerSpanRouteAttribute } from '../../../../utils/setHttpServerSpanRouteAttribute'; import { InstrumentationBase, InstrumentationConfig, @@ -309,10 +309,7 @@ export class HapiInstrumentation extends InstrumentationBase { if (api.trace.getSpan(api.context.active()) === undefined) { return await oldHandler.call(this, ...params); } - const rpcMetadata = getRPCMetadata(api.context.active()); - if (rpcMetadata?.type === RPCType.HTTP) { - rpcMetadata.route = route.path; - } + setHttpServerSpanRouteAttribute(route.path); const metadata = getRouteMetadata(route, instrumentation._semconvStability, pluginName); const span = instrumentation.tracer.startSpan(metadata.name, { attributes: metadata.attributes, diff --git a/packages/node/src/integrations/tracing/koa/vendored/instrumentation.ts b/packages/node/src/integrations/tracing/koa/vendored/instrumentation.ts index 6251e4d81947..062f5329e084 100644 --- a/packages/node/src/integrations/tracing/koa/vendored/instrumentation.ts +++ b/packages/node/src/integrations/tracing/koa/vendored/instrumentation.ts @@ -31,7 +31,7 @@ import { import { KoaLayerType, KoaInstrumentationConfig } from './types'; import { SDK_VERSION } from '@sentry/core'; import { getMiddlewareMetadata, isLayerIgnored } from './utils'; -import { getRPCMetadata, RPCType } from '@opentelemetry/core'; +import { setHttpServerSpanRouteAttribute } from '../../../../utils/setHttpServerSpanRouteAttribute'; import { Next, kLayerPatched, KoaContext, KoaMiddleware, KoaPatchedMiddleware } from './internal-types'; const PACKAGE_NAME = '@sentry/instrumentation-koa'; @@ -156,10 +156,8 @@ export class KoaInstrumentation extends InstrumentationBase ({ - RPCType: { HTTP: 'http' }, - getRPCMetadata: vi.fn(), -})); - vi.mock('@sentry/core', () => ({ SEMANTIC_ATTRIBUTE_SENTRY_SOURCE: 'sentry.source', SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN: 'sentry.origin', diff --git a/packages/react-router/test/server/wrapSentryHandleRequest.test.ts b/packages/react-router/test/server/wrapSentryHandleRequest.test.ts index 71875d1aa887..e31ea94d06bf 100644 --- a/packages/react-router/test/server/wrapSentryHandleRequest.test.ts +++ b/packages/react-router/test/server/wrapSentryHandleRequest.test.ts @@ -1,5 +1,4 @@ import { PassThrough } from 'node:stream'; -import { RPCType } from '@opentelemetry/core'; import { ATTR_HTTP_ROUTE } from '@opentelemetry/semantic-conventions'; import { flushIfServerless, @@ -13,10 +12,6 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; import { getMetaTagTransformer } from '../../src/server/getMetaTagTransformer'; import { wrapSentryHandleRequest } from '../../src/server/wrapSentryHandleRequest'; -vi.mock('@opentelemetry/core', () => ({ - RPCType: { HTTP: 'http' }, - getRPCMetadata: vi.fn(), -})); vi.mock('@sentry/core', () => ({ SEMANTIC_ATTRIBUTE_SENTRY_SOURCE: 'sentry.source', SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN: 'sentry.origin', @@ -64,13 +59,9 @@ describe('wrapSentryHandleRequest', () => { const mockActiveSpan = {}; const mockRootSpan = { setAttributes: vi.fn() }; - const mockRpcMetadata = { type: RPCType.HTTP, route: '/some-path' }; (getActiveSpan as unknown as ReturnType).mockReturnValue(mockActiveSpan); (getRootSpan as unknown as ReturnType).mockReturnValue(mockRootSpan); - const getRPCMetadata = vi.fn().mockReturnValue(mockRpcMetadata); - (vi.importActual('@opentelemetry/core') as unknown as { getRPCMetadata: typeof getRPCMetadata }).getRPCMetadata = - getRPCMetadata; const routerContext = { staticHandlerContext: { @@ -85,7 +76,6 @@ describe('wrapSentryHandleRequest', () => { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react_router.request_handler', }); - expect(mockRpcMetadata.route).toBe('/some-path'); }); test('should not set span attributes when parameterized path does not exist', async () => { @@ -113,13 +103,10 @@ describe('wrapSentryHandleRequest', () => { const originalHandler = vi.fn().mockResolvedValue('test'); const wrappedHandler = wrapSentryHandleRequest(originalHandler); - const mockRpcMetadata = { type: RPCType.HTTP, route: '/some-path' }; + const mockRootSpan = { setAttributes: vi.fn() }; (getActiveSpan as unknown as ReturnType).mockReturnValue(null); - - const getRPCMetadata = vi.fn().mockReturnValue(mockRpcMetadata); - (vi.importActual('@opentelemetry/core') as unknown as { getRPCMetadata: typeof getRPCMetadata }).getRPCMetadata = - getRPCMetadata; + (getRootSpan as unknown as ReturnType).mockReturnValue(mockRootSpan); const routerContext = { staticHandlerContext: { @@ -129,7 +116,7 @@ describe('wrapSentryHandleRequest', () => { await wrappedHandler(new Request('https://tio.pepe'), 200, new Headers(), routerContext, {} as any); - expect(getRPCMetadata).not.toHaveBeenCalled(); + expect(mockRootSpan.setAttributes).not.toHaveBeenCalled(); }); test('should call flushIfServerless on successful execution', async () => { @@ -190,13 +177,9 @@ describe('wrapSentryHandleRequest', () => { const mockActiveSpan = {}; const mockRootSpan = { setAttributes: vi.fn() }; - const mockRpcMetadata = { type: RPCType.HTTP, route: '/some-path' }; (getActiveSpan as unknown as ReturnType).mockReturnValue(mockActiveSpan); (getRootSpan as unknown as ReturnType).mockReturnValue(mockRootSpan); - const getRPCMetadata = vi.fn().mockReturnValue(mockRpcMetadata); - (vi.importActual('@opentelemetry/core') as unknown as { getRPCMetadata: typeof getRPCMetadata }).getRPCMetadata = - getRPCMetadata; const routerContext = { staticHandlerContext: { @@ -211,7 +194,6 @@ describe('wrapSentryHandleRequest', () => { [ATTR_HTTP_ROUTE]: '/some-path', [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', }); - expect(mockRpcMetadata.route).toBe('/some-path'); }); });