diff --git a/README.md b/README.md index 6ef538f..bd7ef6f 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,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 -- Instruments all route hooks (defined at instance and route definition level) +- 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` - `preValidation` @@ -58,6 +58,8 @@ app.get('/', () => 'hello world') app.addHook('onError', () => /* do something */) // Manually skip telemetry for a specific route 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') // you can also scope your instrumentation to only be enabled on a sub context // of your application @@ -224,9 +226,31 @@ const otel = new FastifyOtelInstrumentation({ }) ``` +#### `FastifyOtelInstrumentationOptions#instrumentHooks: boolean | string[]` + +Control which Fastify lifecycle hooks receive child spans. Defaults to instrumenting all hooks listed in Usage. + +* `true` (default) – instrument all lifecycle hooks (`onRequest`, `preParsing`, `preValidation`, `preHandler`, `preSerialization`, `onSend`, `onResponse`, `onError`) +* `false` – no lifecycle hook child spans (the root `request` span and route `handler` span are still created) +* `string[]` – allowlist of hook names to instrument (for example `['preHandler']`) + +Per-route override via `config.otel`: + +* `otel: false` – disable all OpenTelemetry spans for the route (unchanged) +* `otel: { instrumentHooks: false }` – request and handler spans only +* `otel: { instrumentHooks: true }` – all lifecycle hooks on the route (overrides a global `false`) +* `otel: { instrumentHooks: ['preHandler'] }` – allowlist for the route (overrides global settings) + +Precedence: `otel: false`, then route `otel.instrumentHooks` when set, otherwise the global `instrumentHooks` option. + +```js +const otel = new FastifyOtelInstrumentation({ instrumentHooks: false }) +app.get('/debug', { config: { otel: { instrumentHooks: ['onRequest'] } } }, handler) +``` + #### `FastifyOtelInstrumentationOptions#lifecycleHook: function` -A **synchronous** callback that runs whenever a span is created for a Fastify lifecycle hook (route hooks, instance hooks, not-found handlers, and route handlers). +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. * **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 66003a0..d52a40e 100644 --- a/index.js +++ b/index.js @@ -48,6 +48,90 @@ const kAddHookOriginal = Symbol('fastify otel addhook original') 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') + +function isRouteOtelDisabled (config) { + return config?.otel === false +} + +function normalizeInstrumentHooks (value, { strict = false, logger = null } = {}) { + if (value === true || value === undefined) { + return { mode: 'all' } + } + + if (value === false) { + return { mode: 'none' } + } + + if (!Array.isArray(value)) { + if (strict) { + throw new TypeError('instrumentHooks must be a boolean or an array of hook names') + } + return { mode: 'none' } + } + + if (strict && value.length === 0) { + throw new TypeError('instrumentHooks must be a boolean or an array of hook names') + } + + const allowlist = new Set() + + for (const hookName of value) { + if (typeof hookName !== 'string' || !FASTIFY_HOOKS.includes(hookName)) { + if (strict) { + throw new TypeError('instrumentHooks must be a boolean or an array of hook names') + } + logger?.debug( + `Ignoring unknown instrumentHooks entry "${hookName}"` + ) + continue + } + allowlist.add(hookName) + } + + if (allowlist.size === 0) { + return { mode: 'none' } + } + + return { mode: 'allowlist', set: allowlist } +} + +function getHookPolicy (config, globalPolicy, logger = null) { + const otel = config?.otel + if (otel != null && typeof otel === 'object' && otel.instrumentHooks !== undefined) { + return normalizeInstrumentHooks(otel.instrumentHooks, { strict: false, logger }) + } + return globalPolicy +} + +function lifecycleHookBaseName (hookName) { + if (FASTIFY_HOOKS.includes(hookName)) { + return hookName + } + if (hookName.includes(' - ')) { + const base = hookName.split(' - ').pop() + if (FASTIFY_HOOKS.includes(base)) { + return base + } + } + return null +} + +function shouldInstrumentLifecycleHook (hookName, policy) { + const base = lifecycleHookBaseName(hookName) + /* c8 ignore start */ + if (base == null) { + return false + } + /* c8 ignore stop */ + if (policy.mode === 'all') { + return true + } + if (policy.mode === 'none') { + return false + } + return policy.set.has(base) +} class FastifyOtelInstrumentation extends InstrumentationBase { logger = null @@ -59,6 +143,7 @@ class FastifyOtelInstrumentation extends InstrumentationBase { this.logger = diag.createComponentLogger({ namespace: PACKAGE_NAME }) this[kIgnorePaths] = null this[kRecordExceptions] = true + this[kInstrumentHooks] = normalizeInstrumentHooks(true) if (config?.recordExceptions != null) { if (typeof config.recordExceptions !== 'boolean') { @@ -74,6 +159,13 @@ class FastifyOtelInstrumentation extends InstrumentationBase { this._lifecycleHook = config.lifecycleHook } + if (config?.instrumentHooks != null) { + this[kInstrumentHooks] = normalizeInstrumentHooks(config.instrumentHooks, { + strict: true, + logger: this.logger + }) + } + if (config?.ignorePaths != null || process.env.OTEL_FASTIFY_IGNORE_PATHS != null) { const ignorePaths = config?.ignorePaths ?? process.env.OTEL_FASTIFY_IGNORE_PATHS @@ -157,7 +249,7 @@ class FastifyOtelInstrumentation extends InstrumentationBase { const span = this[kRequestSpan] return { - enabled: this.routeOptions.config?.otel !== false, + enabled: !isRouteOtelDisabled(this.routeOptions.config), span, tracer: instrumentation.tracer, context: ctx, @@ -180,7 +272,7 @@ class FastifyOtelInstrumentation extends InstrumentationBase { return } - if (routeOptions.config?.otel === false) { + if (isRouteOtelDisabled(routeOptions.config)) { instrumentation.logger.debug( `Ignoring route instrumentation ${routeOptions.method} ${routeOptions.url} because it is disabled` ) @@ -188,8 +280,18 @@ class FastifyOtelInstrumentation extends InstrumentationBase { return } + const hookPolicy = getHookPolicy( + routeOptions.config, + instrumentation[kInstrumentHooks], + instrumentation.logger + ) + for (const hook of FASTIFY_HOOKS) { if (routeOptions[hook] != null) { + if (!shouldInstrumentLifecycleHook(hook, hookPolicy)) { + continue + } + const handlerLike = routeOptions[hook] if (typeof handlerLike === 'function') { @@ -256,7 +358,7 @@ class FastifyOtelInstrumentation extends InstrumentationBase { instance.addHook('onRequest', function startRequestSpanHook (request, _reply, hookDone) { if ( this[kInstrumentation].isEnabled() === false || - request.routeOptions.config?.otel === false + isRouteOtelDisabled(request.routeOptions.config) ) { return hookDone() } @@ -377,7 +479,10 @@ class FastifyOtelInstrumentation extends InstrumentationBase { function addHookPatched (name, hook) { const addHookOriginal = this[kAddHookOriginal] - if (FASTIFY_HOOKS.includes(name)) { + if ( + FASTIFY_HOOKS.includes(name) && + instrumentation[kInstrumentHooks].mode !== 'none' + ) { return addHookOriginal.call( this, name, @@ -408,7 +513,12 @@ class FastifyOtelInstrumentation extends InstrumentationBase { }) setNotFoundHandlerOriginal.call(this, handler) } else { - if (hooks.preValidation != null) { + const globalHookPolicy = instrumentation[kInstrumentHooks] + + if ( + hooks.preValidation != null && + shouldInstrumentLifecycleHook('preValidation', globalHookPolicy) + ) { hooks.preValidation = handlerWrapper(hooks.preValidation, 'notFoundHandler - preValidation', { [ATTRIBUTE_NAMES.HOOK_NAME]: `${this.pluginName} - not-found-handler - preValidation`, [ATTRIBUTE_NAMES.FASTIFY_TYPE]: HOOK_TYPES.INSTANCE, @@ -419,7 +529,10 @@ class FastifyOtelInstrumentation extends InstrumentationBase { }) } - if (hooks.preHandler != null) { + if ( + hooks.preHandler != null && + shouldInstrumentLifecycleHook('preHandler', globalHookPolicy) + ) { hooks.preHandler = handlerWrapper(hooks.preHandler, 'notFoundHandler - preHandler', { [ATTRIBUTE_NAMES.HOOK_NAME]: `${this.pluginName} - not-found-handler - preHandler`, [ATTRIBUTE_NAMES.FASTIFY_TYPE]: HOOK_TYPES.INSTANCE, @@ -465,7 +578,7 @@ class FastifyOtelInstrumentation extends InstrumentationBase { return handler.call(this, ...args) } - if (instrumentation.isEnabled() === false || request.routeOptions.config?.otel === false) { + if (instrumentation.isEnabled() === false || isRouteOtelDisabled(request.routeOptions.config)) { instrumentation.logger.debug( `Ignoring route instrumentation ${request.routeOptions.method} ${request.routeOptions.url} because it is disabled` ) @@ -482,6 +595,20 @@ class FastifyOtelInstrumentation extends InstrumentationBase { return handler.call(this, ...args) } + if (lifecycleHookBaseName(hookName) != null) { + const hookPolicy = getHookPolicy( + request.routeOptions.config, + instrumentation[kInstrumentHooks], + instrumentation.logger + ) + if (!shouldInstrumentLifecycleHook(hookName, hookPolicy)) { + instrumentation.logger.debug( + `Ignoring hook instrumentation for ${hookName} because instrumentHooks excludes it` + ) + return handler.call(this, ...args) + } + } + /* c8 ignore next */ const ctx = request[kRequestContext] ?? context.active() const handlerName = handler.name?.length > 0 diff --git a/test/api.test.js b/test/api.test.js index 4e7ffe1..7fa9bd9 100644 --- a/test/api.test.js +++ b/test/api.test.js @@ -55,6 +55,15 @@ describe('Interface', () => { assert.doesNotThrow(() => new FastifyInstrumentation({ recordExceptions: false })) }) + test('FastifyOtelInstrumentationOpts#instrumentHooks - should be a boolean or array of hook names', async t => { + assert.throws(() => new FastifyInstrumentation({ instrumentHooks: 'nope' }), /boolean or an array/) + assert.throws(() => new FastifyInstrumentation({ instrumentHooks: [] }), /boolean or an array/) + assert.throws(() => new FastifyInstrumentation({ instrumentHooks: ['notAHook'] }), /boolean or an array/) + assert.doesNotThrow(() => new FastifyInstrumentation({ instrumentHooks: false })) + assert.doesNotThrow(() => new FastifyInstrumentation({ instrumentHooks: true })) + assert.doesNotThrow(() => new FastifyInstrumentation({ instrumentHooks: ['preHandler'] })) + }) + test('NamedFastifyInstrumentation#plugin should return a valid Fastify Plugin', async t => { const app = Fastify() const instrumentation = new FastifyOtelInstrumentation() @@ -216,6 +225,31 @@ describe('Interface', () => { assert.equal(res3.payload, 'world') }) + test('FastifyRequest#opentelemetry() stays enabled when only instrumentHooks is disabled for the route', async () => { + const app = Fastify() + const instrumentation = new FastifyInstrumentation() + const plugin = instrumentation.plugin() + + await app.register(plugin) + + app.get('/hooks-off', { config: { otel: { instrumentHooks: false } } }, (request) => { + const otel = request.opentelemetry() + + assert.equal(otel.enabled, true) + assert.equal(typeof otel.span.spanContext().spanId, 'string') + assert.equal(typeof otel.context, 'object') + + return 'ok' + }) + + const res = await app.inject({ + method: 'GET', + url: '/hooks-off' + }) + assert.equal(res.statusCode, 200) + assert.equal(res.payload, 'ok') + }) + test('FastifyInstrumentation#requestHook should be invoked and can mutate span', async () => { /** @type {import('fastify').FastifyInstance} */ const app = Fastify() diff --git a/test/index.test.js b/test/index.test.js index b3e97a7..48ac420 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -644,6 +644,257 @@ describe('FastifyInstrumentation', () => { assert.equal(await response.text(), 'hello world') }) + test('should not create lifecycle hook spans when instrumentHooks is false globally', async t => { + const localInstrumentation = new FastifyInstrumentation({ instrumentHooks: false }) + localInstrumentation.setTracerProvider(provider) + const app = Fastify() + const plugin = localInstrumentation.plugin() + + await app.register(plugin) + + app.get( + '/', + { + onRequest: (request, reply, done) => { done() }, + preHandler: (request, reply, done) => { done() } + }, + 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(spans.find(s => s.name.startsWith('onRequest -')), undefined) + assert.equal(spans.find(s => s.name.startsWith('preHandler -')), undefined) + assert.equal(response.status, 200) + }) + + test('should not create lifecycle hook spans when route config disables instrumentHooks', async t => { + const app = Fastify() + const plugin = instrumentation.plugin() + + await app.register(plugin) + + app.get( + '/', + { + config: { otel: { instrumentHooks: false } }, + onRequest: (request, reply, done) => { done() }, + preHandler: (request, reply, done) => { done() } + }, + 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.startsWith('onRequest -')), undefined) + assert.equal(spans.find(s => s.name.startsWith('preHandler -')), undefined) + assert.equal(response.status, 200) + }) + + test('should create only allowlisted lifecycle hook spans when instrumentHooks is an array', async t => { + const localInstrumentation = new FastifyInstrumentation({ + instrumentHooks: ['preHandler'] + }) + localInstrumentation.setTracerProvider(provider) + const app = Fastify() + const plugin = localInstrumentation.plugin() + + await app.register(plugin) + + app.get( + '/', + { + onRequest: (request, reply, done) => { done() }, + preHandler: function somePreHandler (request, reply, done) { done() } + }, + 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, 3) + assert.equal(spans.find(s => s.name === 'preHandler - somePreHandler') != null, true) + assert.equal(spans.find(s => s.name.startsWith('onRequest -')), undefined) + assert.equal(response.status, 200) + }) + + test('should treat invalid per-route instrumentHooks value as none', async t => { + const app = Fastify() + const plugin = instrumentation.plugin() + + await app.register(plugin) + + app.get( + '/', + { + config: { otel: { instrumentHooks: null } }, + onRequest: (request, reply, done) => { done() } + }, + async function helloworld () { + return 'hello world' + } + ) + + await app.listen() + + after(() => app.close()) + + await fetch(`http://localhost:${app.server.address().port}/`) + + const spans = memoryExporter + .getFinishedSpans() + .filter(span => span.instrumentationScope.name === '@fastify/otel') + + assert.equal(spans.length, 2) + }) + + test('should ignore invalid per-route instrumentHooks allowlist entries', async t => { + const app = Fastify() + const plugin = instrumentation.plugin() + + await app.register(plugin) + + app.get( + '/', + { + config: { otel: { instrumentHooks: ['notAHook'] } }, + onRequest: (request, reply, done) => { done() } + }, + async function helloworld () { + return 'hello world' + } + ) + + await app.listen() + + after(() => app.close()) + + 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.startsWith('onRequest -')), undefined) + }) + + test('should skip instance lifecycle hooks excluded by global instrumentHooks allowlist', async t => { + const localInstrumentation = new FastifyInstrumentation({ + instrumentHooks: ['preHandler'] + }) + localInstrumentation.setTracerProvider(provider) + const app = Fastify() + const plugin = localInstrumentation.plugin() + + await app.register(plugin) + app.addHook('onRequest', function instanceOnRequest (request, reply, done) { + done() + }) + + app.get( + '/', + { + preHandler: function routePreHandler (request, reply, done) { done() } + }, + async function helloworld () { + return 'hello world' + } + ) + + await app.listen() + + after(() => app.close()) + + await fetch(`http://localhost:${app.server.address().port}/`) + + const spans = memoryExporter + .getFinishedSpans() + .filter(span => span.instrumentationScope.name === '@fastify/otel') + + assert.equal(spans.length, 3) + assert.equal(spans.find(s => s.name.startsWith('onRequest -')), undefined) + assert.equal(spans.find(s => s.name === 'preHandler - routePreHandler') != null, true) + }) + + test('should override global instrumentHooks false with per-route allowlist', async t => { + const localInstrumentation = new FastifyInstrumentation({ instrumentHooks: false }) + localInstrumentation.setTracerProvider(provider) + const app = Fastify() + const plugin = localInstrumentation.plugin() + + await app.register(plugin) + + app.get( + '/', + { + config: { otel: { instrumentHooks: ['onRequest'] } }, + onRequest: function routeOnRequest (request, reply, done) { done() }, + preHandler: (request, reply, done) => { done() } + }, + 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, 3) + assert.equal(spans.find(s => s.name === 'onRequest - routeOnRequest') != null, true) + assert.equal(spans.find(s => s.name.startsWith('preHandler -')), undefined) + assert.equal(response.status, 200) + }) + test('should create span for different hooks (patched)', async t => { const app = Fastify() const plugin = instrumentation.plugin() diff --git a/types/index.d.ts b/types/index.d.ts index 29c970f..376f991 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -4,10 +4,12 @@ import { InstrumentationBase, type InstrumentationNodeModuleDefinition } from '@ import type { FastifyPluginCallback } from 'fastify' import type { + FastifyOtelHookName, FastifyOtelInstrumentationOpts, FastifyOtelLifecycleHookInfo, FastifyOtelOptions, - FastifyOtelRequestContext + FastifyOtelRequestContext, + FastifyOtelRouteConfig } from './types' declare module 'fastify' { @@ -16,8 +18,8 @@ declare module 'fastify' { } interface FastifyContextConfig { - /** Set this to `false` to disable OpenTelemetry for the route */ - otel?: boolean + /** Set to `false` to disable OpenTelemetry for the route, or use an object to control hook spans */ + otel?: boolean | FastifyOtelRouteConfig } } @@ -28,7 +30,12 @@ declare class FastifyOtelInstrumentation() expect(info.handler).type.toBeAssignableTo() }, - recordExceptions: false + recordExceptions: false, + instrumentHooks: ['preHandler'] } as FastifyOtelInstrumentationOpts expect(complexOpts).type.toBeAssignableTo() @@ -79,6 +80,14 @@ app.get('/with-otel-false', { config: { otel: false } }, async function (_reques return { hello: 'world' } }) +app.get('/with-otel-instrument-hooks-false', { config: { otel: { instrumentHooks: false } } }, async function (_request, _reply) { + return { hello: 'world' } +}) + +app.get('/with-otel-instrument-hooks-allowlist', { config: { otel: { instrumentHooks: ['preHandler'] } } }, 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 f4f1cd5..54f264a 100644 --- a/types/types.d.ts +++ b/types/types.d.ts @@ -3,6 +3,20 @@ import type { InstrumentationConfig } from '@opentelemetry/instrumentation' import type { Context, Span, TextMapGetter, TextMapSetter, Tracer } from '@opentelemetry/api' import type { HTTPMethods } from 'fastify' +export type FastifyOtelHookName = + | 'onRequest' + | 'preParsing' + | 'preValidation' + | 'preHandler' + | 'preSerialization' + | 'onSend' + | 'onResponse' + | 'onError' + +export interface FastifyOtelRouteConfig { + instrumentHooks?: boolean | FastifyOtelHookName[] +} + export interface FastifyOtelOptions {} export interface FastifyOtelInstrumentationOpts extends InstrumentationConfig { registerOnInitialization?: boolean @@ -10,6 +24,7 @@ export interface FastifyOtelInstrumentationOpts extends InstrumentationConfig { requestHook?: (span: import('@opentelemetry/api').Span, request: import('fastify').FastifyRequest) => void lifecycleHook?: (span: import('@opentelemetry/api').Span, info: FastifyOtelLifecycleHookInfo) => void recordExceptions?: boolean + instrumentHooks?: boolean | FastifyOtelHookName[] } export interface FastifyOtelLifecycleHookInfo {