diff --git a/dev-packages/node-integration-tests/suites/tracing/hapi/instrument-orchestrion.mjs b/dev-packages/node-integration-tests/suites/tracing/hapi/instrument-orchestrion.mjs new file mode 100644 index 000000000000..92843fdb33c3 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/hapi/instrument-orchestrion.mjs @@ -0,0 +1,17 @@ +// Opting in via `experimentalUseDiagnosticsChannelInjection()` (before `init`) +// is all that's needed. +// +// `Sentry.init()` swaps the OTel `Hapi` integration +// for the diagnostics-channel one and synchronously +// installs the module hooks that inject the channels. +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.experimentalUseDiagnosticsChannelInjection(); + +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/hapi/test.ts b/dev-packages/node-integration-tests/suites/tracing/hapi/test.ts index 2510ecece88b..1386de6f7953 100644 --- a/dev-packages/node-integration-tests/suites/tracing/hapi/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/hapi/test.ts @@ -6,7 +6,7 @@ describe('hapi auto-instrumentation', () => { cleanupChildProcesses(); }); - const EXPECTED_TRANSACTION = { + const expectedTransaction = (origin: string): Record => ({ transaction: 'GET /', spans: expect.arrayContaining([ expect.objectContaining({ @@ -14,16 +14,16 @@ describe('hapi auto-instrumentation', () => { 'http.route': '/', 'http.method': 'GET', 'hapi.type': 'router', - 'sentry.origin': 'auto.http.otel.hapi', + 'sentry.origin': origin, 'sentry.op': 'router.hapi', }), description: 'GET /', op: 'router.hapi', - origin: 'auto.http.otel.hapi', + origin, status: 'ok', }), ]), - }; + }); const EXPECTED_ERROR_EVENT = { exception: { @@ -36,101 +36,118 @@ describe('hapi auto-instrumentation', () => { }, }; - createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { - test('should auto-instrument `@hapi/hapi` package.', async () => { - const runner = createRunner().expect({ transaction: EXPECTED_TRANSACTION }).start(); - runner.makeRequest('get', '/'); - await runner.completed(); - }); + // The orchestrion (diagnostics-channel injection) path produces the same hapi + // span ops and attributes as the OTel path; only the span origin differs to + // signal the injection mechanism (`auto.http.orchestrion.hapi` vs + // `auto.http.otel.hapi`), mirroring the mysql orchestrion integration. + // `@hapi/hapi` is in the injected version range (`>=17.0.0 <22.0.0`), and the + // channels are injected synchronously by `Sentry.init()`, so no extra Node + // flags are needed and ESM works too. + const INSTRUMENT_FILES = ['instrument.mjs', 'instrument-orchestrion.mjs'] as const; - test('should instrument plugin routes and server extensions.', async () => { - const runner = createRunner() - .expect({ - transaction: { - transaction: 'GET /plugin-route', - spans: expect.arrayContaining([ - expect.objectContaining({ - description: 'GET /plugin-route', - op: 'plugin.hapi', - origin: 'auto.http.otel.hapi', - data: expect.objectContaining({ - 'http.route': '/plugin-route', - 'hapi.type': 'plugin', - 'hapi.plugin.name': 'testPlugin', - 'sentry.op': 'plugin.hapi', - 'sentry.origin': 'auto.http.otel.hapi', - }), - }), - expect.objectContaining({ - description: 'ext - onPreResponse', - op: 'server.ext.hapi', - origin: 'auto.http.otel.hapi', - data: expect.objectContaining({ - 'hapi.type': 'server.ext', - 'server.ext.type': 'onPreResponse', - 'sentry.op': 'server.ext.hapi', - 'sentry.origin': 'auto.http.otel.hapi', - }), - }), - ]), - }, - }) - .start(); - runner.makeRequest('get', '/plugin-route'); - await runner.completed(); - }); + for (const instrument of INSTRUMENT_FILES) { + const origin = instrument === 'instrument.mjs' ? 'auto.http.otel.hapi' : 'auto.http.orchestrion.hapi'; - test('should handle returned plain errors in routes.', async () => { - const runner = createRunner() - .expect({ - transaction: { - transaction: 'GET /error', - }, - }) - .expect({ event: EXPECTED_ERROR_EVENT }) - .start(); - runner.makeRequest('get', '/error', { expectError: true }); - await runner.completed(); - }); + describe(instrument === 'instrument.mjs' ? 'opentelemetry' : 'diagnostics-channel (orchestrion)', () => { + createEsmAndCjsTests(__dirname, 'scenario.mjs', instrument, (createRunner, test) => { + test('should auto-instrument `@hapi/hapi` package.', async () => { + const runner = createRunner() + .expect({ transaction: expectedTransaction(origin) }) + .start(); + runner.makeRequest('get', '/'); + await runner.completed(); + }); - test('should assign parameterized transactionName to error.', async () => { - const runner = createRunner() - .expect({ - event: { - ...EXPECTED_ERROR_EVENT, - transaction: 'GET /error/{id}', - }, - }) - .ignore('transaction') - .start(); - runner.makeRequest('get', '/error/123', { expectError: true }); - await runner.completed(); - }); + test('should instrument plugin routes and server extensions.', async () => { + const runner = createRunner() + .expect({ + transaction: { + transaction: 'GET /plugin-route', + spans: expect.arrayContaining([ + expect.objectContaining({ + description: 'GET /plugin-route', + op: 'plugin.hapi', + origin, + data: expect.objectContaining({ + 'http.route': '/plugin-route', + 'hapi.type': 'plugin', + 'hapi.plugin.name': 'testPlugin', + 'sentry.op': 'plugin.hapi', + 'sentry.origin': origin, + }), + }), + expect.objectContaining({ + description: 'ext - onPreResponse', + op: 'server.ext.hapi', + origin, + data: expect.objectContaining({ + 'hapi.type': 'server.ext', + 'server.ext.type': 'onPreResponse', + 'sentry.op': 'server.ext.hapi', + 'sentry.origin': origin, + }), + }), + ]), + }, + }) + .start(); + runner.makeRequest('get', '/plugin-route'); + await runner.completed(); + }); - test('should handle returned Boom errors in routes.', async () => { - const runner = createRunner() - .expect({ - transaction: { - transaction: 'GET /boom-error', - }, - }) - .expect({ event: EXPECTED_ERROR_EVENT }) - .start(); - runner.makeRequest('get', '/boom-error', { expectError: true }); - await runner.completed(); - }); + test('should handle returned plain errors in routes.', async () => { + const runner = createRunner() + .expect({ + transaction: { + transaction: 'GET /error', + }, + }) + .expect({ event: EXPECTED_ERROR_EVENT }) + .start(); + runner.makeRequest('get', '/error', { expectError: true }); + await runner.completed(); + }); + + test('should assign parameterized transactionName to error.', async () => { + const runner = createRunner() + .expect({ + event: { + ...EXPECTED_ERROR_EVENT, + transaction: 'GET /error/{id}', + }, + }) + .ignore('transaction') + .start(); + runner.makeRequest('get', '/error/123', { expectError: true }); + await runner.completed(); + }); - test('should handle promise rejections in routes.', async () => { - const runner = createRunner() - .expect({ - transaction: { - transaction: 'GET /promise-error', - }, - }) - .expect({ event: EXPECTED_ERROR_EVENT }) - .start(); - runner.makeRequest('get', '/promise-error', { expectError: true }); - await runner.completed(); + test('should handle returned Boom errors in routes.', async () => { + const runner = createRunner() + .expect({ + transaction: { + transaction: 'GET /boom-error', + }, + }) + .expect({ event: EXPECTED_ERROR_EVENT }) + .start(); + runner.makeRequest('get', '/boom-error', { expectError: true }); + await runner.completed(); + }); + + test('should handle promise rejections in routes.', async () => { + const runner = createRunner() + .expect({ + transaction: { + transaction: 'GET /promise-error', + }, + }) + .expect({ event: EXPECTED_ERROR_EVENT }) + .start(); + runner.makeRequest('get', '/promise-error', { expectError: true }); + await runner.completed(); + }); + }); }); - }); + } }); diff --git a/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts b/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts index 6bba862cdfc0..93dcd92cb4ba 100644 --- a/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts +++ b/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts @@ -1,6 +1,7 @@ import { mysqlChannelIntegration, lruMemoizerChannelIntegration, + hapiChannelIntegration, detectOrchestrionSetup, } from '@sentry/server-utils/orchestrion'; import { registerDiagnosticsChannelInjection } from '@sentry/server-utils/orchestrion/register'; @@ -41,7 +42,11 @@ import { setDiagnosticsChannelInjectionLoader } from './diagnosticsChannelInject */ export function experimentalUseDiagnosticsChannelInjection(): void { setDiagnosticsChannelInjectionLoader((): DiagnosticsChannelInjection => { - const integrations = [mysqlChannelIntegration(), lruMemoizerChannelIntegration()] as const; + const integrations = [ + mysqlChannelIntegration(), + lruMemoizerChannelIntegration(), + hapiChannelIntegration(), + ] as const; const replacedOtelIntegrationNames = integrations.map(i => i.name); return { diff --git a/packages/server-utils/src/integrations/tracing-channel/hapi-types.ts b/packages/server-utils/src/integrations/tracing-channel/hapi-types.ts new file mode 100644 index 000000000000..898795c17f94 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/hapi-types.ts @@ -0,0 +1,83 @@ +/* + * Structural type definitions and constants ported from the vendored + * `@opentelemetry/instrumentation-hapi` types, with all `@hapi/*` and + * `@opentelemetry/*` dependencies removed. Only the shapes actually accessed by + * the orchestrion hapi subscriber are kept. + */ + +// Single source of truth for the request lifecycle extension points, so the +// `ServerRequestExtType` union and the runtime `HapiLifecycleMethodNames` set +// below can't drift apart. +const LIFECYCLE_EXT_POINTS = [ + 'onPreAuth', + 'onCredentials', + 'onPostAuth', + 'onPreHandler', + 'onPostHandler', + 'onPreResponse', + 'onRequest', +] as const; + +export type ServerRequestExtType = (typeof LIFECYCLE_EXT_POINTS)[number]; + +export type LifecycleMethod = (request: unknown, h: unknown, err?: Error) => unknown; + +export interface ServerRouteOptions { + handler?: LifecycleMethod | unknown; + [key: string]: unknown; +} + +export interface ServerRoute { + path: string; + method: string; + handler?: LifecycleMethod | unknown; + options?: ((server: unknown) => ServerRouteOptions) | ServerRouteOptions; + [key: string]: unknown; +} + +export interface ServerExtEventsObject { + type: string; + [key: string]: unknown; +} + +export interface ServerExtEventsRequestObject { + type: ServerRequestExtType; + method: LifecycleMethod; + [key: string]: unknown; +} + +export interface ServerExtOptions { + [key: string]: unknown; +} + +/** + * This symbol is used to mark a Hapi route handler or server extension handler as + * already patched, since it's possible to use these handlers multiple times + * i.e. when allowing multiple versions of one plugin, or when registering a plugin + * multiple times on different servers. + */ +export const handlerPatched: unique symbol = Symbol('hapi-handler-patched'); + +export type PatchableServerRoute = ServerRoute & { + [handlerPatched]?: boolean; +}; + +export type PatchableExtMethod = LifecycleMethod & { + [handlerPatched]?: boolean; +}; + +export type ServerExtDirectInput = [ServerRequestExtType, LifecycleMethod, (ServerExtOptions | undefined)?]; + +export const HapiLayerType = { + ROUTER: 'router', + PLUGIN: 'plugin', + EXT: 'server.ext', +} as const; + +export const HapiLifecycleMethodNames = new Set(LIFECYCLE_EXT_POINTS); + +export enum AttributeNames { + HAPI_TYPE = 'hapi.type', + PLUGIN_NAME = 'hapi.plugin.name', + EXT_TYPE = 'server.ext.type', +} diff --git a/packages/server-utils/src/integrations/tracing-channel/hapi-utils.ts b/packages/server-utils/src/integrations/tracing-channel/hapi-utils.ts new file mode 100644 index 000000000000..9ae7f9cd2e60 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/hapi-utils.ts @@ -0,0 +1,268 @@ +/* + * OTel-free, `@hapi/*`-free port of the span-building helpers and handler/ext + * wrap logic from the vendored `@opentelemetry/instrumentation-hapi` + * (upstream @opentelemetry/instrumentation-hapi@0.64.0). Span output (names, + * ops, origins, attributes) is kept byte-identical to that instrumentation; + * span creation goes through the `@sentry/core` API and the OTel active-span + * guard is replaced with `getActiveSpan()`. + */ + +import { + getActiveSpan, + getRootSpan, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + spanToJSON, + startSpan, +} from '@sentry/core'; +import type { + LifecycleMethod, + PatchableExtMethod, + PatchableServerRoute, + ServerExtDirectInput, + ServerExtEventsObject, + ServerExtEventsRequestObject, + ServerRequestExtType, + ServerRoute, + ServerRouteOptions, +} from './hapi-types'; +import { AttributeNames, handlerPatched, HapiLayerType, HapiLifecycleMethodNames } from './hapi-types'; + +// Inlined OTel semantic-convention string constants — orchestrion's whole point +// is to step away from the OTel auto-instrumentation stack, so we don't import +// `@opentelemetry/semantic-conventions`. Values match the vendored instrumentation. +const ATTR_HTTP_ROUTE = 'http.route'; +const ATTR_HTTP_METHOD = 'http.method'; + +type SpanAttributes = Record; + +interface SpanMetadata { + attributes: SpanAttributes; + name: string; +} + +/** + * Set the `http.route` attribute on the root HTTP server span for the current trace. + * + * No-op when there is no active span, no root span, or the root span is not an + * `http.server` span — so framework instrumentations can call this unconditionally + * without risking attribute pollution on non-HTTP root spans. + */ +function setHttpServerSpanRouteAttribute(route: string): void { + const activeSpan = getActiveSpan(); + if (!activeSpan) { + return; + } + const rootSpan = getRootSpan(activeSpan); + if (!rootSpan) { + return; + } + if (spanToJSON(rootSpan).data[SEMANTIC_ATTRIBUTE_SENTRY_OP] !== 'http.server') { + return; + } + rootSpan.setAttribute(ATTR_HTTP_ROUTE, route); +} + +const isLifecycleExtType = (variableToCheck: unknown): variableToCheck is ServerRequestExtType => { + return typeof variableToCheck === 'string' && HapiLifecycleMethodNames.has(variableToCheck); +}; + +const isLifecycleExtEventObj = (variableToCheck: unknown): variableToCheck is ServerExtEventsRequestObject => { + const event = (variableToCheck as ServerExtEventsRequestObject)?.type; + return event !== undefined && isLifecycleExtType(event); +}; + +const isDirectExtInput = (variableToCheck: unknown): variableToCheck is ServerExtDirectInput => { + return ( + Array.isArray(variableToCheck) && + variableToCheck.length <= 3 && + isLifecycleExtType(variableToCheck[0]) && + typeof variableToCheck[1] === 'function' + ); +}; + +const isPatchableExtMethod = ( + variableToCheck: PatchableExtMethod | PatchableExtMethod[], +): variableToCheck is PatchableExtMethod => { + return !Array.isArray(variableToCheck); +}; + +/** Build the span name and attributes for a Hapi route. */ +export const getRouteMetadata = (route: ServerRoute, pluginName?: string): SpanMetadata => { + const attributes: SpanAttributes = { + [ATTR_HTTP_ROUTE]: route.path, + [ATTR_HTTP_METHOD]: route.method, + }; + + let name; + if (pluginName) { + attributes[AttributeNames.HAPI_TYPE] = HapiLayerType.PLUGIN; + attributes[AttributeNames.PLUGIN_NAME] = pluginName; + name = `${pluginName}: route - ${route.path}`; + } else { + attributes[AttributeNames.HAPI_TYPE] = HapiLayerType.ROUTER; + name = `route - ${route.path}`; + } + + return { attributes, name }; +}; + +/** Build the span name and attributes for a Hapi server extension. */ +export const getExtMetadata = ( + extPoint: ServerRequestExtType, + pluginName?: string, + methodName?: string, +): SpanMetadata => { + let baseName = `ext - ${extPoint}`; + if (methodName && methodName !== 'method') { + // `method` is the default name for the extension in the ServerExtEventsObject format. + baseName = `ext - ${extPoint} - ${methodName}`; + } + if (pluginName) { + return { + attributes: { + [AttributeNames.EXT_TYPE]: extPoint, + [AttributeNames.HAPI_TYPE]: HapiLayerType.EXT, + [AttributeNames.PLUGIN_NAME]: pluginName, + }, + name: `${pluginName}: ${baseName}`, + }; + } + return { + attributes: { + [AttributeNames.EXT_TYPE]: extPoint, + [AttributeNames.HAPI_TYPE]: HapiLayerType.EXT, + }, + name: baseName, + }; +}; + +function startMetadataSpan(metadata: SpanMetadata, original: () => unknown): unknown { + return startSpan( + { + name: metadata.name, + op: `${metadata.attributes[AttributeNames.HAPI_TYPE]}.hapi`, + attributes: { + ...metadata.attributes, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.orchestrion.hapi', + }, + }, + original, + ); +} + +/** + * Patches each individual route handler method in order to create the span. It + * does not create spans when there is no parent span. + */ +function wrapRouteHandler(route: PatchableServerRoute, pluginName?: string): PatchableServerRoute { + if (route[handlerPatched] === true) return route; + route[handlerPatched] = true; + + const wrapHandler: (oldHandler: LifecycleMethod) => LifecycleMethod = oldHandler => { + return function (this: unknown, ...params: Parameters) { + if (!getActiveSpan()) { + return oldHandler.call(this, ...params); + } + setHttpServerSpanRouteAttribute(route.path); + const metadata = getRouteMetadata(route, pluginName); + return startMetadataSpan(metadata, () => oldHandler.call(this, ...params)); + }; + }; + + if (typeof route.handler === 'function') { + route.handler = wrapHandler(route.handler as LifecycleMethod); + } else if (typeof route.options === 'function') { + const oldOptions = route.options; + route.options = function (server: unknown): ServerRouteOptions { + const options = oldOptions(server); + if (typeof options.handler === 'function') { + options.handler = wrapHandler(options.handler as LifecycleMethod); + } + return options; + }; + } else if (typeof route.options?.handler === 'function') { + route.options.handler = wrapHandler(route.options.handler as LifecycleMethod); + } + return route; +} + +/** + * Wraps request extension methods to add instrumentation to each new extension + * handler. It does not create spans when there is no parent span. + */ +function wrapExtMethods( + method: T, + extPoint: ServerRequestExtType, + pluginName?: string, +): T { + if (Array.isArray(method)) { + for (let i = 0; i < method.length; i++) { + method[i] = wrapExtMethods(method[i]!, extPoint); + } + return method; + } else if (isPatchableExtMethod(method)) { + if (method[handlerPatched] === true) return method; + method[handlerPatched] = true; + + const newHandler: PatchableExtMethod = function (this: unknown, ...params: Parameters) { + if (!getActiveSpan()) { + return method.apply(this, params); + } + const metadata = getExtMetadata(extPoint, pluginName, method.name); + return startMetadataSpan(metadata, () => method.apply(undefined, params)); + }; + // Mark the wrapper too (not just the original) + newHandler[handlerPatched] = true; + return newHandler as T; + } + return method; +} + +/** + * Wrap the route handler(s) in the live `server.route` arguments array, mutating + * `args[0]` in place. `args[0]` is either a single route options object or an + * array of them. Idempotent via the `handlerPatched` marker. + */ +export function wrapRouteArguments(args: unknown[], pluginName?: string): void { + const route = args[0] as PatchableServerRoute | PatchableServerRoute[]; + if (Array.isArray(route)) { + for (let i = 0; i < route.length; i++) { + route[i] = wrapRouteHandler(route[i]!, pluginName); + } + } else { + args[0] = wrapRouteHandler(route, pluginName); + } +} + +/** + * Wrap the extension method(s) in the live `server.ext` arguments array, + * mutating `args` in place. Handles the three accepted input shapes: + * `(eventsArray)`, `(lifecycleEventObject)`, and `(extTypeString, method, options)`. + * Idempotent via the `handlerPatched` marker. + */ +export function wrapExtArguments(args: unknown[], pluginName?: string): void { + if (Array.isArray(args[0])) { + const eventsList = args[0] as ServerExtEventsObject[] | ServerExtEventsRequestObject[]; + for (let i = 0; i < eventsList.length; i++) { + const eventObj = eventsList[i]!; + if (isLifecycleExtType(eventObj.type)) { + const lifecycleEventObj = eventObj as ServerExtEventsRequestObject; + const handler = wrapExtMethods(lifecycleEventObj.method, eventObj.type, pluginName); + lifecycleEventObj.method = handler; + eventsList[i] = lifecycleEventObj; + } + } + return; + } else if (isDirectExtInput(args)) { + const extInput: ServerExtDirectInput = args; + const method: PatchableExtMethod = extInput[1]; + const handler = wrapExtMethods(method, extInput[0], pluginName); + args[1] = handler; + return; + } else if (isLifecycleExtEventObj(args[0])) { + const lifecycleEventObj = args[0]; + const handler = wrapExtMethods(lifecycleEventObj.method, lifecycleEventObj.type, pluginName); + lifecycleEventObj.method = handler; + } +} diff --git a/packages/server-utils/src/integrations/tracing-channel/hapi.ts b/packages/server-utils/src/integrations/tracing-channel/hapi.ts new file mode 100644 index 000000000000..48a2d441873f --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/hapi.ts @@ -0,0 +1,70 @@ +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import type { IntegrationFn } from '@sentry/core'; +import { debug, defineIntegration } from '@sentry/core'; +import { DEBUG_BUILD } from '../../debug-build'; +import { CHANNELS } from '../../orchestrion/channels'; +import { wrapExtArguments, wrapRouteArguments } from './hapi-utils'; + +// NOTE: same name as the OTel integration by design — when enabled, the OTel +// 'Hapi' integration is omitted from the default set. +const INTEGRATION_NAME = 'Hapi' as const; + +/** + * The shape orchestrion's transform attaches to the `@hapi/hapi` route/ext + * tracing-channel `context` objects. + * + * `arguments` is the *live* args array passed to `server.route` / `server.ext`; + * we mutate it in place to swap handlers for span-creating proxies. `self` is + * the hapi server instance: the root server has `self.realm.plugin === undefined`, + * while a plugin's clone server exposes the registering plugin's name there. + */ +interface HapiChannelContext { + arguments: unknown[]; + self?: { realm?: { plugin?: string } }; +} + +const _hapiChannelIntegration = (() => { + return { + name: INTEGRATION_NAME, + setupOnce() { + if (!diagnosticsChannel.tracingChannel) { + return; + } + + DEBUG_BUILD && + debug.log(`[orchestrion:hapi] subscribing to channels "${CHANNELS.HAPI_ROUTE}" / "${CHANNELS.HAPI_EXT}"`); + + // `subscribe` requires all five lifecycle hooks. We only act on `start`, + // which orchestrion fires synchronously with the live args array — that's + // the moment we mutate the handlers in place. + diagnosticsChannel.tracingChannel(CHANNELS.HAPI_ROUTE).subscribe({ + start(rawCtx) { + const ctx = rawCtx as HapiChannelContext; + wrapRouteArguments(ctx.arguments, ctx.self?.realm?.plugin); + }, + end() {}, + asyncStart() {}, + asyncEnd() {}, + error() {}, + }); + + diagnosticsChannel.tracingChannel(CHANNELS.HAPI_EXT).subscribe({ + start(rawCtx) { + const ctx = rawCtx as HapiChannelContext; + wrapExtArguments(ctx.arguments, ctx.self?.realm?.plugin); + }, + end() {}, + asyncStart() {}, + asyncEnd() {}, + error() {}, + }); + }, + }; +}) satisfies IntegrationFn; + +/** + * EXPERIMENTAL — orchestrion-driven hapi integration. Subscribes to the + * `orchestrion:@hapi/hapi:route` / `:ext` channels injected into `@hapi/hapi`'s + * `lib/server.js`. Requires the orchestrion runtime hook or bundler plugin. + */ +export const hapiChannelIntegration = defineIntegration(_hapiChannelIntegration); diff --git a/packages/server-utils/src/orchestrion/channels.ts b/packages/server-utils/src/orchestrion/channels.ts index ad2d8ccdd4dd..45aa108c4c1e 100644 --- a/packages/server-utils/src/orchestrion/channels.ts +++ b/packages/server-utils/src/orchestrion/channels.ts @@ -14,6 +14,8 @@ export const CHANNELS = { MYSQL_QUERY: 'orchestrion:mysql:query', LRU_MEMOIZER_LOAD: 'orchestrion:lru-memoizer:load', + HAPI_ROUTE: 'orchestrion:@hapi/hapi:route', + HAPI_EXT: 'orchestrion:@hapi/hapi:ext', } as const; export type ChannelName = (typeof CHANNELS)[keyof typeof CHANNELS]; diff --git a/packages/server-utils/src/orchestrion/config.ts b/packages/server-utils/src/orchestrion/config.ts index 104df2185386..a626d35ca778 100644 --- a/packages/server-utils/src/orchestrion/config.ts +++ b/packages/server-utils/src/orchestrion/config.ts @@ -38,6 +38,20 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ module: { name: 'lru-memoizer', versionRange: '>=2.1.0 <4', filePath: 'lib/async.js' }, functionQuery: { functionName: 'memoizedFunction', kind: 'Callback' }, }, + // hapi's `route`/`ext` live on an anonymous class (`internals.Server = class {}`), + // so `{className}` can't match — `{methodName}` targets them in lib/server.js. Both + // are synchronous void methods, so `Sync` suffices: we only use `start` to swap + // handlers in `ctx.arguments`. Shape verified across the whole range. + { + channelName: 'route', + module: { name: '@hapi/hapi', versionRange: '>=17.0.0 <22.0.0', filePath: 'lib/server.js' }, + functionQuery: { methodName: 'route', kind: 'Sync' }, + }, + { + channelName: 'ext', + module: { name: '@hapi/hapi', versionRange: '>=17.0.0 <22.0.0', filePath: 'lib/server.js' }, + functionQuery: { methodName: 'ext', kind: 'Sync' }, + }, ]; /** diff --git a/packages/server-utils/src/orchestrion/index.ts b/packages/server-utils/src/orchestrion/index.ts index 4b182e51ec13..02eafd79d06f 100644 --- a/packages/server-utils/src/orchestrion/index.ts +++ b/packages/server-utils/src/orchestrion/index.ts @@ -1,3 +1,4 @@ export { detectOrchestrionSetup } from './detect'; export { mysqlChannelIntegration } from '../integrations/tracing-channel/mysql'; export { lruMemoizerChannelIntegration } from '../integrations/tracing-channel/lru-memoizer'; +export { hapiChannelIntegration } from '../integrations/tracing-channel/hapi'; diff --git a/packages/server-utils/test/integrations/tracing-channel/hapi-utils.test.ts b/packages/server-utils/test/integrations/tracing-channel/hapi-utils.test.ts new file mode 100644 index 000000000000..862e9ee3e747 --- /dev/null +++ b/packages/server-utils/test/integrations/tracing-channel/hapi-utils.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { getExtMetadata, getRouteMetadata } from '../../../src/integrations/tracing-channel/hapi-utils'; + +describe('getRouteMetadata', () => { + const route = { path: '/users/{id}', method: 'get' } as any; + + it('describes a directly-registered route as a router layer', () => { + expect(getRouteMetadata(route)).toEqual({ + name: 'route - /users/{id}', + attributes: { + 'http.route': '/users/{id}', + 'http.method': 'get', + 'hapi.type': 'router', + }, + }); + }); + + it('describes a plugin-registered route as a plugin layer', () => { + expect(getRouteMetadata(route, 'my-plugin')).toEqual({ + name: 'my-plugin: route - /users/{id}', + attributes: { + 'http.route': '/users/{id}', + 'http.method': 'get', + 'hapi.type': 'plugin', + 'hapi.plugin.name': 'my-plugin', + }, + }); + }); +}); + +describe('getExtMetadata', () => { + it('names an extension by its point', () => { + expect(getExtMetadata('onPreHandler')).toEqual({ + name: 'ext - onPreHandler', + attributes: { 'server.ext.type': 'onPreHandler', 'hapi.type': 'server.ext' }, + }); + }); + + it('includes the method name when it is not the default `method`', () => { + expect(getExtMetadata('onPreHandler', undefined, 'myHandler').name).toBe('ext - onPreHandler - myHandler'); + expect(getExtMetadata('onPreHandler', undefined, 'method').name).toBe('ext - onPreHandler'); + }); + + it('includes the plugin name and prefixes the span name', () => { + expect(getExtMetadata('onPreHandler', 'my-plugin')).toEqual({ + name: 'my-plugin: ext - onPreHandler', + attributes: { + 'server.ext.type': 'onPreHandler', + 'hapi.type': 'server.ext', + 'hapi.plugin.name': 'my-plugin', + }, + }); + }); +}); diff --git a/packages/server-utils/test/integrations/tracing-channel/hapi.test.ts b/packages/server-utils/test/integrations/tracing-channel/hapi.test.ts new file mode 100644 index 000000000000..33f6ee3c5bab --- /dev/null +++ b/packages/server-utils/test/integrations/tracing-channel/hapi.test.ts @@ -0,0 +1,353 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { tracingChannel } from 'node:diagnostics_channel'; +import type { Scope, Span } from '@sentry/core'; +import { + _INTERNAL_setSpanForScope, + Client, + createTransport, + getClient, + getCurrentScope, + getDefaultCurrentScope, + getDefaultIsolationScope, + getGlobalScope, + getIsolationScope, + initAndBind, + resolvedSyncPromise, + setAsyncContextStrategy, + spanToJSON, + startSpan, +} from '@sentry/core'; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { hapiChannelIntegration } from '../../../src/integrations/tracing-channel/hapi'; +import { CHANNELS } from '../../../src/orchestrion/channels'; + +interface TestStore { + scope: Scope; + isolationScope: Scope; +} + +class TestClient extends Client { + public eventFromException(): PromiseLike { + return resolvedSyncPromise({}); + } + + public eventFromMessage(): PromiseLike { + return resolvedSyncPromise({}); + } +} + +function initTestClient(): void { + initAndBind(TestClient, { + dsn: 'https://username@domain/123', + integrations: [], + sendClientReports: false, + stackParser: () => [], + tracesSampleRate: 1, + transport: () => + createTransport( + { + recordDroppedEvent: () => undefined, + }, + () => resolvedSyncPromise({}), + ), + }); +} + +function installTestAsyncContextStrategy(): void { + const asyncStorage = new AsyncLocalStorage(); + + function getScopes(): TestStore { + return ( + asyncStorage.getStore() || { + scope: getDefaultCurrentScope(), + isolationScope: getDefaultIsolationScope(), + } + ); + } + + setAsyncContextStrategy({ + withScope: callback => { + const scope = getScopes().scope.clone(); + const isolationScope = getScopes().isolationScope; + return asyncStorage.run({ scope, isolationScope }, () => callback(scope)); + }, + withSetScope: (scope, callback) => { + const isolationScope = getScopes().isolationScope; + return asyncStorage.run({ scope, isolationScope }, () => callback(scope)); + }, + withIsolationScope: callback => { + const scope = getScopes().scope; + const isolationScope = getScopes().isolationScope.clone(); + return asyncStorage.run({ scope, isolationScope }, () => callback(isolationScope)); + }, + withSetIsolationScope: (isolationScope, callback) => { + const scope = getScopes().scope; + return asyncStorage.run({ scope, isolationScope }, () => callback(isolationScope)); + }, + getCurrentScope: () => getScopes().scope, + getIsolationScope: () => getScopes().isolationScope, + getTracingChannelBinding: () => ({ + asyncLocalStorage: asyncStorage, + getStoreWithActiveSpan: span => { + const scope = getScopes().scope.clone(); + const isolationScope = getScopes().isolationScope; + _INTERNAL_setSpanForScope(scope, span); + return { scope, isolationScope }; + }, + }), + }); +} + +// Capture ended spans as plain JSON for assertions. +function collectSpans(): Array> { + const collected: Array> = []; + getClient()!.on('spanEnd', (span: Span) => { + collected.push(spanToJSON(span)); + }); + return collected; +} + +function publishRoute(args: unknown[], pluginName?: string): void { + tracingChannel(CHANNELS.HAPI_ROUTE).start.publish({ + self: { realm: { plugin: pluginName } }, + arguments: args, + }); +} + +function publishExt(args: unknown[], pluginName?: string): void { + tracingChannel(CHANNELS.HAPI_EXT).start.publish({ + self: { realm: { plugin: pluginName } }, + arguments: args, + }); +} + +describe('hapiChannelIntegration', () => { + // Subscribe exactly once for the whole suite. `diagnostics_channel` subscribers + // are process-global and accumulate, so re-running `setupOnce` per test would + // stack subscribers — mirroring production, where `setupOnce` runs once. + beforeAll(() => { + hapiChannelIntegration().setupOnce!(); + }); + + beforeEach(() => { + installTestAsyncContextStrategy(); + initTestClient(); + }); + + afterEach(() => { + setAsyncContextStrategy(undefined); + getCurrentScope().clear(); + getCurrentScope().setClient(undefined); + getIsolationScope().clear(); + getGlobalScope().clear(); + }); + + it('wraps a directly-registered route handler and creates a router span', () => { + const collected = collectSpans(); + + let called = 0; + const handler = (): string => { + called++; + return 'handler-result'; + }; + const route = { method: 'get', path: '/users/{id}', handler }; + const args: unknown[] = [route]; + + publishRoute(args); + + // Invoke the now-swapped handler the way hapi would: inside the active HTTP server span. + let result: unknown; + startSpan({ name: 'GET /users/{id}', op: 'http.server' }, () => { + result = (route.handler as () => unknown)(); + }); + + expect(called).toBe(1); + expect(result).toBe('handler-result'); + + const routerSpans = collected.filter(s => s.op === 'router.hapi'); + expect(routerSpans).toHaveLength(1); + const span = routerSpans[0]!; + expect(span.description).toBe('route - /users/{id}'); + expect(span.origin).toBe('auto.http.orchestrion.hapi'); + expect(span.data['http.route']).toBe('/users/{id}'); + expect(span.data['http.method']).toBe('get'); + expect(span.data['hapi.type']).toBe('router'); + }); + + it('attributes a route to its plugin when the server realm carries a plugin name', () => { + const collected = collectSpans(); + + const handler = (): string => 'ok'; + const route = { method: 'get', path: '/users/{id}', handler }; + const args: unknown[] = [route]; + + publishRoute(args, 'my-plugin'); + + startSpan({ name: 'GET /users/{id}', op: 'http.server' }, () => { + (route.handler as () => unknown)(); + }); + + const pluginSpans = collected.filter(s => s.op === 'plugin.hapi'); + expect(pluginSpans).toHaveLength(1); + const span = pluginSpans[0]!; + expect(span.description).toBe('my-plugin: route - /users/{id}'); + expect(span.origin).toBe('auto.http.orchestrion.hapi'); + expect(span.data['hapi.type']).toBe('plugin'); + expect(span.data['hapi.plugin.name']).toBe('my-plugin'); + }); + + it('wraps every handler in a route array', () => { + const collected = collectSpans(); + + const routeA = { method: 'get', path: '/a', handler: (): string => 'a' }; + const routeB = { method: 'post', path: '/b', handler: (): string => 'b' }; + const args: unknown[] = [[routeA, routeB]]; + + publishRoute(args); + + let resultA: unknown; + let resultB: unknown; + startSpan({ name: 'GET /a', op: 'http.server' }, () => { + resultA = (routeA.handler as () => unknown)(); + }); + startSpan({ name: 'POST /b', op: 'http.server' }, () => { + resultB = (routeB.handler as () => unknown)(); + }); + + expect(resultA).toBe('a'); + expect(resultB).toBe('b'); + + const routerSpans = collected.filter(s => s.op === 'router.hapi'); + expect(routerSpans).toHaveLength(2); + expect(routerSpans.map(s => s.description).sort()).toEqual(['route - /a', 'route - /b']); + }); + + it('does not create a span when there is no active span, but still calls the handler', () => { + const collected = collectSpans(); + + let called = 0; + const handler = (): string => { + called++; + return 'no-span-result'; + }; + const route = { method: 'get', path: '/users/{id}', handler }; + const args: unknown[] = [route]; + + publishRoute(args); + + // Invoke directly, with NO active span. + const result = (route.handler as () => unknown)(); + + expect(called).toBe(1); + expect(result).toBe('no-span-result'); + expect(collected.filter(s => s.op === 'router.hapi')).toHaveLength(0); + }); + + it('is idempotent: publishing start twice wraps the handler only once', () => { + const collected = collectSpans(); + + let called = 0; + const handler = (): string => { + called++; + return 'ok'; + }; + const route = { method: 'get', path: '/users/{id}', handler }; + const args: unknown[] = [route]; + + publishRoute(args); + publishRoute(args); + + startSpan({ name: 'GET /users/{id}', op: 'http.server' }, () => { + (route.handler as () => unknown)(); + }); + + expect(called).toBe(1); + expect(collected.filter(s => s.op === 'router.hapi')).toHaveLength(1); + }); + + it('wraps an ext method given as an event object and creates a server.ext span', () => { + const collected = collectSpans(); + + let called = 0; + // Anonymous (no inferred `.name`) so the span name stays `ext - onPreHandler`; + // a named method would append `- ` per `getExtMetadata`. + const extEvent = { + type: 'onPreHandler', + method: function (): string { + called++; + return 'ext-result'; + }, + }; + const args: unknown[] = [extEvent]; + + publishExt(args); + + let result: unknown; + startSpan({ name: 'GET /', op: 'http.server' }, () => { + result = (extEvent.method as () => unknown)(); + }); + + expect(called).toBe(1); + expect(result).toBe('ext-result'); + + const extSpans = collected.filter(s => s.op === 'server.ext.hapi'); + expect(extSpans).toHaveLength(1); + const span = extSpans[0]!; + expect(span.description).toBe('ext - onPreHandler'); + expect(span.origin).toBe('auto.http.orchestrion.hapi'); + expect(span.data['hapi.type']).toBe('server.ext'); + expect(span.data['server.ext.type']).toBe('onPreHandler'); + }); + + it('is idempotent: publishing ext start twice wraps the method only once', () => { + const collected = collectSpans(); + + let called = 0; + const extEvent = { + type: 'onPreHandler', + method: function (): string { + called++; + return 'ext-result'; + }, + }; + const args: unknown[] = [extEvent]; + + publishExt(args); + publishExt(args); + + startSpan({ name: 'GET /', op: 'http.server' }, () => { + (extEvent.method as () => unknown)(); + }); + + expect(called).toBe(1); + expect(collected.filter(s => s.op === 'server.ext.hapi')).toHaveLength(1); + }); + + it('wraps an ext method given as a tuple and creates a server.ext span', () => { + const collected = collectSpans(); + + const args: unknown[] = [ + 'onPreHandler', + function (): string { + return 'ext-result'; + }, + {}, + ]; + + publishExt(args); + + let result: unknown; + startSpan({ name: 'GET /', op: 'http.server' }, () => { + result = (args[1] as () => unknown)(); + }); + + expect(result).toBe('ext-result'); + + const extSpans = collected.filter(s => s.op === 'server.ext.hapi'); + expect(extSpans).toHaveLength(1); + const span = extSpans[0]!; + expect(span.description).toBe('ext - onPreHandler'); + expect(span.data['hapi.type']).toBe('server.ext'); + expect(span.data['server.ext.type']).toBe('onPreHandler'); + }); +}); diff --git a/packages/server-utils/test/orchestrion/config.test.ts b/packages/server-utils/test/orchestrion/config.test.ts new file mode 100644 index 000000000000..adf18e807cd4 --- /dev/null +++ b/packages/server-utils/test/orchestrion/config.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import { INSTRUMENTED_MODULE_NAMES, withoutInstrumentedExternals } from '../../src/orchestrion/config'; + +describe('orchestrion config — scoped @hapi/hapi module', () => { + it('includes the scoped @hapi/hapi name in INSTRUMENTED_MODULE_NAMES', () => { + expect(INSTRUMENTED_MODULE_NAMES).toContain('@hapi/hapi'); + }); + + it('strips the scoped package and its subpaths from an externals list', () => { + // `@hapi/hapi` is the first scoped (slashed) module name in the config, so this + // exercises `withoutInstrumentedExternals` against a name containing a `/`. + const external = ['react', '@hapi/hapi', '@hapi/hapi/lib/server.js']; + expect(withoutInstrumentedExternals(external)).toEqual(['react']); + }); +});