diff --git a/README.md b/README.md index e1b09cb..cbfc3da 100644 --- a/README.md +++ b/README.md @@ -241,6 +241,12 @@ const otel = new FastifyOtelInstrumentation({ }) ``` +#### `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). diff --git a/index.js b/index.js index 2ace6c7..3f9f939 100644 --- a/index.js +++ b/index.js @@ -47,6 +47,7 @@ const kRequestContext = Symbol('fastify otel request context') 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') class FastifyOtelInstrumentation extends InstrumentationBase { logger = null @@ -57,6 +58,15 @@ class FastifyOtelInstrumentation extends InstrumentationBase { super(PACKAGE_NAME, PACKAGE_VERSION, config) this.logger = diag.createComponentLogger({ namespace: PACKAGE_NAME }) 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 } @@ -362,7 +372,9 @@ class FastifyOtelInstrumentation extends InstrumentationBase { code: SpanStatusCode.ERROR, message: error.message }) - span.recordException(error) + if (instrumentation[kRecordExceptions] !== false) { + span.recordException(error) + } } hookDone() @@ -502,7 +514,9 @@ class FastifyOtelInstrumentation extends InstrumentationBase { code: SpanStatusCode.ERROR, message: error.message }) - span.recordException(error) + if (instrumentation[kRecordExceptions] !== false) { + span.recordException(error) + } span.end() return Promise.reject(error) } @@ -516,7 +530,9 @@ class FastifyOtelInstrumentation extends InstrumentationBase { code: SpanStatusCode.ERROR, message: error.message }) - span.recordException(error) + if (instrumentation[kRecordExceptions] !== false) { + span.recordException(error) + } span.end() throw error } diff --git a/test/api.test.js b/test/api.test.js index 927b8e4..4e7ffe1 100644 --- a/test/api.test.js +++ b/test/api.test.js @@ -49,6 +49,12 @@ describe('Interface', () => { assert.doesNotThrow(() => new FastifyInstrumentation({ ignorePaths: '/foo' })) }) + 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 => { const app = Fastify() const instrumentation = new FastifyOtelInstrumentation() @@ -340,4 +346,81 @@ describe('Interface', () => { 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() + }) }) diff --git a/types/index.test-d.ts b/types/index.test-d.ts index e85561e..5b416ef 100644 --- a/types/index.test-d.ts +++ b/types/index.test-d.ts @@ -19,7 +19,8 @@ expectAssignable({ expectAssignable(info.hookName) expectAssignable(info.request) expectAssignable(info.handler) - } + }, + recordExceptions: false } as FastifyOtelInstrumentationOpts) expectAssignable({} as FastifyOtelInstrumentationOpts) diff --git a/types/types.d.ts b/types/types.d.ts index 2d365b5..f4f1cd5 100644 --- a/types/types.d.ts +++ b/types/types.d.ts @@ -9,6 +9,7 @@ export interface FastifyOtelInstrumentationOpts extends InstrumentationConfig { ignorePaths?: string | ((routeOpts: { url: string, method: HTTPMethods }) => boolean); 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 {