diff --git a/package-lock.json b/package-lock.json index f5ee920..7156b50 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,10 +13,14 @@ "@opentelemetry/instrumentation": "^0.220.0" }, "devDependencies": { + "@opentelemetry/context-async-hooks": "^2.9.0", "@types/node": "^24.9.1", "oxfmt": "0.57.0", "oxlint": "1.72.0", "typescript": "^7.0.2" + }, + "engines": { + "node": ">=22.12.0" } }, "node_modules/@opentelemetry/api": { @@ -40,6 +44,19 @@ "node": ">=8.0.0" } }, + "node_modules/@opentelemetry/context-async-hooks": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.9.0.tgz", + "integrity": "sha512-OQ0vzvbZBiUhjqLnUaoNfYmP8553Crr3aggB4y0ZUi815mZ7idpdJXQmoKdeBKJelYttoBlLSSHubmyw3wvX4w==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, "node_modules/@opentelemetry/instrumentation": { "version": "0.220.0", "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.220.0.tgz", diff --git a/package.json b/package.json index c45a86e..0548a3b 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "@opentelemetry/instrumentation": "^0.220.0" }, "devDependencies": { + "@opentelemetry/context-async-hooks": "^2.9.0", "@types/node": "^24.9.1", "oxfmt": "0.57.0", "oxlint": "1.72.0", diff --git a/src/imq/types.ts b/src/imq/types.ts index 3f1bbf9..49481f6 100644 --- a/src/imq/types.ts +++ b/src/imq/types.ts @@ -28,7 +28,20 @@ export interface IMQRPCRequest { metadata?: any; } -export interface IMQServiceOptions { +export interface IMQRPCResponse { + data?: any; + error?: any; + request?: IMQRPCRequest; +} + +/** + * The subset of `@imqueue/rpc`'s default option singletons this instrumentation + * mutates. `beforeCall`/`afterCall` are used on the client; `wrapCall` (the + * around-hook) is used on the service so the handler runs inside the span's + * OpenTelemetry context. + */ +export interface IMQCallHooks { beforeCall?: Function; afterCall?: Function; + wrapCall?: Function; } diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 3e93f0f..4d9524f 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -16,150 +16,224 @@ import { InstrumentationBase, type InstrumentationConfig, - InstrumentationNodeModuleDefinition, } from '@opentelemetry/instrumentation'; -import { - type IMQClient, - type IMQRPCRequest, - type IMQServiceOptions, -} from './imq/types.js'; import { context, propagation, SpanKind, + SpanStatusCode, trace, type Tracer, } from '@opentelemetry/api'; -import { AttributeNames, SpanNames, TraceKind } from './enums/index.js'; +import { createRequire } from 'node:module'; import { readFileSync } from 'node:fs'; -import path from 'node:path'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { AttributeNames, SpanNames, TraceKind } from './enums/index.js'; +import { + type IMQCallHooks, + type IMQClient, + type IMQRPCRequest, + type IMQRPCResponse, +} from './imq/types.js'; + +const PACKAGE_NAME = '@imqueue/rpc'; +const COMPONENT_NAME = 'imq'; -let packageJson: { name: string; version: string }; let instrumentationName = '@imqueue/opentelemetry-instrumentation-imqueue'; -let instrumentationVersion = '3.0.0'; -const packageName = '@imqueue/rpc'; -const versions = ['>=1.10']; -const componentName = 'imq'; +let instrumentationVersion = '0.0.0'; try { - packageJson = JSON.parse( - readFileSync(`${path.resolve('.')}${path.sep}package.json`, 'utf8'), + const pkg = JSON.parse( + readFileSync(new URL('../package.json', import.meta.url), 'utf8'), ); - instrumentationName = packageJson.name; - instrumentationVersion = packageJson.version; + instrumentationName = pkg.name; + instrumentationVersion = pkg.version; } catch { - // Use fallback values if package.json cannot be read + // Keep the fallback name/version if the package.json can't be read. } -type ServiceModule = { - DEFAULT_IMQ_CLIENT_OPTIONS: IMQServiceOptions; - DEFAULT_IMQ_SERVICE_OPTIONS: IMQServiceOptions; -}; +/** The `@imqueue/rpc` default option singletons this instrumentation patches. */ +export interface RpcModule { + DEFAULT_IMQ_CLIENT_OPTIONS?: IMQCallHooks; + DEFAULT_IMQ_SERVICE_OPTIONS?: IMQCallHooks; +} +/** + * OpenTelemetry instrumentation for `@imqueue/rpc`. + * + * `@imqueue/rpc` exposes its default client/service options as mutable + * singletons and calls their `beforeCall`/`afterCall`/`wrapCall` hooks around + * every RPC. Rather than intercepting module loading (which, for an ESM package, + * needs import-in-the-middle and rewrites the whole module graph), this patches + * those singletons directly on `enable()` — robust and free of ESM-hook + * fragility. + * + * - Client calls use `beforeCall`/`afterCall`: a CLIENT span is started as a + * child of the active context and its trace context is injected into the + * request metadata for propagation, then ended on response. + * - Service calls use `wrapCall` (the around-hook): the SERVER span is started + * from the propagated parent and the handler is run **inside** that span's + * context (`context.with`), so any spans it or its downstream calls create + * nest correctly. + */ export class ImqueueInstrumentation extends InstrumentationBase { - private static thisTracer: Tracer; - constructor(config: InstrumentationConfig = {}) { - super( - instrumentationName, - instrumentationVersion, - Object.assign({}, config), - ); + super(instrumentationName, instrumentationVersion, config); } - protected init() { - const module = new InstrumentationNodeModuleDefinition( - packageName, - versions, - moduleExports => { - const { beforeCallClient, beforeCallService, afterCall } = this; + /** + * No module-load hook: we patch `@imqueue/rpc`'s mutable default options + * directly (see the class docs), so there is nothing to intercept at import. + */ + protected init(): [] { + return []; + } - Object.assign(moduleExports.DEFAULT_IMQ_CLIENT_OPTIONS, { - beforeCall: beforeCallClient, - afterCall, - }); + public override enable(): void { + const rpc = this.resolveRpc(); - Object.assign(moduleExports.DEFAULT_IMQ_SERVICE_OPTIONS, { - beforeCall: beforeCallService, - afterCall, - }); + if (rpc) { + this.patch(rpc); + } + } + + public override disable(): void { + const rpc = this.resolveRpc(); + + if (rpc) { + this.unpatch(rpc); + } + } - return moduleExports; - }, - moduleExports => { - ImqueueInstrumentation.unpatchClient(moduleExports); - ImqueueInstrumentation.unpatchService(moduleExports); + /** Attach the tracing hooks to a module's default client/service options. */ + public patch(rpc: RpcModule): RpcModule { + const { client, service } = this.hooks(); - return moduleExports; - }, - ); + if (rpc.DEFAULT_IMQ_CLIENT_OPTIONS) { + Object.assign(rpc.DEFAULT_IMQ_CLIENT_OPTIONS, client); + } - ImqueueInstrumentation.thisTracer = this.tracer; + if (rpc.DEFAULT_IMQ_SERVICE_OPTIONS) { + Object.assign(rpc.DEFAULT_IMQ_SERVICE_OPTIONS, service); + } - return module; + return rpc; } - private beforeCallClient = async function ( - this: IMQClient, - req: IMQRPCRequest, - ): Promise { - req.toJSON = () => { - const copy = Object.assign({}, req); - delete copy.span; - return copy; - }; + /** Remove the tracing hooks previously attached by {@link patch}. */ + public unpatch(rpc: RpcModule): RpcModule { + for (const options of [ + rpc.DEFAULT_IMQ_CLIENT_OPTIONS, + rpc.DEFAULT_IMQ_SERVICE_OPTIONS, + ]) { + if (options) { + delete options.beforeCall; + delete options.afterCall; + delete options.wrapCall; + } + } - try { - const span = ImqueueInstrumentation.thisTracer.startSpan( - SpanNames.IMQ_REQUEST, - { - attributes: { - [AttributeNames.SPAN_KIND]: TraceKind.CLIENT, - [AttributeNames.RESOURCE_NAME]: `${this.serviceName}.${ - req.method - }`, - [AttributeNames.SERVICE_NAME]: this.serviceName, - [AttributeNames.IMQ_CLIENT]: req.from, - [AttributeNames.COMPONENT]: componentName, - }, - kind: SpanKind.CLIENT, + return rpc; + } + + /** + * Resolve the live `@imqueue/rpc` module (shared with the app's import). + * Tries this package's own location first (the normal hoisted install), + * then the app's working directory — so a symlinked/`npm link`ed dev setup, + * where resolution from this package can't see the app's deps, still works. + */ + private resolveRpc(): RpcModule | undefined { + const bases = [ + import.meta.url, + pathToFileURL(join(process.cwd(), 'noop.js')).href, + ]; + + for (const base of bases) { + try { + return createRequire(base)(PACKAGE_NAME) as RpcModule; + } catch { + // try the next resolution base + } + } + + return undefined; + } + + /** + * Build the client (`beforeCall`/`afterCall`) and service (`wrapCall`) + * hooks. They read the current tracer lazily, so a tracer provider + * registered after construction is still honoured. + */ + private hooks(): { client: IMQCallHooks; service: IMQCallHooks } { + const tracer = (): Tracer => this.tracer; + + const beforeCall = async function ( + this: IMQClient, + req: IMQRPCRequest, + ): Promise { + keepSpanUnserialized(req); + + const span = tracer().startSpan(SpanNames.IMQ_REQUEST, { + kind: SpanKind.CLIENT, + attributes: { + [AttributeNames.SPAN_KIND]: TraceKind.CLIENT, + [AttributeNames.RESOURCE_NAME]: `${this.serviceName}.${ + req.method + }`, + [AttributeNames.SERVICE_NAME]: this.serviceName, + [AttributeNames.IMQ_CLIENT]: req.from, + [AttributeNames.COMPONENT]: COMPONENT_NAME, }, - ); + }); + // Propagate the client span downstream via the request metadata. req.metadata = req.metadata || {}; req.metadata.clientSpan = {}; - propagation.inject( trace.setSpan(context.active(), span), req.metadata.clientSpan, ); - req.span = span; - } catch { - // Silently handle the error - } - }; + }; + + const afterCall = async function ( + this: IMQClient, + req: IMQRPCRequest, + res?: IMQRPCResponse, + ): Promise { + const span = req.span; + + if (!span) { + return; + } + + if (res?.error) { + span.setStatus({ + code: SpanStatusCode.ERROR, + message: errorMessage(res.error), + }); + } - private beforeCallService = async function ( - this: IMQClient, - req: IMQRPCRequest, - ): Promise { - req.toJSON = () => { - const copy = Object.assign({}, req); - delete copy.span; - return copy; + span.end(); }; - try { - const carrier = (req.metadata || { clientSpan: null }).clientSpan; - const parentContext = propagation.extract( + const wrapCall = async function ( + this: IMQClient, + req: IMQRPCRequest, + _res: IMQRPCResponse, + next: () => Promise, + ): Promise { + keepSpanUnserialized(req); + + const parent = propagation.extract( context.active(), - carrier, + (req.metadata || {}).clientSpan || {}, ); - - req.span = ImqueueInstrumentation.thisTracer.startSpan( + const span = tracer().startSpan( SpanNames.IMQ_RESPONSE, { + kind: SpanKind.SERVER, attributes: { [AttributeNames.SPAN_KIND]: TraceKind.SERVER, [AttributeNames.RESOURCE_NAME]: `${this.name}.${ @@ -167,59 +241,49 @@ export class ImqueueInstrumentation extends InstrumentationBase { }`, [AttributeNames.SERVICE_NAME]: this.name, [AttributeNames.IMQ_CLIENT]: req.from, - [AttributeNames.COMPONENT]: componentName, + [AttributeNames.COMPONENT]: COMPONENT_NAME, }, - kind: SpanKind.SERVER, }, - parentContext, + parent, ); - } catch { - // Silently handle the error - } - }; - - private afterCall = async function ( - this: IMQClient, - req: IMQRPCRequest, - ): Promise { - try { - req.span?.end(); - } catch { - // Silently handle the error - } - }; - private static unpatchClient(serviceModule: ServiceModule): void { - if (!serviceModule.DEFAULT_IMQ_CLIENT_OPTIONS) { - return; - } + req.span = span; - const { beforeCall, afterCall } = - serviceModule.DEFAULT_IMQ_CLIENT_OPTIONS; + try { + // Run the handler INSIDE the span's context so anything it (or + // its downstream calls) traces nests under this server span. + return await context.with(trace.setSpan(parent, span), next); + } catch (err: any) { + span.recordException(err); + span.setStatus({ + code: SpanStatusCode.ERROR, + message: err?.message, + }); - if (beforeCall) { - delete serviceModule.DEFAULT_IMQ_CLIENT_OPTIONS.beforeCall; - } + throw err; + } finally { + span.end(); + } + }; - if (afterCall) { - delete serviceModule.DEFAULT_IMQ_CLIENT_OPTIONS.afterCall; - } + return { + client: { beforeCall, afterCall }, + service: { wrapCall }, + }; } +} - private static unpatchService(serviceModule: ServiceModule): void { - if (!serviceModule.DEFAULT_IMQ_SERVICE_OPTIONS) { - return; - } +/** Keep the live span object out of serialized request payloads. */ +function keepSpanUnserialized(req: IMQRPCRequest): void { + req.toJSON = () => { + const copy: any = Object.assign({}, req); - const { beforeCall, afterCall } = - serviceModule.DEFAULT_IMQ_SERVICE_OPTIONS; + delete copy.span; - if (beforeCall) { - delete serviceModule.DEFAULT_IMQ_SERVICE_OPTIONS.beforeCall; - } + return copy; + }; +} - if (afterCall) { - delete serviceModule.DEFAULT_IMQ_SERVICE_OPTIONS.afterCall; - } - } +function errorMessage(error: any): string { + return typeof error === 'string' ? error : error?.message; } diff --git a/test/instrumentation.spec.ts b/test/instrumentation.spec.ts index edba3f9..9f9066b 100644 --- a/test/instrumentation.spec.ts +++ b/test/instrumentation.spec.ts @@ -15,11 +15,11 @@ * OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR * PERFORMANCE OF THIS SOFTWARE. */ -import { describe, it, type TestContext } from 'node:test'; +import { before, describe, it, type TestContext } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { propagation, trace } from '@opentelemetry/api'; +import { context, trace } from '@opentelemetry/api'; +import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; import { type IMQClient, type IMQRPCRequest } from '../src/imq/types.js'; import { ImqueueInstrumentation } from '../index.js'; @@ -27,10 +27,13 @@ const self = JSON.parse( readFileSync(new URL('../package.json', import.meta.url), 'utf8'), ); -const client: IMQClient = { - name: 'client-name', - serviceName: 'service-name', -}; +// A real context manager so `context.with(...)` actually propagates — required +// to prove the service `wrapCall` runs the handler inside the span's context. +before(() => { + context.setGlobalContextManager(new AsyncLocalStorageContextManager()); +}); + +const client: IMQClient = { name: 'client-name', serviceName: 'service-name' }; const service: IMQClient = { name: 'service-name', serviceName: 'service-name', @@ -50,302 +53,198 @@ function makeSpan(t: TestContext): any { end: t.mock.fn(), setAttribute: t.mock.fn(), setStatus: t.mock.fn(), + recordException: t.mock.fn(), + spanContext: () => ({ + traceId: '0'.repeat(32), + spanId: '0'.repeat(16), + traceFlags: 1, + }), }; } -// the instrumentation captures its tracer statically at init() time from -// InstrumentationBase, which resolves it with trace.getTracer() during -// construction — so the tracer mock must be installed before constructing +// Install a tracer mock BEFORE constructing — the base captures the tracer via +// trace.getTracer() at construction time. function makeInstrumentation(t: TestContext, tracer: any): any { t.mock.method(trace, 'getTracer', () => tracer); return new ImqueueInstrumentation() as any; } -function makeModuleExports(): any { - return { - DEFAULT_IMQ_CLIENT_OPTIONS: {}, - DEFAULT_IMQ_SERVICE_OPTIONS: {}, - }; -} +const emptyModule = () => ({ + DEFAULT_IMQ_CLIENT_OPTIONS: {}, + DEFAULT_IMQ_SERVICE_OPTIONS: {}, +}); describe('ImqueueInstrumentation', () => { describe('constructor', () => { - it('should initialize with default config', () => { - assert.ok( - new ImqueueInstrumentation() instanceof ImqueueInstrumentation, - ); - }); - - it('should initialize with custom config', () => { - const instrumentation = new ImqueueInstrumentation({ - enabled: false, - }); - - assert.ok(instrumentation instanceof ImqueueInstrumentation); - }); - - it('should use name and version from package.json', () => { + it('constructs and reads name/version from package.json', () => { const instrumentation = new ImqueueInstrumentation(); + assert.ok(instrumentation instanceof ImqueueInstrumentation); assert.equal(instrumentation.instrumentationName, self.name); assert.equal(instrumentation.instrumentationVersion, self.version); }); - it('should fall back to defaults when no package.json exists', async () => { - const cwd = process.cwd(); - - process.chdir(tmpdir()); - - try { - // a fresh, query-busted copy evaluates from a directory - // without package.json, exercising the fallback branch (the - // ES module registry is immutable, hence the unique URL) - const href = new URL( - '../src/instrumentation.js', - import.meta.url, - ).href; - const { ImqueueInstrumentation: Fallback } = await import( - `${href}?fallback=1` - ); - - assert.ok(new Fallback() instanceof Fallback); - } finally { - process.chdir(cwd); - } + it('honours a custom (disabled) config', () => { + assert.ok( + new ImqueueInstrumentation({ enabled: false }) instanceof + ImqueueInstrumentation, + ); }); }); describe('init()', () => { - it('should define instrumentation for supported @imqueue/rpc', () => { - const instrumentation: any = new ImqueueInstrumentation(); - const definition = instrumentation.init(); - - assert.equal(definition.name, '@imqueue/rpc'); - assert.deepEqual(definition.supportedVersions, ['>=1.10']); - assert.equal(typeof definition.patch, 'function'); - assert.equal(typeof definition.unpatch, 'function'); - }); - }); - - describe('patching', () => { - it('should patch client and service default options', () => { + it('registers no module-load hook (patches singletons directly)', () => { const instrumentation: any = new ImqueueInstrumentation(); - const moduleExports = makeModuleExports(); - const result = instrumentation.init().patch(moduleExports); - - for (const key of [ - 'DEFAULT_IMQ_CLIENT_OPTIONS', - 'DEFAULT_IMQ_SERVICE_OPTIONS', - ]) { - assert.equal(typeof result[key].beforeCall, 'function'); - assert.equal(typeof result[key].afterCall, 'function'); - } - }); - }); - - describe('unpatching', () => { - it('should unpatch client and service default options', () => { - const instrumentation: any = new ImqueueInstrumentation(); - const definition = instrumentation.init(); - const moduleExports = definition.patch(makeModuleExports()); - const result = definition.unpatch(moduleExports); - - for (const key of [ - 'DEFAULT_IMQ_CLIENT_OPTIONS', - 'DEFAULT_IMQ_SERVICE_OPTIONS', - ]) { - assert.equal(result[key].beforeCall, undefined); - assert.equal(result[key].afterCall, undefined); - } - }); - - it('should handle empty client and service options', () => { - const instrumentation: any = new ImqueueInstrumentation(); - const result = instrumentation.init().unpatch(makeModuleExports()); - assert.deepEqual(result, makeModuleExports()); - }); - - it('should handle undefined client options', () => { - const instrumentation: any = new ImqueueInstrumentation(); - const moduleExports = { DEFAULT_IMQ_SERVICE_OPTIONS: {} }; - const result = instrumentation.init().unpatch(moduleExports); - - assert.deepEqual(result, { DEFAULT_IMQ_SERVICE_OPTIONS: {} }); - }); - - it('should handle undefined service options', () => { - const instrumentation: any = new ImqueueInstrumentation(); - const moduleExports = { DEFAULT_IMQ_CLIENT_OPTIONS: {} }; - const result = instrumentation.init().unpatch(moduleExports); - - assert.deepEqual(result, { DEFAULT_IMQ_CLIENT_OPTIONS: {} }); + assert.deepEqual(instrumentation.init(), []); }); }); - describe('beforeCallClient', () => { - it('should create a client span on the request', async (t: TestContext) => { - const span = makeSpan(t); - const startSpan = t.mock.fn(() => span); - const instrumentation = makeInstrumentation(t, { startSpan }); - const request = makeRequest(); - - await instrumentation.beforeCallClient.call(client, request); - - assert.equal(request.span, span); - assert.ok(request.metadata); - assert.ok(request.metadata.clientSpan); - assert.equal(startSpan.mock.calls.length, 1); - - const [, options] = startSpan.mock.calls[0].arguments as any[]; - - assert.equal( - options.attributes['resource.name'], - 'service-name.test-method', - ); - }); - - it('should inject context into request metadata', async (t: TestContext) => { - const span = makeSpan(t); + describe('patch()/unpatch()', () => { + it('patches client before/after and service wrapCall', (t: TestContext) => { const instrumentation = makeInstrumentation(t, { - startSpan: () => span, + startSpan: () => makeSpan(t), }); - const inject = t.mock.method(propagation, 'inject'); - const request = makeRequest(); + const rpc = instrumentation.patch(emptyModule()); - await instrumentation.beforeCallClient.call(client, request); - - assert.equal(inject.mock.calls.length, 1); - assert.equal(request.span, span); + assert.equal( + typeof rpc.DEFAULT_IMQ_CLIENT_OPTIONS.beforeCall, + 'function', + ); + assert.equal( + typeof rpc.DEFAULT_IMQ_CLIENT_OPTIONS.afterCall, + 'function', + ); + assert.equal( + typeof rpc.DEFAULT_IMQ_SERVICE_OPTIONS.wrapCall, + 'function', + ); + // the service uses the around-hook, not before/after + assert.equal(rpc.DEFAULT_IMQ_SERVICE_OPTIONS.beforeCall, undefined); }); - it('should override toJSON to exclude span', async (t: TestContext) => { - const span = makeSpan(t); + it('unpatch removes every hook it added', (t: TestContext) => { const instrumentation = makeInstrumentation(t, { - startSpan: () => span, + startSpan: () => makeSpan(t), }); - const request = makeRequest(); - - await instrumentation.beforeCallClient.call(client, request); + const rpc = instrumentation.unpatch( + instrumentation.patch(emptyModule()), + ); - assert.equal(request.toJSON().span, undefined); + assert.equal(rpc.DEFAULT_IMQ_CLIENT_OPTIONS.beforeCall, undefined); + assert.equal(rpc.DEFAULT_IMQ_CLIENT_OPTIONS.afterCall, undefined); + assert.equal(rpc.DEFAULT_IMQ_SERVICE_OPTIONS.wrapCall, undefined); }); - it('should silently handle tracer errors', async (t: TestContext) => { + it('tolerates missing client/service option objects', (t: TestContext) => { const instrumentation = makeInstrumentation(t, { - startSpan: () => { - throw new Error('Test error'); - }, + startSpan: () => makeSpan(t), }); - const request = makeRequest(); - - await instrumentation.beforeCallClient.call(client, request); - assert.equal(request.span, undefined); + assert.doesNotThrow(() => instrumentation.patch({})); + assert.doesNotThrow(() => instrumentation.unpatch({})); }); }); - describe('beforeCallService', () => { - it('should create a service span from client context', async (t: TestContext) => { + describe('client beforeCall/afterCall', () => { + it('starts a client span and injects context into metadata', async (t: TestContext) => { const span = makeSpan(t); const startSpan = t.mock.fn(() => span); const instrumentation = makeInstrumentation(t, { startSpan }); - const extract = t.mock.method(propagation, 'extract'); - const request = makeRequest({ clientSpan: {} }); + const rpc = instrumentation.patch(emptyModule()); + const req = makeRequest(); - await instrumentation.beforeCallService.call(service, request); + await rpc.DEFAULT_IMQ_CLIENT_OPTIONS.beforeCall.call(client, req); - assert.equal(request.span, span); - assert.equal(extract.mock.calls.length, 1); - - const [, options] = startSpan.mock.calls[0].arguments as any[]; - - assert.equal( - options.attributes['resource.name'], - 'service-name.test-method', - ); + assert.equal(req.span, span); + assert.ok(req.metadata && req.metadata.clientSpan); + assert.equal(startSpan.mock.calls.length, 1); }); - it('should handle missing metadata', async (t: TestContext) => { + it('ends the span and flags errors on afterCall', async (t: TestContext) => { const span = makeSpan(t); const instrumentation = makeInstrumentation(t, { startSpan: () => span, }); - const extract = t.mock.method(propagation, 'extract'); - const request = makeRequest(); - - await instrumentation.beforeCallService.call(service, request); + const rpc = instrumentation.patch(emptyModule()); + const req = makeRequest(); - assert.equal(extract.mock.calls.length, 1); - assert.equal(request.span, span); - }); - - it('should override toJSON to exclude span', async (t: TestContext) => { - const span = makeSpan(t); - const instrumentation = makeInstrumentation(t, { - startSpan: () => span, + await rpc.DEFAULT_IMQ_CLIENT_OPTIONS.beforeCall.call(client, req); + await rpc.DEFAULT_IMQ_CLIENT_OPTIONS.afterCall.call(client, req, { + error: { message: 'boom' }, }); - const request = makeRequest({ clientSpan: {} }); - - await instrumentation.beforeCallService.call(service, request); - assert.equal(request.toJSON().span, undefined); + assert.equal(span.end.mock.calls.length, 1); + assert.equal(span.setStatus.mock.calls.length, 1); }); - it('should silently handle tracer errors', async (t: TestContext) => { + it('afterCall is a no-op when no span was attached', async (t: TestContext) => { const instrumentation = makeInstrumentation(t, { - startSpan: () => { - throw new Error('Test error'); - }, + startSpan: () => makeSpan(t), }); - const request = makeRequest({ clientSpan: {} }); - - await instrumentation.beforeCallService.call(service, request); + const rpc = instrumentation.patch(emptyModule()); - assert.equal(request.span, undefined); + await assert.doesNotReject( + rpc.DEFAULT_IMQ_CLIENT_OPTIONS.afterCall.call( + client, + makeRequest(), + ), + ); }); }); - describe('afterCall', () => { - it('should end the request span', async (t: TestContext) => { + describe('service wrapCall', () => { + it('runs the handler INSIDE the server span context (nesting works)', async (t: TestContext) => { const span = makeSpan(t); const instrumentation = makeInstrumentation(t, { startSpan: () => span, }); - const request = makeRequest(); - - request.span = span; - - await instrumentation.afterCall.call(client, request); + const rpc = instrumentation.patch(emptyModule()); + const req = makeRequest(); - assert.equal(span.end.mock.calls.length, 1); - }); + let activeInsideHandler: unknown; + const next = async () => { + activeInsideHandler = trace.getSpan(context.active()); - it('should handle a missing span', async () => { - const instrumentation: any = new ImqueueInstrumentation(); - const request = makeRequest(); + return 'result'; + }; - await assert.doesNotReject( - instrumentation.afterCall.call(client, request), + const result = await rpc.DEFAULT_IMQ_SERVICE_OPTIONS.wrapCall.call( + service, + req, + {}, + next, ); + + assert.equal(result, 'result'); + assert.equal(activeInsideHandler, span, 'handler sees the span'); + assert.equal(req.span, span); + assert.equal(span.end.mock.calls.length, 1); }); - it('should silently handle span errors', async (t: TestContext) => { + it('records the exception, ends the span, and rethrows', async (t: TestContext) => { const span = makeSpan(t); - - span.end = t.mock.fn(() => { - throw new Error('Test error'); + const instrumentation = makeInstrumentation(t, { + startSpan: () => span, }); - - const instrumentation: any = new ImqueueInstrumentation(); - const request = makeRequest(); - - request.span = span; - - await assert.doesNotReject( - instrumentation.afterCall.call(client, request), + const rpc = instrumentation.patch(emptyModule()); + const boom = new Error('handler failed'); + + await assert.rejects( + rpc.DEFAULT_IMQ_SERVICE_OPTIONS.wrapCall.call( + service, + makeRequest(), + {}, + async () => { + throw boom; + }, + ), + boom, ); + + assert.equal(span.recordException.mock.calls.length, 1); + assert.equal(span.setStatus.mock.calls.length, 1); assert.equal(span.end.mock.calls.length, 1); }); });