Skip to content
Merged
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
17 changes: 15 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
37 changes: 28 additions & 9 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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') {
Expand All @@ -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
}
Expand Down Expand Up @@ -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) {
Expand Down
6 changes: 6 additions & 0 deletions test/api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
97 changes: 97 additions & 0 deletions test/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down
2 changes: 1 addition & 1 deletion types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
11 changes: 10 additions & 1 deletion types/index.tst.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ const complexOpts = {
expect(info.handler).type.toBeAssignableTo<string | undefined>()
},
recordExceptions: false,
instrumentHooks: ['preHandler']
instrumentHooks: ['preHandler'],
instrumentHandler: false
} as FastifyOtelInstrumentationOpts

expect(complexOpts).type.toBeAssignableTo<InstrumentationConfig>()
Expand Down Expand Up @@ -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' }
})
2 changes: 2 additions & 0 deletions types/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export type FastifyOtelHookName =

export interface FastifyOtelRouteConfig {
instrumentHooks?: boolean | FastifyOtelHookName[]
instrumentHandler?: boolean
}

export interface FastifyOtelOptions {}
Expand All @@ -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 {
Expand Down