Skip to content

Commit 894cf8e

Browse files
authored
Record exceptions on spans when callbacks throw or reject (#102)
1 parent 6200b9f commit 894cf8e

3 files changed

Lines changed: 154 additions & 6 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"logfire": patch
3+
---
4+
5+
Record exceptions on spans when callbacks throw or reject
6+
7+
`span()` now automatically records exception details (event, ERROR status, log level, fingerprint) when the callback throws synchronously or the returned promise rejects, matching the Python SDK's behavior.

packages/logfire-api/src/index.test.ts

Lines changed: 113 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,40 @@
1-
import { trace } from '@opentelemetry/api'
1+
import { SpanStatusCode, trace } from '@opentelemetry/api'
22
import { beforeEach, describe, expect, test, vi } from 'vitest'
33

44
import {
5+
ATTRIBUTES_EXCEPTION_FINGERPRINT_KEY,
56
ATTRIBUTES_LEVEL_KEY,
67
ATTRIBUTES_MESSAGE_KEY,
78
ATTRIBUTES_MESSAGE_TEMPLATE_KEY,
89
ATTRIBUTES_SPAN_TYPE_KEY,
910
ATTRIBUTES_TAGS_KEY,
1011
} from './constants'
11-
import { info } from './index'
12+
import { info, span } from './index'
1213

13-
vi.mock('@opentelemetry/api', () => {
14+
const { spanMock } = vi.hoisted(() => {
1415
const spanMock = {
1516
end: vi.fn(),
17+
recordException: vi.fn(),
1618
setAttribute: vi.fn(),
1719
setStatus: vi.fn(),
1820
}
21+
return { spanMock }
22+
})
1923

24+
vi.mock('@opentelemetry/api', () => {
2025
const tracerMock = {
26+
startActiveSpan: vi.fn((_name: string, _options: unknown, _context: unknown, fn: (s: typeof spanMock) => unknown) => fn(spanMock)),
2127
startSpan: vi.fn(() => spanMock),
2228
}
2329

2430
return {
2531
context: {
2632
active: vi.fn(),
2733
},
34+
SpanStatusCode: { ERROR: 2 },
2835
trace: {
2936
getTracer: vi.fn(() => tracerMock),
37+
setSpan: vi.fn(),
3038
},
3139
}
3240
})
@@ -80,3 +88,105 @@ describe('info', () => {
8088
)
8189
})
8290
})
91+
92+
describe('span', () => {
93+
beforeEach(() => {
94+
vi.clearAllMocks()
95+
})
96+
97+
test('sync callback succeeds - span ends normally', () => {
98+
const result = span('test {x}', { attributes: { x: 1 }, callback: () => 'ok' })
99+
100+
expect(result).toBe('ok')
101+
expect(spanMock.end).toHaveBeenCalledOnce()
102+
expect(spanMock.recordException).not.toHaveBeenCalled()
103+
expect(spanMock.setStatus).not.toHaveBeenCalled()
104+
})
105+
106+
test('sync callback throws Error - records exception and re-throws', () => {
107+
const error = new Error('boom')
108+
109+
expect(() =>
110+
span('test {x}', {
111+
attributes: { x: 1 },
112+
callback: () => {
113+
throw error
114+
},
115+
})
116+
).toThrow(error)
117+
118+
expect(spanMock.recordException).toHaveBeenCalledWith(error)
119+
expect(spanMock.setStatus).toHaveBeenCalledWith({ code: SpanStatusCode.ERROR, message: 'Error: boom' })
120+
expect(spanMock.setAttribute).toHaveBeenCalledWith(ATTRIBUTES_LEVEL_KEY, 17)
121+
expect(spanMock.setAttribute).toHaveBeenCalledWith(ATTRIBUTES_EXCEPTION_FINGERPRINT_KEY, expect.any(String))
122+
expect(spanMock.end).toHaveBeenCalledOnce()
123+
})
124+
125+
test('sync callback throws string - records exception without fingerprint', () => {
126+
expect(() =>
127+
span('test', {
128+
callback: () => {
129+
// eslint-disable-next-line no-throw-literal, @typescript-eslint/only-throw-error
130+
throw 'oops'
131+
},
132+
})
133+
).toThrow('oops')
134+
135+
expect(spanMock.recordException).toHaveBeenCalledWith('oops')
136+
expect(spanMock.setStatus).toHaveBeenCalledWith({ code: SpanStatusCode.ERROR, message: 'Error: oops' })
137+
expect(spanMock.setAttribute).toHaveBeenCalledWith(ATTRIBUTES_LEVEL_KEY, 17)
138+
expect(spanMock.setAttribute).not.toHaveBeenCalledWith(ATTRIBUTES_EXCEPTION_FINGERPRINT_KEY, expect.anything())
139+
expect(spanMock.end).toHaveBeenCalledOnce()
140+
})
141+
142+
test('async callback resolves - span ends normally', async () => {
143+
const result = span('test', { callback: () => Promise.resolve('async-ok') })
144+
await expect(result).resolves.toBe('async-ok')
145+
146+
expect(spanMock.end).toHaveBeenCalledOnce()
147+
expect(spanMock.recordException).not.toHaveBeenCalled()
148+
expect(spanMock.setStatus).not.toHaveBeenCalled()
149+
})
150+
151+
test('async callback rejects with Error - records exception', async () => {
152+
const error = new Error('async-boom')
153+
const result = span('test', { callback: () => Promise.reject(error) })
154+
155+
await expect(result).rejects.toThrow(error)
156+
157+
// Allow microtask for the .then() handler to run
158+
await new Promise((resolve) => setTimeout(resolve, 0))
159+
160+
expect(spanMock.recordException).toHaveBeenCalledWith(error)
161+
expect(spanMock.setStatus).toHaveBeenCalledWith({ code: SpanStatusCode.ERROR, message: 'Error: async-boom' })
162+
expect(spanMock.setAttribute).toHaveBeenCalledWith(ATTRIBUTES_LEVEL_KEY, 17)
163+
expect(spanMock.setAttribute).toHaveBeenCalledWith(ATTRIBUTES_EXCEPTION_FINGERPRINT_KEY, expect.any(String))
164+
expect(spanMock.end).toHaveBeenCalledOnce()
165+
})
166+
167+
test('async callback rejects with string - records exception without fingerprint', async () => {
168+
// eslint-disable-next-line prefer-promise-reject-errors, @typescript-eslint/prefer-promise-reject-errors
169+
const result = span('test', { callback: () => Promise.reject('async-oops') })
170+
171+
await expect(result).rejects.toBe('async-oops')
172+
173+
await new Promise((resolve) => setTimeout(resolve, 0))
174+
175+
expect(spanMock.recordException).toHaveBeenCalledWith('async-oops')
176+
expect(spanMock.setStatus).toHaveBeenCalledWith({ code: SpanStatusCode.ERROR, message: 'Error: async-oops' })
177+
expect(spanMock.setAttribute).toHaveBeenCalledWith(ATTRIBUTES_LEVEL_KEY, 17)
178+
expect(spanMock.setAttribute).not.toHaveBeenCalledWith(ATTRIBUTES_EXCEPTION_FINGERPRINT_KEY, expect.anything())
179+
expect(spanMock.end).toHaveBeenCalledOnce()
180+
})
181+
182+
test('thenable callback result is returned untouched', () => {
183+
const then = vi.fn()
184+
const lazyThenable = { then }
185+
186+
const result = span('test', { callback: () => lazyThenable })
187+
188+
expect(result).toBe(lazyThenable)
189+
expect(then).not.toHaveBeenCalled()
190+
expect(spanMock.end).toHaveBeenCalledOnce()
191+
})
192+
})

packages/logfire-api/src/index.ts

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -144,10 +144,27 @@ export function span<R>(msgTemplate: string, ...args: SpanArgsVariant1<R> | Span
144144
},
145145
context,
146146
(span: Span) => {
147-
const result = callback(span)
147+
let result: R
148+
try {
149+
result = callback(span)
150+
} catch (thrown) {
151+
recordSpanException(span, thrown)
152+
span.end()
153+
throw thrown
154+
}
148155

149-
// we need this clunky detection because of zone.js promises
150-
if (typeof result === 'object' && result !== null && 'finally' in result && typeof result.finally === 'function') {
156+
if (result instanceof Promise) {
157+
result.then(
158+
() => {
159+
span.end()
160+
},
161+
(reason: unknown) => {
162+
recordSpanException(span, reason)
163+
span.end()
164+
}
165+
)
166+
// we need this clunky detection because of zone.js promises
167+
} else if (typeof result === 'object' && result !== null && 'finally' in result && typeof result.finally === 'function') {
151168
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
152169
result.finally(() => {
153170
span.end()
@@ -192,6 +209,20 @@ export function warning(message: string, attributes: Record<string, unknown> = {
192209
log(message, attributes, { ...options, level: Level.Warning })
193210
}
194211

212+
function recordSpanException(span: Span, thrown: unknown): void {
213+
const isError = thrown instanceof Error
214+
const errorMessage = isError ? thrown.message : String(thrown)
215+
const errorName = isError ? thrown.name : 'Error'
216+
217+
span.recordException(isError ? thrown : String(thrown))
218+
span.setStatus({ code: SpanStatusCode.ERROR, message: `${errorName}: ${errorMessage}` })
219+
span.setAttribute(ATTRIBUTES_LEVEL_KEY, Level.Error)
220+
221+
if (isError && logfireApiConfig.enableErrorFingerprinting) {
222+
span.setAttribute(ATTRIBUTES_EXCEPTION_FINGERPRINT_KEY, computeFingerprint(thrown))
223+
}
224+
}
225+
195226
/**
196227
* Use this method to report an error to Logfire.
197228
* Captures the error stack trace and message in the respective semantic attributes and sets the correct level and status.

0 commit comments

Comments
 (0)