Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
141 changes: 134 additions & 7 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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') {
Expand All @@ -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

Expand Down Expand Up @@ -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,
Expand All @@ -180,16 +272,26 @@ 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`
)

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') {
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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`
)
Expand All @@ -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
Expand Down
34 changes: 34 additions & 0 deletions test/api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
Loading