Skip to content

Commit d73f474

Browse files
authored
Log more info about internal errors (#346)
1 parent fe704f3 commit d73f474

File tree

9 files changed

+251
-13
lines changed

9 files changed

+251
-13
lines changed

logfire-api/logfire_api/_internal/stack_info.pyi

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ STACK_INFO_KEYS: Incomplete
77
SITE_PACKAGES_DIR: Incomplete
88
PYTHON_LIB_DIR: Incomplete
99
LOGFIRE_DIR: Incomplete
10-
PREFIXES: Incomplete
10+
NON_USER_CODE_PREFIXES: Incomplete
1111

1212
def get_filepath_attribute(file: str) -> StackInfo: ...
1313
def get_code_object_info(code: CodeType) -> StackInfo: ...

logfire-api/logfire_api/_internal/utils.pyi

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from _typeshed import Incomplete
22
from collections.abc import Generator
3+
from logfire._internal.stack_info import is_user_code as is_user_code
34
from opentelemetry import trace as trace_api
45
from opentelemetry.sdk.resources import Resource
56
from opentelemetry.sdk.trace import Event as Event, ReadableSpan

logfire/_internal/main.py

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
import warnings
99
from functools import cached_property, partial
1010
from time import time
11-
from types import TracebackType
1211
from typing import TYPE_CHECKING, Any, Callable, ContextManager, Iterable, Literal, Sequence, TypeVar, Union, cast
1312

1413
import opentelemetry.context as context_api
@@ -52,7 +51,7 @@
5251
from .metrics import ProxyMeterProvider
5352
from .stack_info import get_user_stack_info
5453
from .tracer import ProxyTracerProvider
55-
from .utils import handle_internal_errors, log_internal_error, uniquify_sequence
54+
from .utils import SysExcInfo, handle_internal_errors, log_internal_error, uniquify_sequence
5655

5756
if TYPE_CHECKING:
5857
import anthropic
@@ -88,13 +87,7 @@
8887
# 1. It's convenient to pass the result of sys.exc_info() directly
8988
# 2. It mirrors the exc_info argument of the stdlib logging methods
9089
# 3. The argument name exc_info is very suggestive of the sys function.
91-
ExcInfo: typing.TypeAlias = Union[
92-
'tuple[type[BaseException], BaseException, TracebackType | None]',
93-
'tuple[None, None, None]',
94-
BaseException,
95-
bool,
96-
None,
97-
]
90+
ExcInfo: typing.TypeAlias = Union[SysExcInfo, BaseException, bool, None]
9891

9992

10093
class Logfire:

logfire/_internal/stack_info.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
SITE_PACKAGES_DIR = str(Path(opentelemetry.sdk.trace.__file__).parent.parent.parent.parent.absolute())
2323
PYTHON_LIB_DIR = str(Path(inspect.__file__).parent.absolute())
2424
LOGFIRE_DIR = str(Path(logfire.__file__).parent.absolute())
25-
PREFIXES = (SITE_PACKAGES_DIR, PYTHON_LIB_DIR, LOGFIRE_DIR)
25+
NON_USER_CODE_PREFIXES = (SITE_PACKAGES_DIR, PYTHON_LIB_DIR, LOGFIRE_DIR)
2626

2727

2828
def get_filepath_attribute(file: str) -> StackInfo:
@@ -95,7 +95,7 @@ def is_user_code(code: CodeType) -> bool:
9595
On the other hand, generator expressions and lambdas might be called far away from where they are defined.
9696
"""
9797
return not (
98-
str(Path(code.co_filename).absolute()).startswith(PREFIXES)
98+
str(Path(code.co_filename).absolute()).startswith(NON_USER_CODE_PREFIXES)
9999
or code.co_name in ('<listcomp>', '<dictcomp>', '<setcomp>')
100100
)
101101

logfire/_internal/utils.py

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
from __future__ import annotations
22

3+
import inspect
34
import json
45
import logging
56
import os
67
import sys
8+
import typing
79
from contextlib import contextmanager
810
from pathlib import Path
11+
from types import TracebackType
912
from typing import TYPE_CHECKING, Any, Dict, List, Mapping, Sequence, Tuple, TypedDict, TypeVar, Union
1013

1114
from opentelemetry import context, trace as trace_api
@@ -16,6 +19,8 @@
1619
from opentelemetry.util import types as otel_types
1720
from requests import RequestException, Response
1821

22+
from logfire._internal.stack_info import is_user_code
23+
1924
if TYPE_CHECKING:
2025
from packaging.version import Version
2126

@@ -252,7 +257,81 @@ def log_internal_error():
252257
raise
253258

254259
with suppress_instrumentation(): # prevent infinite recursion from the logging integration
255-
logger.exception('Internal error in Logfire')
260+
logger.exception('Internal error in Logfire', exc_info=_internal_error_exc_info())
261+
262+
263+
SysExcInfo: typing.TypeAlias = Union[
264+
'tuple[type[BaseException], BaseException, TracebackType | None]',
265+
'tuple[None, None, None]',
266+
]
267+
"""
268+
The return type of sys.exc_info(): exc_type, exc_val, exc_tb.
269+
"""
270+
271+
272+
def _internal_error_exc_info() -> SysExcInfo:
273+
"""Returns an exc_info tuple with a nicely tweaked traceback."""
274+
original_exc_info: SysExcInfo = sys.exc_info()
275+
exc_type, exc_val, original_tb = original_exc_info
276+
try:
277+
# First remove redundant frames already in the traceback about where the error was raised.
278+
tb = original_tb
279+
if tb and tb.tb_frame and tb.tb_frame.f_code is _HANDLE_INTERNAL_ERRORS_CODE:
280+
# Skip the 'yield' line in _handle_internal_errors
281+
tb = tb.tb_next
282+
283+
if (
284+
tb
285+
and tb.tb_frame
286+
and tb.tb_frame.f_code.co_filename == contextmanager.__code__.co_filename
287+
and tb.tb_frame.f_code.co_name == 'inner'
288+
):
289+
# Skip the 'inner' function frame when handle_internal_errors is used as a decorator.
290+
# It looks like `return func(*args, **kwds)`
291+
tb = tb.tb_next
292+
293+
# Now add useful outer frames that give context, but skipping frames that are just about handling the error.
294+
frame = inspect.currentframe()
295+
# Skip this frame right here.
296+
assert frame
297+
frame = frame.f_back
298+
299+
if frame and frame.f_code is log_internal_error.__code__: # pragma: no branch
300+
# This function is always called from log_internal_error, so skip that frame.
301+
frame = frame.f_back
302+
assert frame
303+
304+
if frame.f_code is _HANDLE_INTERNAL_ERRORS_CODE:
305+
# Skip the line in _handle_internal_errors that calls log_internal_error
306+
frame = frame.f_back
307+
# Skip the frame defining the _handle_internal_errors context manager
308+
assert frame and frame.f_code.co_name == '__exit__'
309+
frame = frame.f_back
310+
assert frame
311+
# Skip the frame calling the context manager, on the `with` line.
312+
frame = frame.f_back
313+
else:
314+
# `log_internal_error()` was called directly, so just skip that frame. No context manager stuff.
315+
frame = frame.f_back
316+
317+
# Now add all remaining frames from internal logfire code.
318+
while frame and not is_user_code(frame.f_code):
319+
tb = TracebackType(tb_next=tb, tb_frame=frame, tb_lasti=frame.f_lasti, tb_lineno=frame.f_lineno)
320+
frame = frame.f_back
321+
322+
# Add up to 3 frames from user code.
323+
for _ in range(3):
324+
if not frame: # pragma: no cover
325+
break
326+
tb = TracebackType(tb_next=tb, tb_frame=frame, tb_lasti=frame.f_lasti, tb_lineno=frame.f_lineno)
327+
frame = frame.f_back
328+
329+
assert exc_type
330+
assert exc_val
331+
exc_val = exc_val.with_traceback(tb)
332+
return exc_type, exc_val, tb
333+
except Exception: # pragma: no cover
334+
return original_exc_info
256335

257336

258337
@contextmanager
@@ -263,6 +342,9 @@ def handle_internal_errors():
263342
log_internal_error()
264343

265344

345+
_HANDLE_INTERNAL_ERRORS_CODE = inspect.unwrap(handle_internal_errors).__code__
346+
347+
266348
def maybe_capture_server_headers(capture: bool):
267349
if capture:
268350
os.environ['OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST'] = '.*'

tests/import_used_for_tests/internal_error_handling/__init__.py

Whitespace-only changes.
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
from typing import Any
2+
3+
from logfire._internal.utils import handle_internal_errors, log_internal_error
4+
5+
6+
def inner1():
7+
raise ValueError('inner1')
8+
9+
10+
def inner2():
11+
inner1()
12+
13+
14+
@handle_internal_errors()
15+
def using_decorator():
16+
inner2()
17+
18+
19+
def using_context_manager():
20+
with handle_internal_errors():
21+
inner2()
22+
23+
24+
def using_try_except():
25+
try:
26+
inner2()
27+
except Exception:
28+
log_internal_error()
29+
30+
31+
def outer1(func: Any):
32+
func()
33+
34+
35+
def outer2(func: Any):
36+
outer1(func)
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from tests.import_used_for_tests.internal_error_handling.internal_logfire_code_example import (
2+
outer2,
3+
using_context_manager,
4+
using_decorator,
5+
using_try_except,
6+
)
7+
8+
9+
def user1():
10+
user2()
11+
12+
13+
def user2():
14+
user3()
15+
16+
17+
def user3():
18+
user4()
19+
20+
21+
def user4():
22+
user5()
23+
24+
25+
def user5():
26+
user6()
27+
28+
29+
def user6():
30+
outer2(using_decorator)
31+
outer2(using_context_manager)
32+
outer2(using_try_except)

tests/test_utils.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import pytest
22
import requests
33
import requests_mock
4+
from inline_snapshot import snapshot
45

6+
import logfire._internal.stack_info
57
from logfire._internal.utils import UnexpectedResponse, handle_internal_errors
8+
from tests.import_used_for_tests.internal_error_handling import internal_logfire_code_example, user_code_example
69

710

811
def test_raise_for_status() -> None:
@@ -21,3 +24,94 @@ def test_reraise_internal_exception():
2124
with pytest.raises(ZeroDivisionError):
2225
with handle_internal_errors():
2326
str(1 / 0)
27+
28+
29+
def test_internal_exception_tb(caplog: pytest.LogCaptureFixture):
30+
# Pretend that `internal_logfire_code_example` is a module within logfire,
31+
# so all frames from it should be included.
32+
logfire._internal.stack_info.NON_USER_CODE_PREFIXES += (internal_logfire_code_example.__file__,)
33+
34+
user_code_example.user1()
35+
36+
tracebacks = [
37+
r.exc_text.replace( # type: ignore
38+
user_code_example.__file__,
39+
'user_code_example.py',
40+
).replace(
41+
internal_logfire_code_example.__file__,
42+
'internal_logfire_code_example.py',
43+
)
44+
for r in caplog.records
45+
]
46+
47+
# Important notes about these tracebacks:
48+
# - They should look very similar to each other, regardless of how log_internal_error was called.
49+
# - They should include all frames from internal_logfire_code_example.py.
50+
# - They should include exactly 3 frames from user_code_example.py.
51+
# - They should look seamless, with each frame pointing to the next one.
52+
# - There should be no sign of logfire's internal error handling code.
53+
# - The two files should be isolated and stable so that the exact traceback contents can be asserted.
54+
assert tracebacks == snapshot(
55+
[
56+
"""\
57+
Traceback (most recent call last):
58+
File "user_code_example.py", line 22, in user4
59+
user5()
60+
File "user_code_example.py", line 26, in user5
61+
user6()
62+
File "user_code_example.py", line 30, in user6
63+
outer2(using_decorator)
64+
File "internal_logfire_code_example.py", line 36, in outer2
65+
outer1(func)
66+
File "internal_logfire_code_example.py", line 32, in outer1
67+
func()
68+
File "internal_logfire_code_example.py", line 16, in using_decorator
69+
inner2()
70+
File "internal_logfire_code_example.py", line 11, in inner2
71+
inner1()
72+
File "internal_logfire_code_example.py", line 7, in inner1
73+
raise ValueError('inner1')
74+
ValueError: inner1\
75+
""",
76+
"""\
77+
Traceback (most recent call last):
78+
File "user_code_example.py", line 22, in user4
79+
user5()
80+
File "user_code_example.py", line 26, in user5
81+
user6()
82+
File "user_code_example.py", line 31, in user6
83+
outer2(using_context_manager)
84+
File "internal_logfire_code_example.py", line 36, in outer2
85+
outer1(func)
86+
File "internal_logfire_code_example.py", line 32, in outer1
87+
func()
88+
File "internal_logfire_code_example.py", line 21, in using_context_manager
89+
inner2()
90+
File "internal_logfire_code_example.py", line 11, in inner2
91+
inner1()
92+
File "internal_logfire_code_example.py", line 7, in inner1
93+
raise ValueError('inner1')
94+
ValueError: inner1\
95+
""",
96+
"""\
97+
Traceback (most recent call last):
98+
File "user_code_example.py", line 22, in user4
99+
user5()
100+
File "user_code_example.py", line 26, in user5
101+
user6()
102+
File "user_code_example.py", line 32, in user6
103+
outer2(using_try_except)
104+
File "internal_logfire_code_example.py", line 36, in outer2
105+
outer1(func)
106+
File "internal_logfire_code_example.py", line 32, in outer1
107+
func()
108+
File "internal_logfire_code_example.py", line 26, in using_try_except
109+
inner2()
110+
File "internal_logfire_code_example.py", line 11, in inner2
111+
inner1()
112+
File "internal_logfire_code_example.py", line 7, in inner1
113+
raise ValueError('inner1')
114+
ValueError: inner1\
115+
""",
116+
]
117+
)

0 commit comments

Comments
 (0)