diff --git a/README.md b/README.md index bfb0646..d9aeaf0 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ npm i @fastify/otel It must be configured before defining routes and other plugins in order to cover the most of your Fastify server. -- It automatically wraps the main request handler +- It automatically wraps the main request handler by default; use `instrumentHandler` globally or per-route to disable its child span - Instruments all route hooks (defined at instance and route definition level) by default; use `instrumentHooks` globally or per-route to control auto-instrumented hook spans - `onRequest` - `preParsing` @@ -60,6 +60,8 @@ app.addHook('onError', () => /* do something */) app.get('/healthcheck', { config: { otel: false } }, () => 'Up!') // Keep request and handler spans but skip lifecycle hook spans on a route app.get('/api', { config: { otel: { instrumentHooks: false } } }, () => 'ok') +// Keep the request span but skip the handler child span on a route +app.get('/health', { config: { otel: { instrumentHandler: false } } }, () => 'ok') // you can also scope your instrumentation to only be enabled on a sub context // of your application @@ -262,9 +264,20 @@ const otel = new FastifyOtelInstrumentation({ instrumentHooks: false }) app.get('/debug', { config: { otel: { instrumentHooks: ['onRequest'] } } }, handler) ``` +#### `FastifyOtelInstrumentationOptions#instrumentHandler: boolean` + +Control whether the Fastify route handler receives a child span. Defaults to `true`. + +Set `instrumentHandler: false` globally to keep request spans without route handler child spans. A route can override the global setting with `config.otel.instrumentHandler`. Setting `otel: false` still disables all OpenTelemetry spans for the route. + +```js +const otel = new FastifyOtelInstrumentation({ instrumentHandler: false }) +app.get('/debug', { config: { otel: { instrumentHandler: true } } }, handler) +``` + #### `FastifyOtelInstrumentationOptions#lifecycleHook: function` -A **synchronous** callback that runs whenever a span is created for an instrumented Fastify lifecycle hook (route hooks, instance hooks, not-found handlers, and route handlers). It is not invoked when `instrumentHooks` skips a hook. +A **synchronous** callback that runs whenever a span is created for an instrumented Fastify lifecycle hook (route hooks, instance hooks, not-found handlers, and route handlers). It is not invoked when `instrumentHooks` skips a hook or `instrumentHandler` skips a route handler. * **span** – the hook span that was just created * **info.hookName** – Fastify lifecycle stage (e.g., `onRequest`, `preHandler`, `handler`) * **info.handler** – the resolved handler or plugin name when available diff --git a/index.js b/index.js index f690b2b..6885e29 100644 --- a/index.js +++ b/index.js @@ -49,6 +49,7 @@ const kSetNotFoundOriginal = Symbol('fastify otel setnotfound original') const kIgnorePaths = Symbol('fastify otel ignore path') const kRecordExceptions = Symbol('fastify otel record exceptions') const kInstrumentHooks = Symbol('fastify otel instrument hooks') +const kInstrumentHandler = Symbol('fastify otel instrument handler') function isRouteOtelDisabled (config) { return config?.otel === false @@ -104,6 +105,14 @@ function getHookPolicy (config, globalPolicy, logger = null) { return globalPolicy } +function shouldInstrumentHandler (config, globalSetting) { + const otel = config?.otel + if (otel != null && typeof otel === 'object' && typeof otel.instrumentHandler === 'boolean') { + return otel.instrumentHandler + } + return globalSetting +} + function lifecycleHookBaseName (hookName) { if (FASTIFY_HOOKS.includes(hookName)) { return hookName @@ -144,6 +153,7 @@ class FastifyOtelInstrumentation extends InstrumentationBase { this[kIgnorePaths] = null this[kRecordExceptions] = true this[kInstrumentHooks] = normalizeInstrumentHooks(true) + this[kInstrumentHandler] = true if (config?.recordExceptions != null) { if (typeof config.recordExceptions !== 'boolean') { @@ -152,6 +162,13 @@ class FastifyOtelInstrumentation extends InstrumentationBase { this[kRecordExceptions] = config.recordExceptions } + if (config?.instrumentHandler != null) { + if (typeof config.instrumentHandler !== 'boolean') { + throw new TypeError('instrumentHandler must be a boolean') + } + + this[kInstrumentHandler] = config.instrumentHandler + } if (typeof config?.requestHook === 'function') { this._requestHook = config.requestHook } @@ -348,15 +365,17 @@ class FastifyOtelInstrumentation extends InstrumentationBase { routeOptions.onError = recordErrorInSpanHook } - routeOptions.handler = handlerWrapper(routeOptions.handler, 'handler', { - [ATTRIBUTE_NAMES.HOOK_NAME]: `${this.pluginName} - route-handler`, - [ATTRIBUTE_NAMES.FASTIFY_TYPE]: HOOK_TYPES.HANDLER, - [ATTR_HTTP_ROUTE]: routeOptions.url, - [ATTRIBUTE_NAMES.HOOK_CALLBACK_NAME]: - routeOptions.handler.name.length > 0 - ? routeOptions.handler.name - : ANONYMOUS_FUNCTION_NAME - }) + if (shouldInstrumentHandler(routeOptions.config, instrumentation[kInstrumentHandler])) { + routeOptions.handler = handlerWrapper(routeOptions.handler, 'handler', { + [ATTRIBUTE_NAMES.HOOK_NAME]: `${this.pluginName} - route-handler`, + [ATTRIBUTE_NAMES.FASTIFY_TYPE]: HOOK_TYPES.HANDLER, + [ATTR_HTTP_ROUTE]: routeOptions.url, + [ATTRIBUTE_NAMES.HOOK_CALLBACK_NAME]: + routeOptions.handler.name.length > 0 + ? routeOptions.handler.name + : ANONYMOUS_FUNCTION_NAME + }) + } }) instance.addHook('onRequest', function startRequestSpanHook (request, _reply, hookDone) { diff --git a/test/api.test.js b/test/api.test.js index cad67e2..dfd8623 100644 --- a/test/api.test.js +++ b/test/api.test.js @@ -64,6 +64,12 @@ describe('Interface', () => { assert.doesNotThrow(() => new FastifyInstrumentation({ instrumentHooks: ['preHandler'] })) }) + test('FastifyOtelInstrumentationOpts#instrumentHandler - should be a boolean', async t => { + assert.throws(() => new FastifyInstrumentation({ instrumentHandler: 'nope' }), /boolean/) + assert.doesNotThrow(() => new FastifyInstrumentation({ instrumentHandler: false })) + assert.doesNotThrow(() => new FastifyInstrumentation({ instrumentHandler: true })) + }) + test('NamedFastifyInstrumentation#plugin should return a valid Fastify Plugin', async t => { const app = Fastify() const instrumentation = new FastifyOtelInstrumentation() diff --git a/test/index.test.js b/test/index.test.js index d2a9b18..1cc052d 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -756,6 +756,103 @@ describe('FastifyInstrumentation', () => { assert.equal(response.status, 200) }) + test('should not create handler spans when instrumentHandler is false globally', async t => { + const localInstrumentation = new FastifyInstrumentation({ instrumentHandler: false }) + localInstrumentation.setTracerProvider(provider) + const app = Fastify() + const plugin = localInstrumentation.plugin() + + await app.register(plugin) + + app.get('/', async function helloworld () { + return 'hello world' + }) + + await app.listen() + + after(() => app.close()) + + const response = await fetch( + `http://localhost:${app.server.address().port}/` + ) + + const spans = memoryExporter + .getFinishedSpans() + .filter(span => span.instrumentationScope.name === '@fastify/otel') + + assert.equal(spans.length, 1) + assert.equal(spans[0].name, 'request') + assert.equal(response.status, 200) + assert.equal(await response.text(), 'hello world') + }) + + test('should not create handler spans when route config disables instrumentHandler', async t => { + const app = Fastify() + const plugin = instrumentation.plugin() + + await app.register(plugin) + + app.get( + '/', + { config: { otel: { instrumentHandler: false } } }, + async function helloworld () { + return 'hello world' + } + ) + + await app.listen() + + after(() => app.close()) + + const response = await fetch( + `http://localhost:${app.server.address().port}/` + ) + + const spans = memoryExporter + .getFinishedSpans() + .filter(span => span.instrumentationScope.name === '@fastify/otel') + + assert.equal(spans.length, 1) + assert.equal(spans[0].name, 'request') + assert.equal(response.status, 200) + assert.equal(await response.text(), 'hello world') + }) + + test('should override global instrumentHandler false per route', async t => { + const localInstrumentation = new FastifyInstrumentation({ instrumentHandler: false }) + localInstrumentation.setTracerProvider(provider) + const app = Fastify() + const plugin = localInstrumentation.plugin() + + await app.register(plugin) + + app.get( + '/', + { config: { otel: { instrumentHandler: true } } }, + async function helloworld () { + return 'hello world' + } + ) + + await app.listen() + + after(() => app.close()) + + const response = await fetch( + `http://localhost:${app.server.address().port}/` + ) + + const spans = memoryExporter + .getFinishedSpans() + .filter(span => span.instrumentationScope.name === '@fastify/otel') + + assert.equal(spans.length, 2) + assert.equal(spans.find(s => s.name === 'request') != null, true) + assert.equal(spans.find(s => s.name === 'handler - helloworld') != null, true) + assert.equal(response.status, 200) + assert.equal(await response.text(), 'hello world') + }) + test('should create only allowlisted lifecycle hook spans when instrumentHooks is an array', async t => { const localInstrumentation = new FastifyInstrumentation({ instrumentHooks: ['preHandler'] diff --git a/types/index.d.ts b/types/index.d.ts index faaceec..8707a6a 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -18,7 +18,7 @@ declare module 'fastify' { } interface FastifyContextConfig { - /** Set to `false` to disable OpenTelemetry for the route, or use an object to control hook spans */ + /** Set to `false` to disable OpenTelemetry for the route, or use an object to control handler and hook spans */ otel?: boolean | FastifyOtelRouteConfig } } diff --git a/types/index.tst.ts b/types/index.tst.ts index 7e0c5a0..f01471d 100644 --- a/types/index.tst.ts +++ b/types/index.tst.ts @@ -26,7 +26,8 @@ const complexOpts = { expect(info.handler).type.toBeAssignableTo() }, recordExceptions: false, - instrumentHooks: ['preHandler'] + instrumentHooks: ['preHandler'], + instrumentHandler: false } as FastifyOtelInstrumentationOpts expect(complexOpts).type.toBeAssignableTo() @@ -89,6 +90,14 @@ app.get('/with-otel-instrument-hooks-allowlist', { config: { otel: { instrumentH return { hello: 'world' } }) +app.get('/with-otel-instrument-handler-false', { config: { otel: { instrumentHandler: false } } }, async function (_request, _reply) { + return { hello: 'world' } +}) + +app.get('/with-otel-instrument-handler-true', { config: { otel: { instrumentHandler: true } } }, async function (_request, _reply) { + return { hello: 'world' } +}) + app.get('/with-other-config', { config: { customField: 'value' } }, async function (_request, _reply) { return { hello: 'world' } }) diff --git a/types/types.d.ts b/types/types.d.ts index de516e8..5b76366 100644 --- a/types/types.d.ts +++ b/types/types.d.ts @@ -15,6 +15,7 @@ export type FastifyOtelHookName = export interface FastifyOtelRouteConfig { instrumentHooks?: boolean | FastifyOtelHookName[] + instrumentHandler?: boolean } export interface FastifyOtelOptions {} @@ -25,6 +26,7 @@ export interface FastifyOtelInstrumentationOpts extends InstrumentationConfig { lifecycleHook?: (span: import('@opentelemetry/api').Span, info: FastifyOtelLifecycleHookInfo) => void recordExceptions?: boolean instrumentHooks?: boolean | FastifyOtelHookName[] + instrumentHandler?: boolean } export interface FastifyOtelLifecycleHookInfo {