diff --git a/logfire/_internal/exporters/console.py b/logfire/_internal/exporters/console.py index 808fa22b4..69b9335c4 100644 --- a/logfire/_internal/exporters/console.py +++ b/logfire/_internal/exporters/console.py @@ -10,9 +10,10 @@ import sys from collections.abc import Sequence from datetime import datetime, timezone +from textwrap import indent as indent_text from typing import Any, List, Literal, Mapping, TextIO, Tuple, cast -from opentelemetry.sdk.trace import ReadableSpan +from opentelemetry.sdk.trace import Event, ReadableSpan from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult from opentelemetry.util import types as otel_types from rich.columns import Columns @@ -125,6 +126,9 @@ def _print_span(self, span: ReadableSpan, indent: int = 0): # in the rich case it uses syntax highlighting and columns for layout. self._print_arguments(span, indent_str) + exc_event = next((event for event in span.events or [] if event.name == 'exception'), None) + self._print_exc_info(exc_event, indent_str) + def _span_text_parts(self, span: ReadableSpan, indent: int) -> tuple[str, TextParts]: """Return the formatted message or span name and parts containing basic span information. @@ -255,6 +259,27 @@ def _print_arguments_plain(self, arguments: dict[str, Any], indent_str: str) -> out += [f'{prefix}{line}'] print('\n'.join(out), file=self._output) + def _print_exc_info(self, exc_event: Event | None, indent_str: str) -> None: + """Print exception information if an exception event is present.""" + if exc_event is None or not exc_event.attributes: + return + + exc_type = cast(str, exc_event.attributes.get('exception.type')) + exc_msg = cast(str, exc_event.attributes.get('exception.message')) + exc_tb = cast(str, exc_event.attributes.get('exception.stacktrace')) + + if self._console: + barrier = Text(indent_str + '│ ', style='blue', end='') + exc_type = Text(f'{exc_type}: ', end='', style='bold red') + exc_msg = Text(exc_msg) + indented_code = indent_text(exc_tb, indent_str + '│ ') + exc_tb = Syntax(indented_code, 'python', background_color='default') + self._console.print(Group(barrier, exc_type, exc_msg), exc_tb) + else: + out = [f'{indent_str}│ {exc_type}: {exc_msg}'] + out += [indent_text(exc_tb, indent_str + '│ ')] + print('\n'.join(out), file=self._output) + def force_flush(self, timeout_millis: int = 0) -> bool: # pragma: no cover """Force flush all spans, does nothing for this exporter.""" return True diff --git a/tests/test_console_exporter.py b/tests/test_console_exporter.py index ee9f2a96d..0808d9bab 100644 --- a/tests/test_console_exporter.py +++ b/tests/test_console_exporter.py @@ -2,8 +2,10 @@ from __future__ import annotations import io +import sys import pytest +from dirty_equals import IsStr from inline_snapshot import snapshot from opentelemetry import trace from opentelemetry.sdk.trace import ReadableSpan @@ -689,3 +691,100 @@ def test_console_logging_to_stdout(capsys: pytest.CaptureFixture[str]): ' outer span log message', ] ) + + +def test_exception(exporter: TestExporter) -> None: + try: + 1 / 0 # type: ignore + except ZeroDivisionError: + logfire.exception('error!!! {a}', a='test') + + spans = exported_spans_as_models(exporter) + assert spans == snapshot( + [ + ReadableSpanModel( + name='error!!! {a}', + context=SpanContextModel(trace_id=1, span_id=1, is_remote=False), + parent=None, + start_time=1000000000, + end_time=1000000000, + attributes={ + 'logfire.span_type': 'log', + 'logfire.level_num': 17, + 'logfire.msg_template': 'error!!! {a}', + 'logfire.msg': 'error!!! test', + 'code.filepath': 'test_console_exporter.py', + 'code.function': 'test_exception', + 'code.lineno': 123, + 'a': 'test', + 'logfire.json_schema': '{"type":"object","properties":{"a":{}}}', + }, + events=[ + { + 'name': 'exception', + 'timestamp': 2000000000, + 'attributes': { + 'exception.type': 'ZeroDivisionError', + 'exception.message': 'division by zero', + 'exception.stacktrace': 'ZeroDivisionError: division by zero', + 'exception.escaped': 'False', + }, + } + ], + resource=None, + ) + ] + ) + + issue_lines = ( + [' │ 1 / 0 # type: ignore', ' │ ~~^~~'] + if sys.version_info >= (3, 11) + else [' │ 1 / 0 # type: ignore'] + ) + out = io.StringIO() + SimpleConsoleSpanExporter(output=out, colors='never').export(exporter.exported_spans) + assert out.getvalue().splitlines() == snapshot( + [ + '00:00:01.000 error!!! test', + ' │ ZeroDivisionError: division by zero', + ' │ Traceback (most recent call last):', + IsStr(regex=rf' │ File "{__file__}", line \d+, in test_exception'), + *issue_lines, + ' │ ZeroDivisionError: division by zero', + '', + ] + ) + + issue_lines = ( + [ + '\x1b[97;49m \x1b[0m\x1b[35;49m│\x1b[0m\x1b[97;49m ' + '\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', + ] + if sys.version_info >= (3, 11) + else [] + ) + + out = io.StringIO() + SimpleConsoleSpanExporter(output=out, colors='always').export(exporter.exported_spans) + assert out.getvalue().splitlines() == [ + '\x1b[32m00:00:01.000\x1b[0m \x1b[31merror!!! test\x1b[0m', + '\x1b[34m │ \x1b[0m\x1b[1;31mZeroDivisionError: ' '\x1b[0mdivision by zero', + '\x1b[97;49m \x1b[0m\x1b[35;49m│\x1b[0m\x1b[97;49m ' + '\x1b[0m\x1b[97;49mTraceback\x1b[0m\x1b[97;49m ' + '\x1b[0m\x1b[97;49m(\x1b[0m\x1b[97;49mmost\x1b[0m\x1b[97;49m ' + '\x1b[0m\x1b[97;49mrecent\x1b[0m\x1b[97;49m ' + '\x1b[0m\x1b[97;49mcall\x1b[0m\x1b[97;49m ' + '\x1b[0m\x1b[97;49mlast\x1b[0m\x1b[97;49m)\x1b[0m\x1b[97;49m:\x1b[0m', + IsStr(), + '\x1b[97;49m \x1b[0m\x1b[35;49m│\x1b[0m\x1b[97;49m ' + '\x1b[0m\x1b[37;49m1\x1b[0m\x1b[97;49m ' + '\x1b[0m\x1b[91;49m/\x1b[0m\x1b[97;49m ' + '\x1b[0m\x1b[37;49m0\x1b[0m\x1b[97;49m \x1b[0m\x1b[37;49m# type: ' + 'ignore\x1b[0m', + *issue_lines, + '\x1b[97;49m \x1b[0m\x1b[35;49m│\x1b[0m\x1b[97;49m ' + '\x1b[0m\x1b[92;49mZeroDivisionError\x1b[0m\x1b[97;49m:\x1b[0m\x1b[97;49m ' + '\x1b[0m\x1b[97;49mdivision\x1b[0m\x1b[97;49m ' + '\x1b[0m\x1b[97;49mby\x1b[0m\x1b[97;49m \x1b[0m\x1b[97;49mzero\x1b[0m', + '', + ]