@fastify/otel
Advanced tools
+40
-9
@@ -50,2 +50,3 @@ 'use strict' | ||
| const kIgnorePaths = Symbol('fastify otel ignore path') | ||
| const kRecordExceptions = Symbol('fastify otel record exceptions') | ||
@@ -55,2 +56,3 @@ class FastifyOtelInstrumentation extends InstrumentationBase { | ||
| _requestHook = null | ||
| _lifecycleHook = null | ||
@@ -61,5 +63,17 @@ constructor (config) { | ||
| this[kIgnorePaths] = null | ||
| this[kRecordExceptions] = true | ||
| if (config?.recordExceptions != null) { | ||
| if (typeof config.recordExceptions !== 'boolean') { | ||
| throw new TypeError('recordExceptions must be a boolean') | ||
| } | ||
| this[kRecordExceptions] = config.recordExceptions | ||
| } | ||
| if (typeof config?.requestHook === 'function') { | ||
| this._requestHook = config.requestHook | ||
| } | ||
| if (typeof config?.lifecycleHook === 'function') { | ||
| this._lifecycleHook = config.lifecycleHook | ||
| } | ||
@@ -364,3 +378,5 @@ if (config?.ignorePaths != null || process.env.OTEL_FASTIFY_IGNORE_PATHS != null) { | ||
| }) | ||
| span.recordException(error) | ||
| if (instrumentation[kRecordExceptions] !== false) { | ||
| span.recordException(error) | ||
| } | ||
| } | ||
@@ -464,9 +480,8 @@ | ||
| const ctx = request[kRequestContext] ?? context.active() | ||
| const handlerName = handler.name?.length > 0 | ||
| ? handler.name | ||
| : this.pluginName /* c8 ignore next */ ?? ANONYMOUS_FUNCTION_NAME /* c8 ignore next */ | ||
| const span = instrumentation.tracer.startSpan( | ||
| `${hookName} - ${ | ||
| handler.name?.length > 0 | ||
| ? handler.name | ||
| : this.pluginName /* c8 ignore next */ ?? | ||
| ANONYMOUS_FUNCTION_NAME /* c8 ignore next */ | ||
| }`, | ||
| `${hookName} - ${handlerName}`, | ||
| { | ||
@@ -478,2 +493,14 @@ attributes: spanAttributes | ||
| if (instrumentation._lifecycleHook != null) { | ||
| try { | ||
| instrumentation._lifecycleHook(span, { | ||
| hookName, | ||
| request, | ||
| handler: handlerName | ||
| }) | ||
| } catch (err) { | ||
| instrumentation.logger.error({ err }, 'Execution of lifecycleHook failed') | ||
| } | ||
| } | ||
| return context.with( | ||
@@ -496,3 +523,5 @@ trace.setSpan(ctx, span), | ||
| }) | ||
| span.recordException(error) | ||
| if (instrumentation[kRecordExceptions] !== false) { | ||
| span.recordException(error) | ||
| } | ||
| span.end() | ||
@@ -511,3 +540,5 @@ return Promise.reject(error) | ||
| }) | ||
| span.recordException(error) | ||
| if (instrumentation[kRecordExceptions] !== false) { | ||
| span.recordException(error) | ||
| } | ||
| span.end() | ||
@@ -514,0 +545,0 @@ throw error |
+1
-1
| { | ||
| "name": "@fastify/otel", | ||
| "version": "0.14.0", | ||
| "version": "0.15.0", | ||
| "description": "Official Fastify OpenTelemetry Instrumentation", | ||
@@ -5,0 +5,0 @@ "main": "index.js", |
+28
-0
@@ -222,4 +222,32 @@ # @fastify/otel | ||
| #### `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). | ||
| * **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 | ||
| * **info.request** – the current `FastifyRequest` | ||
| Use it to rename hook spans or annotate them with framework-specific metadata (for example, tRPC procedure names) without registering additional Fastify hooks. | ||
| ```js | ||
| const otel = new FastifyOtelInstrumentation({ | ||
| lifecycleHook: (span, info) => { | ||
| if (info.hookName === 'handler' && info.request.headers['x-trpc-op'] != null) { | ||
| span.updateName(`tRPC handler - ${info.request.headers['x-trpc-op']}`) | ||
| } | ||
| span.setAttribute('hook.handler.name', info.handler ?? 'anonymous') | ||
| } | ||
| }) | ||
| ``` | ||
| #### `FastifyOtelInstrumentationOptions#recordExceptions: boolean` | ||
| Control whether the instrumentation automatically calls `span.recordException` when a handler or hook throws. | ||
| Defaults to `true`, recording every exception. Set it to `false` if you prefer to record only the | ||
| exceptions that you consider actionable (for example to avoid noisy `4xx` entries in Datadog Error Tracking). | ||
| ## License | ||
| Licensed under [MIT](./LICENSE). |
+150
-0
@@ -52,2 +52,8 @@ 'use strict' | ||
| test('FastifyOtelInstrumentationOpts#recordExceptions - should be a boolean when provided', async t => { | ||
| assert.throws(() => new FastifyInstrumentation({ recordExceptions: 'nope' }), /boolean/) | ||
| assert.doesNotThrow(() => new FastifyInstrumentation({ recordExceptions: true })) | ||
| assert.doesNotThrow(() => new FastifyInstrumentation({ recordExceptions: false })) | ||
| }) | ||
| test('NamedFastifyInstrumentation#plugin should return a valid Fastify Plugin', async t => { | ||
@@ -277,2 +283,146 @@ const app = Fastify() | ||
| }) | ||
| test('FastifyInstrumentation#lifecycleHook should be invoked for every hook span', async () => { | ||
| const app = Fastify() | ||
| const calls = [] | ||
| const exporter = new InMemorySpanExporter() | ||
| const provider = new NodeTracerProvider({ | ||
| spanProcessors: [new SimpleSpanProcessor(exporter)] | ||
| }) | ||
| provider.register() | ||
| const instrumentation = new FastifyInstrumentation({ | ||
| lifecycleHook: (span, info) => { | ||
| calls.push({ hookName: info.hookName, handler: info.handler }) | ||
| span.updateName(`custom:${info.hookName}`) | ||
| span.setAttribute('hook.handler', info.handler ?? 'unknown') | ||
| span.addEvent('customized') | ||
| } | ||
| }) | ||
| instrumentation.setTracerProvider(provider) | ||
| await app.register(instrumentation.plugin()) | ||
| app.get('/', { | ||
| preHandler: function guard (request, reply, done) { | ||
| done() | ||
| } | ||
| }, function routeHandler () { | ||
| return 'ok' | ||
| }) | ||
| const res = await app.inject({ | ||
| method: 'GET', | ||
| url: '/' | ||
| }) | ||
| assert.equal(res.statusCode, 200) | ||
| assert.equal(res.payload, 'ok') | ||
| assert.deepStrictEqual(calls.map(call => call.hookName), ['preHandler', 'handler']) | ||
| assert.deepStrictEqual(calls.map(call => call.handler), ['guard', 'routeHandler']) | ||
| const hookSpans = exporter.getFinishedSpans().filter(span => span.name.startsWith('custom:')) | ||
| assert.equal(hookSpans.length, 2) | ||
| assert.ok(hookSpans.every(span => span.attributes['hook.handler'] != null)) | ||
| }) | ||
| test('FastifyInstrumentation#lifecycleHook should not crash when it throws', async () => { | ||
| const app = Fastify() | ||
| const instrumentation = new FastifyInstrumentation({ | ||
| lifecycleHook: () => { | ||
| throw new Error('boom') | ||
| } | ||
| }) | ||
| await app.register(instrumentation.plugin()) | ||
| app.get('/', () => 'ok') | ||
| const res = await app.inject({ | ||
| method: 'GET', | ||
| url: '/' | ||
| }) | ||
| assert.equal(res.statusCode, 200) | ||
| assert.equal(res.payload, 'ok') | ||
| }) | ||
| test('FastifyInstrumentationOptions#recordExceptions defaults to true', async () => { | ||
| const exporter = new InMemorySpanExporter() | ||
| const provider = new NodeTracerProvider({ | ||
| spanProcessors: [new SimpleSpanProcessor(exporter)] | ||
| }) | ||
| provider.register() | ||
| const instrumentation = new FastifyInstrumentation() | ||
| instrumentation.setTracerProvider(provider) | ||
| /** @type {import('fastify').FastifyInstance} */ | ||
| const app = Fastify() | ||
| await app.register(instrumentation.plugin()) | ||
| app.get('/', async function badRequest () { | ||
| const error = new Error('book not found') | ||
| error.statusCode = 404 | ||
| throw error | ||
| }) | ||
| const res = await app.inject({ | ||
| method: 'GET', | ||
| url: '/' | ||
| }) | ||
| assert.equal(res.statusCode, 404) | ||
| const spans = exporter.getFinishedSpans() | ||
| const handlerSpan = spans.find((span) => span.name.startsWith('handler')) | ||
| assert.ok(handlerSpan) | ||
| assert.equal(handlerSpan.events.length, 1) | ||
| assert.equal(handlerSpan.events[0].name, 'exception') | ||
| await app.close() | ||
| instrumentation.disable() | ||
| }) | ||
| test('FastifyInstrumentationOptions#recordExceptions can be disabled', async () => { | ||
| const exporter = new InMemorySpanExporter() | ||
| const provider = new NodeTracerProvider({ | ||
| spanProcessors: [new SimpleSpanProcessor(exporter)] | ||
| }) | ||
| provider.register() | ||
| const instrumentation = new FastifyInstrumentation({ | ||
| recordExceptions: false | ||
| }) | ||
| instrumentation.setTracerProvider(provider) | ||
| /** @type {import('fastify').FastifyInstance} */ | ||
| const app = Fastify() | ||
| await app.register(instrumentation.plugin()) | ||
| app.get('/', async function badRequest () { | ||
| const error = new Error('book not found') | ||
| error.statusCode = 404 | ||
| throw error | ||
| }) | ||
| const res = await app.inject({ | ||
| method: 'GET', | ||
| url: '/' | ||
| }) | ||
| assert.equal(res.statusCode, 404) | ||
| const spans = exporter.getFinishedSpans() | ||
| const handlerSpan = spans.find((span) => span.name.startsWith('handler')) | ||
| assert.ok(handlerSpan) | ||
| assert.equal(handlerSpan.events.length, 0) | ||
| await app.close() | ||
| instrumentation.disable() | ||
| }) | ||
| }) |
+2
-1
@@ -8,2 +8,3 @@ /// <reference types="node" /> | ||
| FastifyOtelInstrumentationOpts, | ||
| FastifyOtelLifecycleHookInfo, | ||
| FastifyOtelOptions, | ||
@@ -31,3 +32,3 @@ FastifyOtelRequestContext | ||
| declare namespace exported { | ||
| export type { FastifyOtelInstrumentationOpts } | ||
| export type { FastifyOtelInstrumentationOpts, FastifyOtelLifecycleHookInfo } | ||
| export { FastifyOtelInstrumentation } | ||
@@ -34,0 +35,0 @@ export { FastifyOtelInstrumentation as default } |
@@ -16,3 +16,10 @@ import { expectAssignable } from 'tsd' | ||
| expectAssignable<FastifyRequest>(request) | ||
| } | ||
| }, | ||
| lifecycleHook (span, info) { | ||
| expectAssignable<Span>(span) | ||
| expectAssignable<string>(info.hookName) | ||
| expectAssignable<FastifyRequest>(info.request) | ||
| expectAssignable<string | undefined>(info.handler) | ||
| }, | ||
| recordExceptions: false | ||
| } as FastifyOtelInstrumentationOpts) | ||
@@ -19,0 +26,0 @@ expectAssignable<InstrumentationConfig>({} as FastifyOtelInstrumentationOpts) |
+8
-0
@@ -11,4 +11,12 @@ // types.d.ts | ||
| requestHook?: (span: import('@opentelemetry/api').Span, request: import('fastify').FastifyRequest) => void | ||
| lifecycleHook?: (span: import('@opentelemetry/api').Span, info: FastifyOtelLifecycleHookInfo) => void | ||
| recordExceptions?: boolean | ||
| } | ||
| export interface FastifyOtelLifecycleHookInfo { | ||
| hookName: string | ||
| request: import('fastify').FastifyRequest | ||
| handler?: string | ||
| } | ||
| interface FastifyOtelRequestInfo { | ||
@@ -15,0 +23,0 @@ tracer: Tracer, |
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
Dynamic require
Supply chain riskDynamic require can indicate the package is performing dangerous or unsafe dynamic code execution.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 3 instances in 1 package
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
Dynamic require
Supply chain riskDynamic require can indicate the package is performing dangerous or unsafe dynamic code execution.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 3 instances in 1 package
115518
7.02%2538
6.68%253
12.44%