diff --git a/src/utils/trace.ts b/src/utils/trace.ts index f5c2345..a238337 100644 --- a/src/utils/trace.ts +++ b/src/utils/trace.ts @@ -15,12 +15,19 @@ export function emitTrace( ): void { if (!fn) return try { - fn(event) + // Guard async callbacks: if fn returns a Promise, swallow its rejection + // so an async onTrace never produces an unhandled promise rejection. + const result = fn(event) as unknown + if (result && typeof (result as Promise).catch === 'function') { + ;(result as Promise).catch(noop) + } } catch { // Intentionally swallowed — observability must never break execution. } } +function noop() {} + /** Generate a unique run ID for trace correlation. */ export function generateRunId(): string { return randomUUID() diff --git a/tests/trace.test.ts b/tests/trace.test.ts index 7e4884b..fbeb78c 100644 --- a/tests/trace.test.ts +++ b/tests/trace.test.ts @@ -136,6 +136,25 @@ describe('emitTrace', () => { }), ).not.toThrow() }) + + it('swallows rejected promises from async callbacks', async () => { + // An async onTrace that rejects should not produce unhandled rejection + const fn = async () => { throw new Error('async boom') } + emitTrace(fn as unknown as (event: TraceEvent) => void, { + type: 'agent', + runId: 'r1', + agent: 'a', + turns: 1, + tokens: { input_tokens: 0, output_tokens: 0 }, + toolCalls: 0, + startMs: 0, + endMs: 0, + durationMs: 0, + }) + // If the rejection is not caught, vitest will fail with unhandled rejection. + // Give the microtask queue a tick to surface any unhandled rejection. + await new Promise(resolve => setTimeout(resolve, 10)) + }) }) describe('generateRunId', () => {