Skip to content

Commit ffde7b3

Browse files
authored
Add exception on console (#168)
1 parent 6b1f4af commit ffde7b3

File tree

2 files changed

+125
-1
lines changed

2 files changed

+125
-1
lines changed

logfire/_internal/exporters/console.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@
1010
import sys
1111
from collections.abc import Sequence
1212
from datetime import datetime, timezone
13+
from textwrap import indent as indent_text
1314
from typing import Any, List, Literal, Mapping, TextIO, Tuple, cast
1415

15-
from opentelemetry.sdk.trace import ReadableSpan
16+
from opentelemetry.sdk.trace import Event, ReadableSpan
1617
from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult
1718
from opentelemetry.util import types as otel_types
1819
from rich.columns import Columns
@@ -125,6 +126,9 @@ def _print_span(self, span: ReadableSpan, indent: int = 0):
125126
# in the rich case it uses syntax highlighting and columns for layout.
126127
self._print_arguments(span, indent_str)
127128

129+
exc_event = next((event for event in span.events or [] if event.name == 'exception'), None)
130+
self._print_exc_info(exc_event, indent_str)
131+
128132
def _span_text_parts(self, span: ReadableSpan, indent: int) -> tuple[str, TextParts]:
129133
"""Return the formatted message or span name and parts containing basic span information.
130134
@@ -255,6 +259,27 @@ def _print_arguments_plain(self, arguments: dict[str, Any], indent_str: str) ->
255259
out += [f'{prefix}{line}']
256260
print('\n'.join(out), file=self._output)
257261

262+
def _print_exc_info(self, exc_event: Event | None, indent_str: str) -> None:
263+
"""Print exception information if an exception event is present."""
264+
if exc_event is None or not exc_event.attributes:
265+
return
266+
267+
exc_type = cast(str, exc_event.attributes.get('exception.type'))
268+
exc_msg = cast(str, exc_event.attributes.get('exception.message'))
269+
exc_tb = cast(str, exc_event.attributes.get('exception.stacktrace'))
270+
271+
if self._console:
272+
barrier = Text(indent_str + '│ ', style='blue', end='')
273+
exc_type = Text(f'{exc_type}: ', end='', style='bold red')
274+
exc_msg = Text(exc_msg)
275+
indented_code = indent_text(exc_tb, indent_str + '│ ')
276+
exc_tb = Syntax(indented_code, 'python', background_color='default')
277+
self._console.print(Group(barrier, exc_type, exc_msg), exc_tb)
278+
else:
279+
out = [f'{indent_str}{exc_type}: {exc_msg}']
280+
out += [indent_text(exc_tb, indent_str + '│ ')]
281+
print('\n'.join(out), file=self._output)
282+
258283
def force_flush(self, timeout_millis: int = 0) -> bool: # pragma: no cover
259284
"""Force flush all spans, does nothing for this exporter."""
260285
return True

tests/test_console_exporter.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22
from __future__ import annotations
33

44
import io
5+
import sys
56

67
import pytest
8+
from dirty_equals import IsStr
79
from inline_snapshot import snapshot
810
from opentelemetry import trace
911
from opentelemetry.sdk.trace import ReadableSpan
@@ -689,3 +691,100 @@ def test_console_logging_to_stdout(capsys: pytest.CaptureFixture[str]):
689691
' outer span log message',
690692
]
691693
)
694+
695+
696+
def test_exception(exporter: TestExporter) -> None:
697+
try:
698+
1 / 0 # type: ignore
699+
except ZeroDivisionError:
700+
logfire.exception('error!!! {a}', a='test')
701+
702+
spans = exported_spans_as_models(exporter)
703+
assert spans == snapshot(
704+
[
705+
ReadableSpanModel(
706+
name='error!!! {a}',
707+
context=SpanContextModel(trace_id=1, span_id=1, is_remote=False),
708+
parent=None,
709+
start_time=1000000000,
710+
end_time=1000000000,
711+
attributes={
712+
'logfire.span_type': 'log',
713+
'logfire.level_num': 17,
714+
'logfire.msg_template': 'error!!! {a}',
715+
'logfire.msg': 'error!!! test',
716+
'code.filepath': 'test_console_exporter.py',
717+
'code.function': 'test_exception',
718+
'code.lineno': 123,
719+
'a': 'test',
720+
'logfire.json_schema': '{"type":"object","properties":{"a":{}}}',
721+
},
722+
events=[
723+
{
724+
'name': 'exception',
725+
'timestamp': 2000000000,
726+
'attributes': {
727+
'exception.type': 'ZeroDivisionError',
728+
'exception.message': 'division by zero',
729+
'exception.stacktrace': 'ZeroDivisionError: division by zero',
730+
'exception.escaped': 'False',
731+
},
732+
}
733+
],
734+
resource=None,
735+
)
736+
]
737+
)
738+
739+
issue_lines = (
740+
[' │ 1 / 0 # type: ignore', ' │ ~~^~~']
741+
if sys.version_info >= (3, 11)
742+
else [' │ 1 / 0 # type: ignore']
743+
)
744+
out = io.StringIO()
745+
SimpleConsoleSpanExporter(output=out, colors='never').export(exporter.exported_spans)
746+
assert out.getvalue().splitlines() == snapshot(
747+
[
748+
'00:00:01.000 error!!! test',
749+
' │ ZeroDivisionError: division by zero',
750+
' │ Traceback (most recent call last):',
751+
IsStr(regex=rf' │ File "{__file__}", line \d+, in test_exception'),
752+
*issue_lines,
753+
' │ ZeroDivisionError: division by zero',
754+
'',
755+
]
756+
)
757+
758+
issue_lines = (
759+
[
760+
'\x1b[97;49m \x1b[0m\x1b[35;49m│\x1b[0m\x1b[97;49m '
761+
'\x1b[0m\x1b[91;49m~\x1b[0m\x1b[91;49m~\x1b[0m\x1b[91;49m^\x1b[0m\x1b[91;49m~\x1b[0m\x1b[91;49m~\x1b[0m',
762+
]
763+
if sys.version_info >= (3, 11)
764+
else []
765+
)
766+
767+
out = io.StringIO()
768+
SimpleConsoleSpanExporter(output=out, colors='always').export(exporter.exported_spans)
769+
assert out.getvalue().splitlines() == [
770+
'\x1b[32m00:00:01.000\x1b[0m \x1b[31merror!!! test\x1b[0m',
771+
'\x1b[34m │ \x1b[0m\x1b[1;31mZeroDivisionError: ' '\x1b[0mdivision by zero',
772+
'\x1b[97;49m \x1b[0m\x1b[35;49m│\x1b[0m\x1b[97;49m '
773+
'\x1b[0m\x1b[97;49mTraceback\x1b[0m\x1b[97;49m '
774+
'\x1b[0m\x1b[97;49m(\x1b[0m\x1b[97;49mmost\x1b[0m\x1b[97;49m '
775+
'\x1b[0m\x1b[97;49mrecent\x1b[0m\x1b[97;49m '
776+
'\x1b[0m\x1b[97;49mcall\x1b[0m\x1b[97;49m '
777+
'\x1b[0m\x1b[97;49mlast\x1b[0m\x1b[97;49m)\x1b[0m\x1b[97;49m:\x1b[0m',
778+
IsStr(),
779+
'\x1b[97;49m \x1b[0m\x1b[35;49m│\x1b[0m\x1b[97;49m '
780+
'\x1b[0m\x1b[37;49m1\x1b[0m\x1b[97;49m '
781+
'\x1b[0m\x1b[91;49m/\x1b[0m\x1b[97;49m '
782+
'\x1b[0m\x1b[37;49m0\x1b[0m\x1b[97;49m \x1b[0m\x1b[37;49m# type: '
783+
'ignore\x1b[0m',
784+
*issue_lines,
785+
'\x1b[97;49m \x1b[0m\x1b[35;49m│\x1b[0m\x1b[97;49m '
786+
'\x1b[0m\x1b[92;49mZeroDivisionError\x1b[0m\x1b[97;49m:\x1b[0m\x1b[97;49m '
787+
'\x1b[0m\x1b[97;49mdivision\x1b[0m\x1b[97;49m '
788+
'\x1b[0m\x1b[97;49mby\x1b[0m\x1b[97;49m \x1b[0m\x1b[97;49mzero\x1b[0m',
789+
'',
790+
]

0 commit comments

Comments
 (0)