From 551c8674d16ee051414c655d50e2db8607802831 Mon Sep 17 00:00:00 2001 From: Alex Hall Date: Tue, 26 Aug 2025 13:19:25 +0200 Subject: [PATCH 01/14] ExceptionCallback --- logfire/_internal/main.py | 27 +-- logfire/_internal/tracer.py | 28 ++- logfire/sampling/_tail_sampling.py | 55 +---- logfire/types.py | 219 ++++++++++++++++++ tests/otel_integrations/test_openai_agents.py | 14 +- tests/test_logfire.py | 14 +- 6 files changed, 270 insertions(+), 87 deletions(-) create mode 100644 logfire/types.py diff --git a/logfire/_internal/main.py b/logfire/_internal/main.py index 102036960..2bea5bb84 100644 --- a/logfire/_internal/main.py +++ b/logfire/_internal/main.py @@ -26,7 +26,7 @@ from opentelemetry.context import Context from opentelemetry.metrics import CallbackT, Counter, Histogram, UpDownCounter from opentelemetry.sdk.trace import ReadableSpan, Span -from opentelemetry.trace import SpanContext, Tracer +from opentelemetry.trace import SpanContext from opentelemetry.util import types as otel_types from typing_extensions import LiteralString, ParamSpec @@ -60,7 +60,12 @@ ) from .metrics import ProxyMeterProvider from .stack_info import get_user_stack_info -from .tracer import ProxyTracerProvider, _LogfireWrappedSpan, record_exception, set_exception_status # type: ignore +from .tracer import ( + ProxyTracerProvider, + _LogfireWrappedSpan, # type: ignore + _ProxyTracer, # type: ignore + set_exception_status, +) from .utils import get_version, handle_internal_errors, log_internal_error, uniquify_sequence if TYPE_CHECKING: @@ -153,14 +158,14 @@ def _meter(self): return self._meter_provider.get_meter(self._otel_scope, VERSION) @cached_property - def _logs_tracer(self) -> Tracer: + def _logs_tracer(self) -> _ProxyTracer: return self._get_tracer(is_span_tracer=False) @cached_property - def _spans_tracer(self) -> Tracer: + def _spans_tracer(self) -> _ProxyTracer: return self._get_tracer(is_span_tracer=True) - def _get_tracer(self, *, is_span_tracer: bool) -> Tracer: # pragma: no cover + def _get_tracer(self, *, is_span_tracer: bool) -> _ProxyTracer: return self._tracer_provider.get_tracer( self._otel_scope, VERSION, @@ -755,7 +760,7 @@ def log( if isinstance(exc_info, tuple): exc_info = exc_info[1] if isinstance(exc_info, BaseException): - record_exception(span, exc_info) + span.record_exception(exc_info) if otlp_attributes[ATTRIBUTES_LOG_LEVEL_NUM_KEY] >= LEVEL_NUMBERS['error']: # type: ignore # Set the status description to the exception message. # OTEL only lets us set the description when the status code is ERROR, @@ -2263,7 +2268,7 @@ def __init__( self, span_name: str, otlp_attributes: dict[str, otel_types.AttributeValue], - tracer: Tracer, + tracer: _ProxyTracer, json_schema_properties: JsonSchemaProperties, links: Sequence[tuple[SpanContext, otel_types.Attributes]], ) -> None: @@ -2275,7 +2280,7 @@ def __init__( self._added_attributes = False self._token: None | Token[Context] = None - self._span: None | trace_api.Span = None + self._span: None | _LogfireWrappedSpan = None if not TYPE_CHECKING: # pragma: no branch @@ -2393,11 +2398,7 @@ def record_exception( if not self._span.is_recording(): return - span = self._span - while isinstance(span, _LogfireWrappedSpan): - span = span.span - record_exception( - span, + self._span.record_exception( exception, attributes=attributes, timestamp=timestamp, diff --git a/logfire/_internal/tracer.py b/logfire/_internal/tracer.py index 7372f97be..c9c4a50c0 100644 --- a/logfire/_internal/tracer.py +++ b/logfire/_internal/tracer.py @@ -16,6 +16,7 @@ from opentelemetry.sdk.resources import Resource from opentelemetry.sdk.trace import ( ReadableSpan, + Span as SDKSpan, SpanProcessor, Tracer as SDKTracer, TracerProvider as SDKTracerProvider, @@ -35,9 +36,10 @@ ATTRIBUTES_VALIDATION_ERROR_KEY, log_level_attributes, ) -from .utils import canonicalize_exception_traceback, handle_internal_errors, sha256_string +from .utils import handle_internal_errors, sha256_string if TYPE_CHECKING: + from ..types import ExceptionCallback from .config import LogfireConfig try: @@ -210,7 +212,7 @@ def increment_metric(self, name: str, attributes: Mapping[str, otel_types.Attrib return self.metrics[name].increment(attributes, value) - if self.parent and (parent := OPEN_SPANS.get(_open_spans_key(self.parent))): + if parent := get_parent_span(self): parent.increment_metric(name, attributes, value) def __exit__(self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: Any) -> None: @@ -225,6 +227,10 @@ def __getattr__(self, name: str) -> Any: return getattr(self.span, name) +def get_parent_span(span: ReadableSpan) -> _LogfireWrappedSpan | None: + return span.parent and OPEN_SPANS.get(_open_spans_key(span.parent)) + + def _open_spans_key(ctx: SpanContext) -> tuple[int, int]: return ctx.trace_id, ctx.span_id @@ -257,7 +263,7 @@ def start_span( start_time: int | None = None, record_exception: bool = True, set_status_on_exception: bool = True, - ) -> Span: + ) -> _LogfireWrappedSpan: config = self.provider.config ns_timestamp_generator = config.advanced.ns_timestamp_generator record_metrics: bool = not isinstance(config.metrics, (bool, type(None))) and config.metrics.collect_in_spans @@ -399,8 +405,11 @@ def record_exception( attributes: otel_types.Attributes = None, timestamp: int | None = None, escaped: bool = False, + callback: ExceptionCallback | None = None, ) -> None: """Similar to the OTEL SDK Span.record_exception method, with our own additions.""" + from ..types import ExceptionCallbackHelper + if is_starlette_http_exception_400(exception): span.set_attributes(log_level_attributes('warn')) @@ -412,6 +421,15 @@ def record_exception( set_exception_status(span, exception) span.set_attributes(log_level_attributes('error')) + helper = ExceptionCallbackHelper(span=cast(SDKSpan, span), exception=exception) + + if callback is not None: + with handle_internal_errors: + callback(helper) + + if not helper._record_exception: # type: ignore + return + attributes = {**(attributes or {})} if ValidationError is not None and isinstance(exception, ValidationError): # insert a more detailed breakdown of pydantic errors @@ -430,7 +448,9 @@ def record_exception( stacktrace = ''.join(traceback.format_exception(type(exception), exception, exception.__traceback__)) attributes['exception.stacktrace'] = stacktrace - span.set_attribute(ATTRIBUTES_EXCEPTION_FINGERPRINT_KEY, sha256_string(canonicalize_exception_traceback(exception))) + if helper.create_issue: + span.set_attribute(ATTRIBUTES_EXCEPTION_FINGERPRINT_KEY, sha256_string(helper.issue_fingerprint_source)) + span.record_exception(exception, attributes=attributes, timestamp=timestamp, escaped=escaped) diff --git a/logfire/sampling/_tail_sampling.py b/logfire/sampling/_tail_sampling.py index 821570952..4f12abccf 100644 --- a/logfire/sampling/_tail_sampling.py +++ b/logfire/sampling/_tail_sampling.py @@ -11,58 +11,11 @@ from typing_extensions import Self from logfire._internal.constants import ( - ATTRIBUTES_LOG_LEVEL_NUM_KEY, - LEVEL_NUMBERS, - NUMBER_TO_LEVEL, ONE_SECOND_IN_NANOSECONDS, LevelName, ) from logfire._internal.exporters.wrapper import WrapperSpanProcessor - - -@dataclass -class SpanLevel: - """A convenience class for comparing span/log levels. - - Can be compared to log level names (strings) such as 'info' or 'error' using - `<`, `>`, `<=`, or `>=`, so e.g. `level >= 'error'` is valid. - - Will raise an exception if compared to a non-string or an invalid level name. - """ - - number: int - """ - The raw numeric value of the level. Higher values are more severe. - """ - - @property - def name(self) -> LevelName | None: - """The human-readable name of the level, or `None` if the number is invalid.""" - return NUMBER_TO_LEVEL.get(self.number) - - def __eq__(self, other: object): - if isinstance(other, int): - return self.number == other - if isinstance(other, str): - return self.name == other - if isinstance(other, SpanLevel): - return self.number == other.number - return NotImplemented - - def __hash__(self): - return hash(self.number) - - def __lt__(self, other: LevelName): - return self.number < LEVEL_NUMBERS[other] - - def __gt__(self, other: LevelName): - return self.number > LEVEL_NUMBERS[other] - - def __ge__(self, other: LevelName): - return self.number >= LEVEL_NUMBERS[other] - - def __le__(self, other: LevelName): - return self.number <= LEVEL_NUMBERS[other] +from logfire.types import SpanLevel @dataclass @@ -111,11 +64,7 @@ class TailSamplingSpanInfo: @property def level(self) -> SpanLevel: """The log level of the span.""" - attributes = self.span.attributes or {} - level = attributes.get(ATTRIBUTES_LOG_LEVEL_NUM_KEY) - if not isinstance(level, int): - level = LEVEL_NUMBERS['info'] - return SpanLevel(level) + return SpanLevel.from_span(self.span) @property def duration(self) -> float: diff --git a/logfire/types.py b/logfire/types.py new file mode 100644 index 000000000..c2629baff --- /dev/null +++ b/logfire/types.py @@ -0,0 +1,219 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Callable + +from logfire._internal.constants import ( + ATTRIBUTES_LOG_LEVEL_NUM_KEY, + LEVEL_NUMBERS, + NUMBER_TO_LEVEL, + LevelName, + log_level_attributes, +) +from logfire._internal.tracer import get_parent_span +from logfire._internal.utils import canonicalize_exception_traceback + +if TYPE_CHECKING: + from opentelemetry.sdk.trace import ReadableSpan, Span + + +@dataclass +class SpanLevel: + """A convenience class for comparing span/log levels. + + Can be compared to log level names (strings) such as 'info' or 'error' using + `<`, `>`, `<=`, or `>=`, so e.g. `level >= 'error'` is valid. + + Will raise an exception if compared to a non-string or an invalid level name. + """ + + number: int + """ + The raw numeric value of the level. Higher values are more severe. + """ + + @classmethod + def from_span(cls, span: ReadableSpan) -> SpanLevel: + """Create a SpanLevel from an OpenTelemetry span. + + If the span has no level set, defaults to 'info'. + """ + attributes = span.attributes or {} + level = attributes.get(ATTRIBUTES_LOG_LEVEL_NUM_KEY) + if not isinstance(level, int): + level = LEVEL_NUMBERS['info'] + return cls(level) + + @property + def name(self) -> LevelName | None: + """The human-readable name of the level, or `None` if the number is invalid.""" + return NUMBER_TO_LEVEL.get(self.number) + + def __eq__(self, other: object): + if isinstance(other, int): + return self.number == other + if isinstance(other, str): + return self.name == other + if isinstance(other, SpanLevel): + return self.number == other.number + return NotImplemented + + def __hash__(self): + return hash(self.number) + + def __lt__(self, other: LevelName): + return self.number < LEVEL_NUMBERS[other] + + def __gt__(self, other: LevelName): + return self.number > LEVEL_NUMBERS[other] + + def __ge__(self, other: LevelName): + return self.number >= LEVEL_NUMBERS[other] + + def __le__(self, other: LevelName): + return self.number <= LEVEL_NUMBERS[other] + + +@dataclass +class ExceptionCallbackHelper: + """TODO.""" + + span: Span + exception: BaseException + _issue_fingerprint_source: str | None = None + _create_issue: bool | None = None + _record_exception: bool = True + + @property + def level(self) -> SpanLevel: + """Convenient way to see and compare the level of the span. + + Usually the level is error. + FastAPI/Starlette 4xx HTTPExceptions are warnings. + Will be a different level if this is created by e.g. `logfire.info(..., _exc_info=True)`. + + Returns a https://logfire.pydantic.dev/docs/reference/api/sampling/#logfire.sampling.SpanLevel + """ + return SpanLevel.from_span(self.span) + + @level.setter + def level(self, value: LevelName | int) -> None: + """Override the level of the span. + + For example: + + helper.level = 'warning' + """ + self.span.set_attributes(log_level_attributes(value)) + + @property + def parent_span(self) -> ReadableSpan | None: + """The parent span of the span the exception was recorded on. + + This is `None` if there is no parent span, or if the parent span is in a different process. + """ + return get_parent_span(self.span) + + @property + def issue_fingerprint_source(self) -> str: + """Returns a string that will be hashed to create the issue fingerprint. + + By default this is a canonical representation of the exception traceback. + """ + if self._issue_fingerprint_source is not None: + return self._issue_fingerprint_source + self._issue_fingerprint_source = canonicalize_exception_traceback(self.exception) + return self._issue_fingerprint_source + + @issue_fingerprint_source.setter + def issue_fingerprint_source(self, value: str): + """Override the string that will be hashed to create the issue fingerprint. + + For example, if you want all exceptions of a certain type to be grouped into the same issue, + you could do something like: + + if isinstance(helper.exception, MyCustomError): + helper.issue_fingerprint_source = "MyCustomError" + + Or if you want to add the exception message to make grouping more granular: + + helper.issue_fingerprint_source += str(helper.exception) + + Note that setting this property automatically sets `create_issue` to True. + """ + self._issue_fingerprint_source = value + self._create_issue = True + + @property + def create_issue(self) -> bool: + """Whether to create an issue for this exception. + + By default, issues are only created for exceptions on spans with level 'error' or higher, + and for which no parent span exists in the current process. + + Example: + if helper.create_issue: + helper.issue_fingerprint_source = "MyCustomError" + """ + if self._create_issue is not None: + return self._create_issue + + return self._record_exception and self.level >= 'error' and self.parent_span is None + + @create_issue.setter + def create_issue(self, value: bool): + """Override whether to create an issue for this exception. + + For example, if you want to create issues for all exceptions, even warnings: + + helper.create_issue = True + + Issues can only be created if the exception is recorded on the span. + """ + if not self._record_exception and value: + raise ValueError('Cannot create issue if exception is not recorded on the span.') + self._create_issue = value + + def no_record_exception(self) -> None: + """Call this method to prevent recording the exception on the span. + + This improves performance and reduces noise in Logfire. + This will also prevent creating an issue for this exception. + The span itself will still be recorded, just without the exception information. + This doesn't affect the level of the span, it will still be 'error' by default. + """ + self._record_exception = False + self._create_issue = False + + +ExceptionCallback = Callable[[ExceptionCallbackHelper], None] +""" + def my_callback(helper: logfire.ExceptionCallbackHelper): + ... + + logfire.configure(advanced=logfire.AdvancedOptions(exception_callback=my_callback)) + +Examples: + +Set the level: + + helper.level = 'warning' + +Make the issue fingerprint less granular: + + if isinstance(helper.exception, MyCustomError): + helper.issue_fingerprint_source = "MyCustomError" + +Make the issue fingerprint more granular: + + if helper.create_issue: + helper.issue_fingerprint_source += str(helper.exception) + +Create issues for all exceptions, even warnings: + + helper.create_issue = True + +Don't record the exception on the span: + + helper.no_record_exception() +""" diff --git a/tests/otel_integrations/test_openai_agents.py b/tests/otel_integrations/test_openai_agents.py index 822711273..fc376f240 100644 --- a/tests/otel_integrations/test_openai_agents.py +++ b/tests/otel_integrations/test_openai_agents.py @@ -35,7 +35,7 @@ from agents.tracing.spans import NoOpSpan from agents.tracing.traces import NoOpTrace from agents.voice import AudioInput, SingleAgentVoiceWorkflow, VoicePipeline -from dirty_equals import IsInt, IsPartialDict, IsStr +from dirty_equals import IsPartialDict, IsStr from inline_snapshot import snapshot from openai import AsyncOpenAI @@ -1540,7 +1540,7 @@ def tool(): 'context': {'trace_id': 1, 'span_id': 7, 'is_remote': False}, 'parent': {'trace_id': 1, 'span_id': 3, 'is_remote': False}, 'start_time': 5000000000, - 'end_time': 6000000000, + 'end_time': 7000000000, 'attributes': { 'logfire.msg_template': 'Function: {name}', 'logfire.span_type': 'span', @@ -1559,7 +1559,7 @@ def tool(): 'events': [ { 'name': 'exception', - 'timestamp': IsInt(), + 'timestamp': 6000000000, 'attributes': { 'exception.type': 'RuntimeError', 'exception.message': "Ouch, don't do that again!", @@ -1573,8 +1573,8 @@ def tool(): 'name': 'Responses API with {gen_ai.request.model!r}', 'context': {'trace_id': 1, 'span_id': 9, 'is_remote': False}, 'parent': {'trace_id': 1, 'span_id': 3, 'is_remote': False}, - 'start_time': 7000000000, - 'end_time': 8000000000, + 'start_time': 8000000000, + 'end_time': 9000000000, 'attributes': { 'code.filepath': 'test_openai_agents.py', 'code.function': 'test_function_tool_exception', @@ -1638,7 +1638,7 @@ def tool(): 'context': {'trace_id': 1, 'span_id': 3, 'is_remote': False}, 'parent': {'trace_id': 1, 'span_id': 1, 'is_remote': False}, 'start_time': 2000000000, - 'end_time': 9000000000, + 'end_time': 10000000000, 'attributes': { 'code.filepath': 'test_openai_agents.py', 'code.function': 'test_function_tool_exception', @@ -1658,7 +1658,7 @@ def tool(): 'context': {'trace_id': 1, 'span_id': 1, 'is_remote': False}, 'parent': None, 'start_time': 1000000000, - 'end_time': 10000000000, + 'end_time': 11000000000, 'attributes': { 'code.filepath': 'test_openai_agents.py', 'code.function': 'test_function_tool_exception', diff --git a/tests/test_logfire.py b/tests/test_logfire.py index eeefacb3c..06d90ea64 100644 --- a/tests/test_logfire.py +++ b/tests/test_logfire.py @@ -3202,10 +3202,7 @@ def patched_record_exception(*args: Any, **kwargs: Any) -> Any: return record_exception(*args, **kwargs) - with ( - patch('logfire._internal.tracer.record_exception', patched_record_exception), - patch('logfire._internal.main.record_exception', patched_record_exception), - ): + with patch('logfire._internal.tracer.record_exception', patched_record_exception): with pytest.raises(RuntimeError): with logfire.span('foo'): raise RuntimeError('error') @@ -3254,10 +3251,7 @@ def patched_record_exception(*args: Any, **kwargs: Any) -> Any: return record_exception(*args, **kwargs) - with ( - patch('logfire._internal.tracer.record_exception', patched_record_exception), - patch('logfire._internal.main.record_exception', patched_record_exception), - ): + with patch('logfire._internal.tracer.record_exception', patched_record_exception): with logfire.span('foo') as span: span.record_exception(RuntimeError('error')) @@ -3269,7 +3263,7 @@ def patched_record_exception(*args: Any, **kwargs: Any) -> Any: 'context': {'trace_id': 1, 'span_id': 1, 'is_remote': False}, 'parent': None, 'start_time': 1000000000, - 'end_time': 2000000000, + 'end_time': 3000000000, 'attributes': { 'code.filepath': 'test_logfire.py', 'code.function': 'test_logfire_span_records_exceptions_manually_once', @@ -3281,7 +3275,7 @@ def patched_record_exception(*args: Any, **kwargs: Any) -> Any: 'events': [ { 'name': 'exception', - 'timestamp': IsInt(), + 'timestamp': 2000000000, 'attributes': { 'exception.type': 'RuntimeError', 'exception.message': 'error', From 383fe7f73faac5f0afed551871b800ea893024ec Mon Sep 17 00:00:00 2001 From: Alex Hall Date: Tue, 26 Aug 2025 13:45:06 +0200 Subject: [PATCH 02/14] config --- logfire/_internal/config.py | 6 ++ logfire/_internal/tracer.py | 12 +++- ...calize_exception.py => test_exceptions.py} | 66 +++++++++++++++++++ 3 files changed, 83 insertions(+), 1 deletion(-) rename tests/{test_canonicalize_exception.py => test_exceptions.py} (70%) diff --git a/logfire/_internal/config.py b/logfire/_internal/config.py index 38319d488..0aaef7bce 100644 --- a/logfire/_internal/config.py +++ b/logfire/_internal/config.py @@ -66,6 +66,7 @@ from logfire.version import VERSION from ..propagate import NoExtractTraceContextPropagator, WarnOnExtractTraceContextPropagator +from ..types import ExceptionCallback from .client import InvalidProjectName, LogfireClient, ProjectAlreadyExists from .config_params import ParamManager, PydanticPluginRecordValues from .constants import ( @@ -182,6 +183,11 @@ class AdvancedOptions: log_record_processors: Sequence[LogRecordProcessor] = () """Configuration for OpenTelemetry logging. This is experimental and may be removed.""" + exception_callback: ExceptionCallback | None = None + """Callback function that is called when an exception is recorded on a span. + + This is experimental and may be removed.""" + def generate_base_url(self, token: str) -> str: if self.base_url is not None: return self.base_url diff --git a/logfire/_internal/tracer.py b/logfire/_internal/tracer.py index c9c4a50c0..9ba40badf 100644 --- a/logfire/_internal/tracer.py +++ b/logfire/_internal/tracer.py @@ -148,6 +148,7 @@ class _LogfireWrappedSpan(trace_api.Span, ReadableSpan): ns_timestamp_generator: Callable[[], int] record_metrics: bool metrics: dict[str, SpanMetric] = field(default_factory=lambda: defaultdict(SpanMetric)) + exception_callback: ExceptionCallback | None = None def __post_init__(self): OPEN_SPANS[self._open_spans_key()] = self @@ -205,7 +206,14 @@ def record_exception( escaped: bool = False, ) -> None: timestamp = timestamp or self.ns_timestamp_generator() - record_exception(self.span, exception, attributes=attributes, timestamp=timestamp, escaped=escaped) + record_exception( + self.span, + exception, + attributes=attributes, + timestamp=timestamp, + escaped=escaped, + callback=self.exception_callback, + ) def increment_metric(self, name: str, attributes: Mapping[str, otel_types.AttributeValue], value: float) -> None: if not self.is_recording() or not self.record_metrics: @@ -267,6 +275,7 @@ def start_span( config = self.provider.config ns_timestamp_generator = config.advanced.ns_timestamp_generator record_metrics: bool = not isinstance(config.metrics, (bool, type(None))) and config.metrics.collect_in_spans + exception_callback = config.advanced.exception_callback start_time = start_time or ns_timestamp_generator() @@ -295,6 +304,7 @@ def start_span( span, ns_timestamp_generator=ns_timestamp_generator, record_metrics=record_metrics, + exception_callback=exception_callback, ) # This means that `with start_as_current_span(...):` diff --git a/tests/test_canonicalize_exception.py b/tests/test_exceptions.py similarity index 70% rename from tests/test_canonicalize_exception.py rename to tests/test_exceptions.py index e118e9f44..c877136d8 100644 --- a/tests/test_canonicalize_exception.py +++ b/tests/test_exceptions.py @@ -1,4 +1,5 @@ import sys +from typing import Any import pytest from inline_snapshot import snapshot @@ -7,6 +8,7 @@ from logfire._internal.constants import ATTRIBUTES_EXCEPTION_FINGERPRINT_KEY from logfire._internal.exporters.test import TestExporter from logfire._internal.utils import canonicalize_exception_traceback, sha256_string +from logfire.types import ExceptionCallbackHelper def test_canonicalize_exception_func(): @@ -242,3 +244,67 @@ def test_cyclic_exception_group(): """) + + +def test_exception_callback_set_level(exporter: TestExporter, config_kwargs: dict[str, Any]): + def exception_callback(helper: ExceptionCallbackHelper) -> None: + assert helper.level.name == 'error' + assert helper.create_issue + helper.level = 'warning' + assert helper.level.name == 'warn' + assert not helper.create_issue + assert helper.parent_span is None + assert isinstance(helper.exception, ValueError) + assert helper.issue_fingerprint_source == canonicalize_exception_traceback(helper.exception) + helper.span.set_attribute('original_fingerprint', helper.issue_fingerprint_source) + + config_kwargs['advanced'].exception_callback = exception_callback + logfire.configure(**config_kwargs) + + with pytest.raises(ValueError): + with logfire.span('foo'): + raise ValueError('test') + + (_pending, span) = exporter.exported_spans + assert span.attributes + assert ATTRIBUTES_EXCEPTION_FINGERPRINT_KEY not in span.attributes + + assert exporter.exported_spans_as_dict() == snapshot( + [ + { + 'name': 'foo', + 'context': {'trace_id': 1, 'span_id': 1, 'is_remote': False}, + 'parent': None, + 'start_time': 1000000000, + 'end_time': 3000000000, + 'attributes': { + 'code.filepath': 'test_exceptions.py', + 'code.function': 'test_exception_callback_set_level', + 'code.lineno': 123, + 'logfire.msg_template': 'foo', + 'logfire.msg': 'foo', + 'logfire.span_type': 'span', + 'logfire.level_num': 13, + 'original_fingerprint': """\ + +builtins.ValueError +---- +tests.test_exceptions.test_exception_callback_set_level + raise ValueError('test')\ +""", + }, + 'events': [ + { + 'name': 'exception', + 'timestamp': 2000000000, + 'attributes': { + 'exception.type': 'ValueError', + 'exception.message': 'test', + 'exception.stacktrace': 'ValueError: test', + 'exception.escaped': 'True', + }, + } + ], + } + ] + ) From 9ef703900576b098cb9ac4d7244e13dd9c9fabf2 Mon Sep 17 00:00:00 2001 From: Alex Hall Date: Tue, 26 Aug 2025 13:49:10 +0200 Subject: [PATCH 03/14] test_exception_nested_span --- tests/test_exceptions.py | 73 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index c877136d8..b2ae74e17 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -308,3 +308,76 @@ def exception_callback(helper: ExceptionCallbackHelper) -> None: } ] ) + + +def test_exception_nested_span(exporter: TestExporter, config_kwargs: dict[str, Any]): + def exception_callback(helper: ExceptionCallbackHelper) -> None: + assert helper.span.name == 'inner' + assert helper.parent_span + assert helper.parent_span.name == 'outer' + assert not helper.create_issue + helper.create_issue = True + assert helper.create_issue + + config_kwargs['advanced'].exception_callback = exception_callback + logfire.configure(**config_kwargs) + + with logfire.span('outer'): + with pytest.raises(ValueError): + with logfire.span('inner'): + raise ValueError('test') + + span = exporter.exported_spans[2] + assert span.name == 'inner' + assert span.attributes + assert span.attributes[ATTRIBUTES_EXCEPTION_FINGERPRINT_KEY] == snapshot( + '2d233734d60da1a16e3627ba78180e4f83a9588ab6bd365283331a1339d56072' + ) + + assert exporter.exported_spans_as_dict() == snapshot( + [ + { + 'name': 'inner', + 'context': {'trace_id': 1, 'span_id': 3, 'is_remote': False}, + 'parent': {'trace_id': 1, 'span_id': 1, 'is_remote': False}, + 'start_time': 2000000000, + 'end_time': 4000000000, + 'attributes': { + 'code.filepath': 'test_exceptions.py', + 'code.function': 'test_exception_nested_span', + 'code.lineno': 123, + 'logfire.msg_template': 'inner', + 'logfire.msg': 'inner', + 'logfire.span_type': 'span', + 'logfire.level_num': 17, + }, + 'events': [ + { + 'name': 'exception', + 'timestamp': 3000000000, + 'attributes': { + 'exception.type': 'ValueError', + 'exception.message': 'test', + 'exception.stacktrace': 'ValueError: test', + 'exception.escaped': 'True', + }, + } + ], + }, + { + 'name': 'outer', + 'context': {'trace_id': 1, 'span_id': 1, 'is_remote': False}, + 'parent': None, + 'start_time': 1000000000, + 'end_time': 5000000000, + 'attributes': { + 'code.filepath': 'test_exceptions.py', + 'code.function': 'test_exception_nested_span', + 'code.lineno': 123, + 'logfire.msg_template': 'outer', + 'logfire.msg': 'outer', + 'logfire.span_type': 'span', + }, + }, + ] + ) From abf5a6ba08203553e99c2846eed89a79dfc81e17 Mon Sep 17 00:00:00 2001 From: Alex Hall Date: Tue, 26 Aug 2025 13:59:53 +0200 Subject: [PATCH 04/14] separate tests --- logfire/types.py | 2 +- tests/test_canonicalize_exception.py | 244 +++++++++++++++++++ tests/test_exceptions.py | 344 +++++++++------------------ 3 files changed, 352 insertions(+), 238 deletions(-) create mode 100644 tests/test_canonicalize_exception.py diff --git a/logfire/types.py b/logfire/types.py index c2629baff..ca9064321 100644 --- a/logfire/types.py +++ b/logfire/types.py @@ -142,7 +142,7 @@ def issue_fingerprint_source(self, value: str): Note that setting this property automatically sets `create_issue` to True. """ self._issue_fingerprint_source = value - self._create_issue = True + self.create_issue = True @property def create_issue(self) -> bool: diff --git a/tests/test_canonicalize_exception.py b/tests/test_canonicalize_exception.py new file mode 100644 index 000000000..e118e9f44 --- /dev/null +++ b/tests/test_canonicalize_exception.py @@ -0,0 +1,244 @@ +import sys + +import pytest +from inline_snapshot import snapshot + +import logfire +from logfire._internal.constants import ATTRIBUTES_EXCEPTION_FINGERPRINT_KEY +from logfire._internal.exporters.test import TestExporter +from logfire._internal.utils import canonicalize_exception_traceback, sha256_string + + +def test_canonicalize_exception_func(): + def foo(): + bar() + + def bar(): + raise ValueError + + def foo2(): + bar2() + + def bar2(): + raise TypeError + + try: + foo() + except Exception as e: + e1 = e + + try: + foo() + except Exception as e: + e1_b = e + + try: + foo2() + except Exception: + try: + # Intentionally trigger a NameError by referencing an undefined variable + exec('undefined_variable') + except Exception: + try: + raise ZeroDivisionError + except Exception as e4: + e5 = e4 + + canonicalized = canonicalize_exception_traceback(e5) # type: ignore + assert canonicalized.replace(__file__, '__file__') == snapshot("""\ + +builtins.ZeroDivisionError +---- +tests.test_canonicalize_exception.test_canonicalize_exception_func + raise ZeroDivisionError + +__context__: + +builtins.NameError +---- +tests.test_canonicalize_exception.test_canonicalize_exception_func + exec('undefined_variable') +tests.test_canonicalize_exception. + \n\ + +__context__: + +builtins.TypeError +---- +tests.test_canonicalize_exception.test_canonicalize_exception_func + foo2() +tests.test_canonicalize_exception.foo2 + bar2() +tests.test_canonicalize_exception.bar2 + raise TypeError\ +""") + + if sys.version_info < (3, 11): + return + + try: + raise BaseExceptionGroup('group', [e1, e1_b, e5]) # type: ignore # noqa + except BaseExceptionGroup as group: # noqa + try: + raise Exception from group + except Exception as e6: + assert canonicalize_exception_traceback(e6).replace(__file__, '__file__') == snapshot("""\ + +builtins.Exception +---- +tests.test_canonicalize_exception.test_canonicalize_exception_func + raise Exception from group + +__cause__: + +builtins.ExceptionGroup +---- +tests.test_canonicalize_exception.test_canonicalize_exception_func + raise BaseExceptionGroup('group', [e1, e1_b, e5]) # type: ignore # noqa + + + +builtins.ValueError +---- +tests.test_canonicalize_exception.test_canonicalize_exception_func + foo() +tests.test_canonicalize_exception.foo + bar() +tests.test_canonicalize_exception.bar + raise ValueError + +builtins.ZeroDivisionError +---- +tests.test_canonicalize_exception.test_canonicalize_exception_func + raise ZeroDivisionError + +__context__: + +builtins.NameError +---- +tests.test_canonicalize_exception.test_canonicalize_exception_func + exec('undefined_variable') +tests.test_canonicalize_exception. + \n\ + +__context__: + +builtins.TypeError +---- +tests.test_canonicalize_exception.test_canonicalize_exception_func + foo2() +tests.test_canonicalize_exception.foo2 + bar2() +tests.test_canonicalize_exception.bar2 + raise TypeError + + +""") + + +def test_canonicalize_repeated_frame_exception(): + def foo(n: int): + if n == 0: + raise ValueError + bar(n) + + def bar(n: int): + foo(n - 1) + + try: + foo(3) + except Exception as e: + canonicalized = canonicalize_exception_traceback(e) + assert canonicalized.replace(__file__, '__file__') == snapshot("""\ + +builtins.ValueError +---- +tests.test_canonicalize_exception.test_canonicalize_repeated_frame_exception + foo(3) +tests.test_canonicalize_exception.foo + bar(n) +tests.test_canonicalize_exception.bar + foo(n - 1) +tests.test_canonicalize_exception.foo + raise ValueError\ +""") + + +def test_sha256_string(): + assert sha256_string('test') == snapshot('9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08') + + +def test_fingerprint_attribute(exporter: TestExporter): + with pytest.raises(ValueError): + with logfire.span('foo'): + raise ValueError('test') + + (_pending, span) = exporter.exported_spans + assert span.attributes + assert span.attributes[ATTRIBUTES_EXCEPTION_FINGERPRINT_KEY] == snapshot( + '3ca86c8642e26597ed1f2485859197fd294e17719e31b302b55246dab493ce83' + ) + + +def test_cyclic_exception_cause(): + try: + try: + raise ValueError('test') + except Exception as e: + raise e from e + except Exception as e2: + assert canonicalize_exception_traceback(e2) == snapshot("""\ + +builtins.ValueError +---- +tests.test_canonicalize_exception.test_cyclic_exception_cause + raise e from e +tests.test_canonicalize_exception.test_cyclic_exception_cause + raise ValueError('test') + +__cause__: + +builtins.ValueError +---- +tests.test_canonicalize_exception.test_cyclic_exception_cause + raise e from e +tests.test_canonicalize_exception.test_cyclic_exception_cause + raise ValueError('test') + +\ +""") + + +@pytest.mark.skipif(sys.version_info < (3, 11), reason='ExceptionGroup is not available in Python < 3.11') +def test_cyclic_exception_group(): + try: + raise ExceptionGroup('group', [ValueError('test')]) # noqa + except ExceptionGroup as group: # noqa + try: + raise group.exceptions[0] + except Exception as e: + assert canonicalize_exception_traceback(e) == snapshot("""\ + +builtins.ValueError +---- +tests.test_canonicalize_exception.test_cyclic_exception_group + raise group.exceptions[0] + +__context__: + +builtins.ExceptionGroup +---- +tests.test_canonicalize_exception.test_cyclic_exception_group + raise ExceptionGroup('group', [ValueError('test')]) # noqa + + + +builtins.ValueError +---- +tests.test_canonicalize_exception.test_cyclic_exception_group + raise group.exceptions[0] + + + + +""") diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index b2ae74e17..deb5ddb58 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -1,4 +1,3 @@ -import sys from typing import Any import pytest @@ -7,245 +6,10 @@ import logfire from logfire._internal.constants import ATTRIBUTES_EXCEPTION_FINGERPRINT_KEY from logfire._internal.exporters.test import TestExporter -from logfire._internal.utils import canonicalize_exception_traceback, sha256_string +from logfire._internal.utils import canonicalize_exception_traceback from logfire.types import ExceptionCallbackHelper -def test_canonicalize_exception_func(): - def foo(): - bar() - - def bar(): - raise ValueError - - def foo2(): - bar2() - - def bar2(): - raise TypeError - - try: - foo() - except Exception as e: - e1 = e - - try: - foo() - except Exception as e: - e1_b = e - - try: - foo2() - except Exception: - try: - # Intentionally trigger a NameError by referencing an undefined variable - exec('undefined_variable') - except Exception: - try: - raise ZeroDivisionError - except Exception as e4: - e5 = e4 - - canonicalized = canonicalize_exception_traceback(e5) # type: ignore - assert canonicalized.replace(__file__, '__file__') == snapshot("""\ - -builtins.ZeroDivisionError ----- -tests.test_canonicalize_exception.test_canonicalize_exception_func - raise ZeroDivisionError - -__context__: - -builtins.NameError ----- -tests.test_canonicalize_exception.test_canonicalize_exception_func - exec('undefined_variable') -tests.test_canonicalize_exception. - \n\ - -__context__: - -builtins.TypeError ----- -tests.test_canonicalize_exception.test_canonicalize_exception_func - foo2() -tests.test_canonicalize_exception.foo2 - bar2() -tests.test_canonicalize_exception.bar2 - raise TypeError\ -""") - - if sys.version_info < (3, 11): - return - - try: - raise BaseExceptionGroup('group', [e1, e1_b, e5]) # type: ignore # noqa - except BaseExceptionGroup as group: # noqa - try: - raise Exception from group - except Exception as e6: - assert canonicalize_exception_traceback(e6).replace(__file__, '__file__') == snapshot("""\ - -builtins.Exception ----- -tests.test_canonicalize_exception.test_canonicalize_exception_func - raise Exception from group - -__cause__: - -builtins.ExceptionGroup ----- -tests.test_canonicalize_exception.test_canonicalize_exception_func - raise BaseExceptionGroup('group', [e1, e1_b, e5]) # type: ignore # noqa - - - -builtins.ValueError ----- -tests.test_canonicalize_exception.test_canonicalize_exception_func - foo() -tests.test_canonicalize_exception.foo - bar() -tests.test_canonicalize_exception.bar - raise ValueError - -builtins.ZeroDivisionError ----- -tests.test_canonicalize_exception.test_canonicalize_exception_func - raise ZeroDivisionError - -__context__: - -builtins.NameError ----- -tests.test_canonicalize_exception.test_canonicalize_exception_func - exec('undefined_variable') -tests.test_canonicalize_exception. - \n\ - -__context__: - -builtins.TypeError ----- -tests.test_canonicalize_exception.test_canonicalize_exception_func - foo2() -tests.test_canonicalize_exception.foo2 - bar2() -tests.test_canonicalize_exception.bar2 - raise TypeError - - -""") - - -def test_canonicalize_repeated_frame_exception(): - def foo(n: int): - if n == 0: - raise ValueError - bar(n) - - def bar(n: int): - foo(n - 1) - - try: - foo(3) - except Exception as e: - canonicalized = canonicalize_exception_traceback(e) - assert canonicalized.replace(__file__, '__file__') == snapshot("""\ - -builtins.ValueError ----- -tests.test_canonicalize_exception.test_canonicalize_repeated_frame_exception - foo(3) -tests.test_canonicalize_exception.foo - bar(n) -tests.test_canonicalize_exception.bar - foo(n - 1) -tests.test_canonicalize_exception.foo - raise ValueError\ -""") - - -def test_sha256_string(): - assert sha256_string('test') == snapshot('9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08') - - -def test_fingerprint_attribute(exporter: TestExporter): - with pytest.raises(ValueError): - with logfire.span('foo'): - raise ValueError('test') - - (_pending, span) = exporter.exported_spans - assert span.attributes - assert span.attributes[ATTRIBUTES_EXCEPTION_FINGERPRINT_KEY] == snapshot( - '3ca86c8642e26597ed1f2485859197fd294e17719e31b302b55246dab493ce83' - ) - - -def test_cyclic_exception_cause(): - try: - try: - raise ValueError('test') - except Exception as e: - raise e from e - except Exception as e2: - assert canonicalize_exception_traceback(e2) == snapshot("""\ - -builtins.ValueError ----- -tests.test_canonicalize_exception.test_cyclic_exception_cause - raise e from e -tests.test_canonicalize_exception.test_cyclic_exception_cause - raise ValueError('test') - -__cause__: - -builtins.ValueError ----- -tests.test_canonicalize_exception.test_cyclic_exception_cause - raise e from e -tests.test_canonicalize_exception.test_cyclic_exception_cause - raise ValueError('test') - -\ -""") - - -@pytest.mark.skipif(sys.version_info < (3, 11), reason='ExceptionGroup is not available in Python < 3.11') -def test_cyclic_exception_group(): - try: - raise ExceptionGroup('group', [ValueError('test')]) # noqa - except ExceptionGroup as group: # noqa - try: - raise group.exceptions[0] - except Exception as e: - assert canonicalize_exception_traceback(e) == snapshot("""\ - -builtins.ValueError ----- -tests.test_canonicalize_exception.test_cyclic_exception_group - raise group.exceptions[0] - -__context__: - -builtins.ExceptionGroup ----- -tests.test_canonicalize_exception.test_cyclic_exception_group - raise ExceptionGroup('group', [ValueError('test')]) # noqa - - - -builtins.ValueError ----- -tests.test_canonicalize_exception.test_cyclic_exception_group - raise group.exceptions[0] - - - - -""") - - def test_exception_callback_set_level(exporter: TestExporter, config_kwargs: dict[str, Any]): def exception_callback(helper: ExceptionCallbackHelper) -> None: assert helper.level.name == 'error' @@ -381,3 +145,109 @@ def exception_callback(helper: ExceptionCallbackHelper) -> None: }, ] ) + + +def test_set_create_issue_false(exporter: TestExporter, config_kwargs: dict[str, Any]): + def exception_callback(helper: ExceptionCallbackHelper) -> None: + assert helper.create_issue + helper.create_issue = False + assert not helper.create_issue + + config_kwargs['advanced'].exception_callback = exception_callback + logfire.configure(**config_kwargs) + + with pytest.raises(ValueError): + with logfire.span('foo'): + raise ValueError('test') + + (_pending, span) = exporter.exported_spans + assert span.attributes + assert ATTRIBUTES_EXCEPTION_FINGERPRINT_KEY not in span.attributes + + assert exporter.exported_spans_as_dict() == snapshot( + [ + { + 'name': 'foo', + 'context': {'trace_id': 1, 'span_id': 1, 'is_remote': False}, + 'parent': None, + 'start_time': 1000000000, + 'end_time': 3000000000, + 'attributes': { + 'code.filepath': 'test_exceptions.py', + 'code.function': 'test_set_create_issue_false', + 'code.lineno': 123, + 'logfire.msg_template': 'foo', + 'logfire.msg': 'foo', + 'logfire.span_type': 'span', + 'logfire.level_num': 17, + }, + 'events': [ + { + 'name': 'exception', + 'timestamp': 2000000000, + 'attributes': { + 'exception.type': 'ValueError', + 'exception.message': 'test', + 'exception.stacktrace': 'ValueError: test', + 'exception.escaped': 'True', + }, + } + ], + } + ] + ) + + +def test_set_fingerprint(exporter: TestExporter, config_kwargs: dict[str, Any]): + def exception_callback(helper: ExceptionCallbackHelper) -> None: + assert not helper.create_issue + helper.issue_fingerprint_source = 'custom fingerprint source' + assert helper.issue_fingerprint_source == 'custom fingerprint source' + assert helper.create_issue + + config_kwargs['advanced'].exception_callback = exception_callback + logfire.configure(**config_kwargs) + + try: + raise ValueError('test') + except ValueError: + logfire.notice('caught error', _exc_info=True) + + [span] = exporter.exported_spans + assert span.attributes + assert span.attributes[ATTRIBUTES_EXCEPTION_FINGERPRINT_KEY] == snapshot( + '88555bd8bc2401ab2887ac1f1286642f98322a580cfe30dd6ad067fffd4a01c9' + ) + + assert exporter.exported_spans_as_dict() == snapshot( + [ + { + 'name': 'caught error', + 'context': {'trace_id': 1, 'span_id': 1, 'is_remote': False}, + 'parent': None, + 'start_time': 1000000000, + 'end_time': 1000000000, + 'attributes': { + 'code.filepath': 'test_exceptions.py', + 'code.function': 'test_set_fingerprint', + 'code.lineno': 123, + 'logfire.msg_template': 'caught error', + 'logfire.msg': 'caught error', + 'logfire.span_type': 'log', + 'logfire.level_num': 10, + }, + 'events': [ + { + 'name': 'exception', + 'timestamp': 2000000000, + 'attributes': { + 'exception.type': 'ValueError', + 'exception.message': 'test', + 'exception.stacktrace': 'ValueError: test', + 'exception.escaped': 'False', + }, + } + ], + } + ] + ) From 1c499ccfd6064b7f5524d17b8b971ac07006a3c3 Mon Sep 17 00:00:00 2001 From: Alex Hall Date: Tue, 26 Aug 2025 17:27:07 +0200 Subject: [PATCH 05/14] test_no_record_exception --- tests/test_exceptions.py | 44 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index deb5ddb58..c1065e527 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -251,3 +251,47 @@ def exception_callback(helper: ExceptionCallbackHelper) -> None: } ] ) + + +def test_no_record_exception(exporter: TestExporter, config_kwargs: dict[str, Any]): + def exception_callback(helper: ExceptionCallbackHelper) -> None: + assert helper.create_issue + helper.no_record_exception() + assert not helper.create_issue + with pytest.raises(ValueError): + helper.create_issue = True + with pytest.raises(ValueError): + helper.issue_fingerprint_source = 'custom fingerprint source' + assert not helper.create_issue + + config_kwargs['advanced'].exception_callback = exception_callback + logfire.configure(**config_kwargs) + + with pytest.raises(ValueError): + with logfire.span('span'): + raise ValueError('test') + + (_pending, span) = exporter.exported_spans + assert span.attributes + assert ATTRIBUTES_EXCEPTION_FINGERPRINT_KEY not in span.attributes + + assert exporter.exported_spans_as_dict() == snapshot( + [ + { + 'name': 'span', + 'context': {'trace_id': 1, 'span_id': 1, 'is_remote': False}, + 'parent': None, + 'start_time': 1000000000, + 'end_time': 3000000000, + 'attributes': { + 'code.filepath': 'test_exceptions.py', + 'code.function': 'test_no_record_exception', + 'code.lineno': 123, + 'logfire.msg_template': 'span', + 'logfire.msg': 'span', + 'logfire.span_type': 'span', + 'logfire.level_num': 17, + }, + } + ] + ) From 8c6dc2a94be31520d6e0cd43278717c5fcfaa5ed Mon Sep 17 00:00:00 2001 From: Alex Hall Date: Tue, 26 Aug 2025 17:34:28 +0200 Subject: [PATCH 06/14] coverage --- tests/test_canonicalize_exception.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/test_canonicalize_exception.py b/tests/test_canonicalize_exception.py index e118e9f44..2c2f4ef86 100644 --- a/tests/test_canonicalize_exception.py +++ b/tests/test_canonicalize_exception.py @@ -242,3 +242,16 @@ def test_cyclic_exception_group(): """) + + +def test_canonicalize_no_traceback(): + try: + raise ValueError('oops') + except Exception as e: + e.__traceback__ = None + canonicalized = canonicalize_exception_traceback(e) + assert canonicalized.replace(__file__, '__file__') == snapshot("""\ + +builtins.ValueError +----\ +""") From 9006282379c7303afbee34380a227dc2bd93479d Mon Sep 17 00:00:00 2001 From: Alex Hall Date: Wed, 27 Aug 2025 16:52:23 +0200 Subject: [PATCH 07/14] docstrings --- logfire/types.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/logfire/types.py b/logfire/types.py index ca9064321..e61dc25b5 100644 --- a/logfire/types.py +++ b/logfire/types.py @@ -76,7 +76,10 @@ def __le__(self, other: LevelName): @dataclass class ExceptionCallbackHelper: - """TODO.""" + """Helper object passed to the exception callback. + + This is experimental and may change significantly in future releases. + """ span: Span exception: BaseException @@ -91,8 +94,6 @@ def level(self) -> SpanLevel: Usually the level is error. FastAPI/Starlette 4xx HTTPExceptions are warnings. Will be a different level if this is created by e.g. `logfire.info(..., _exc_info=True)`. - - Returns a https://logfire.pydantic.dev/docs/reference/api/sampling/#logfire.sampling.SpanLevel """ return SpanLevel.from_span(self.span) @@ -188,6 +189,10 @@ def no_record_exception(self) -> None: ExceptionCallback = Callable[[ExceptionCallbackHelper], None] """ +This is experimental and may change significantly in future releases. + +Usage: + def my_callback(helper: logfire.ExceptionCallbackHelper): ... From ad78c1b0a901106e3e577c28c2bf5a14946ac152 Mon Sep 17 00:00:00 2001 From: Alex Hall Date: Wed, 27 Aug 2025 17:07:51 +0200 Subject: [PATCH 08/14] Update logfire/_internal/config.py Co-authored-by: David Montague <35119617+dmontagu@users.noreply.github.com> --- logfire/_internal/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/logfire/_internal/config.py b/logfire/_internal/config.py index 0aaef7bce..b9a2fbd1a 100644 --- a/logfire/_internal/config.py +++ b/logfire/_internal/config.py @@ -186,7 +186,7 @@ class AdvancedOptions: exception_callback: ExceptionCallback | None = None """Callback function that is called when an exception is recorded on a span. - This is experimental and may be removed.""" + This is experimental and may be modified or removed.""" def generate_base_url(self, token: str) -> str: if self.base_url is not None: From e2c91a6a9bf85de77f998cf241a9ffea7ebb7f90 Mon Sep 17 00:00:00 2001 From: David Montague <35119617+dmontagu@users.noreply.github.com> Date: Thu, 28 Aug 2025 14:50:58 -0600 Subject: [PATCH 09/14] Ensure exceptions are treated as escaping in the fastapi instrumentation --- logfire/_internal/integrations/fastapi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/logfire/_internal/integrations/fastapi.py b/logfire/_internal/integrations/fastapi.py index 165e1bce9..3f8068d50 100644 --- a/logfire/_internal/integrations/fastapi.py +++ b/logfire/_internal/integrations/fastapi.py @@ -186,7 +186,7 @@ def set_timestamp(attribute_name: str): # Record the end timestamp before recording exceptions. set_timestamp('end_timestamp') except Exception as exc: - root_span.record_exception(exc) + root_span.record_exception(exc, escaped=True) raise async def solve_dependencies(self, request: Request | WebSocket, original: Awaitable[Any]) -> Any: From 83a7d04bb8fa0a95ee92cc64d48f1a3a81620f3c Mon Sep 17 00:00:00 2001 From: David Montague <35119617+dmontagu@users.noreply.github.com> Date: Thu, 28 Aug 2025 16:18:55 -0600 Subject: [PATCH 10/14] Revert change to call exceptions escaping but allow unset level to generate a fingerprint by default --- logfire/_internal/integrations/fastapi.py | 2 +- logfire/types.py | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/logfire/_internal/integrations/fastapi.py b/logfire/_internal/integrations/fastapi.py index 3f8068d50..165e1bce9 100644 --- a/logfire/_internal/integrations/fastapi.py +++ b/logfire/_internal/integrations/fastapi.py @@ -186,7 +186,7 @@ def set_timestamp(attribute_name: str): # Record the end timestamp before recording exceptions. set_timestamp('end_timestamp') except Exception as exc: - root_span.record_exception(exc, escaped=True) + root_span.record_exception(exc) raise async def solve_dependencies(self, request: Request | WebSocket, original: Awaitable[Any]) -> Any: diff --git a/logfire/types.py b/logfire/types.py index e61dc25b5..a4132cbb8 100644 --- a/logfire/types.py +++ b/logfire/types.py @@ -107,6 +107,14 @@ def level(self, value: LevelName | int) -> None: """ self.span.set_attributes(log_level_attributes(value)) + @property + def level_is_unset(self) -> bool: + """Determine if the level has not been explicitly set on the span. + + This generally happens when a span is not marked as escaping. + """ + return ATTRIBUTES_LOG_LEVEL_NUM_KEY not in (self.span.attributes or {}) + @property def parent_span(self) -> ReadableSpan | None: """The parent span of the span the exception was recorded on. @@ -159,7 +167,9 @@ def create_issue(self) -> bool: if self._create_issue is not None: return self._create_issue - return self._record_exception and self.level >= 'error' and self.parent_span is None + # Note: the level might not be set if dealing with a non-escaping exception, but that's expected for e.g. + # the root spans of web frameworks. + return self._record_exception and (self.level_is_unset or self.level >= 'error') and self.parent_span is None @create_issue.setter def create_issue(self, value: bool): From 08460c61107d89db8a08f843d2dad8c5eddfc350 Mon Sep 17 00:00:00 2001 From: Alex Hall Date: Thu, 2 Oct 2025 14:18:14 +0200 Subject: [PATCH 11/14] `exception_callback` followups (#1442) --- logfire/_internal/exporters/test.py | 3 +- logfire/_internal/tracer.py | 25 ++- logfire/types.py | 9 +- .../otel_integrations/test_aiohttp_client.py | 3 + tests/otel_integrations/test_django.py | 1 + tests/otel_integrations/test_fastapi.py | 191 +++++++++++++++++- tests/otel_integrations/test_starlette.py | 1 + tests/test_auto_trace.py | 1 + tests/test_console_exporter.py | 1 + tests/test_exceptions.py | 2 + tests/test_logfire.py | 4 + tests/test_loguru.py | 1 + tests/test_pydantic_plugin.py | 3 + tests/test_secret_scrubbing.py | 1 + tests/test_structlog.py | 1 + tests/test_testing.py | 1 + 16 files changed, 230 insertions(+), 18 deletions(-) diff --git a/logfire/_internal/exporters/test.py b/logfire/_internal/exporters/test.py index a5f4aa683..b0ba062cc 100644 --- a/logfire/_internal/exporters/test.py +++ b/logfire/_internal/exporters/test.py @@ -171,7 +171,8 @@ def build_attributes( k: process_attribute(k, v, strip_filepaths, fixed_line_number, strip_function_qualname, parse_json_attributes) for k, v in attributes.items() } - attributes.pop(ATTRIBUTES_EXCEPTION_FINGERPRINT_KEY, None) + if ATTRIBUTES_EXCEPTION_FINGERPRINT_KEY in attributes: + attributes[ATTRIBUTES_EXCEPTION_FINGERPRINT_KEY] = '0' * 64 if 'telemetry.sdk.version' in attributes: attributes['telemetry.sdk.version'] = '0.0.0' return attributes diff --git a/logfire/_internal/tracer.py b/logfire/_internal/tracer.py index 99b0cbe51..c391dce02 100644 --- a/logfire/_internal/tracer.py +++ b/logfire/_internal/tracer.py @@ -39,6 +39,9 @@ from .utils import handle_internal_errors, sha256_string if TYPE_CHECKING: + from starlette.exceptions import HTTPException + from typing_extensions import TypeIs + from ..types import ExceptionCallback from .config import LogfireConfig @@ -420,8 +423,14 @@ def record_exception( """Similar to the OTEL SDK Span.record_exception method, with our own additions.""" from ..types import ExceptionCallbackHelper - if is_starlette_http_exception_400(exception): - span.set_attributes(log_level_attributes('warn')) + if is_starlette_http_exception(exception): + if 400 <= exception.status_code < 500: + # Don't mark 4xx HTTP exceptions as errors, they are expected to happen in normal operation. + # But do record them as warnings. + span.set_attributes(log_level_attributes('warn')) + elif exception.status_code >= 500: + set_exception_status(span, exception) + span.set_attributes(log_level_attributes('error')) # From https://opentelemetry.io/docs/specs/semconv/attributes-registry/exception/ # `escaped=True` means that the exception is escaping the scope of the span. @@ -431,7 +440,11 @@ def record_exception( set_exception_status(span, exception) span.set_attributes(log_level_attributes('error')) - helper = ExceptionCallbackHelper(span=cast(SDKSpan, span), exception=exception) + helper = ExceptionCallbackHelper( + span=cast(SDKSpan, span), + exception=exception, + event_attributes={**(attributes or {})}, + ) if callback is not None: with handle_internal_errors: @@ -440,7 +453,7 @@ def record_exception( if not helper._record_exception: # type: ignore return - attributes = {**(attributes or {})} + attributes = helper.event_attributes if ValidationError is not None and isinstance(exception, ValidationError): # insert a more detailed breakdown of pydantic errors try: @@ -473,10 +486,10 @@ def set_exception_status(span: trace_api.Span, exception: BaseException): ) -def is_starlette_http_exception_400(exception: BaseException) -> bool: +def is_starlette_http_exception(exception: BaseException) -> TypeIs[HTTPException]: if 'starlette.exceptions' not in sys.modules: # pragma: no cover return False from starlette.exceptions import HTTPException - return isinstance(exception, HTTPException) and 400 <= exception.status_code < 500 + return isinstance(exception, HTTPException) diff --git a/logfire/types.py b/logfire/types.py index a4132cbb8..5d4d62021 100644 --- a/logfire/types.py +++ b/logfire/types.py @@ -15,6 +15,7 @@ if TYPE_CHECKING: from opentelemetry.sdk.trace import ReadableSpan, Span + from opentelemetry.util import types as otel_types @dataclass @@ -83,6 +84,7 @@ class ExceptionCallbackHelper: span: Span exception: BaseException + event_attributes: dict[str, otel_types.AttributeValue] _issue_fingerprint_source: str | None = None _create_issue: bool | None = None _record_exception: bool = True @@ -169,7 +171,12 @@ def create_issue(self) -> bool: # Note: the level might not be set if dealing with a non-escaping exception, but that's expected for e.g. # the root spans of web frameworks. - return self._record_exception and (self.level_is_unset or self.level >= 'error') and self.parent_span is None + return ( + self._record_exception + and (self.level_is_unset or self.level >= 'error') + and self.parent_span is None + and not (self.event_attributes.get('recorded_by_logfire_fastapi') and self.level < 'error') + ) @create_issue.setter def create_issue(self, value: bool): diff --git a/tests/otel_integrations/test_aiohttp_client.py b/tests/otel_integrations/test_aiohttp_client.py index 3de7c3498..4fb9eb35c 100644 --- a/tests/otel_integrations/test_aiohttp_client.py +++ b/tests/otel_integrations/test_aiohttp_client.py @@ -213,6 +213,7 @@ async def test_aiohttp_client_exception_handling(exporter: TestExporter): 'logfire.span_type': 'span', 'logfire.msg': 'GET non-existent-host-12345.example.com/test', 'error.type': 'ClientConnectorDNSError', + 'logfire.exception.fingerprint': '0000000000000000000000000000000000000000000000000000000000000000', 'http.target': '/test', 'logfire.level_num': 17, }, @@ -267,6 +268,7 @@ async def test_aiohttp_client_exception_handling_with_hooks(exporter: TestExport 'logfire.msg': 'GET non-existent-host-12345.example.com/test', 'custom.request.name': 'Custom Request', 'error.type': 'ClientConnectorDNSError', + 'logfire.exception.fingerprint': '0000000000000000000000000000000000000000000000000000000000000000', 'custom.response.exception': 'Custom Exception', 'http.target': '/test', 'logfire.level_num': 17, @@ -394,6 +396,7 @@ async def test_aiohttp_client_capture_response_body_exception(exporter: TestExpo 'logfire.span_type': 'span', 'logfire.msg': 'GET non-existent-host-12345.example.com/test', 'error.type': 'ClientConnectorDNSError', + 'logfire.exception.fingerprint': '0000000000000000000000000000000000000000000000000000000000000000', 'http.target': '/test', 'logfire.level_num': 17, }, diff --git a/tests/otel_integrations/test_django.py b/tests/otel_integrations/test_django.py index 5f7fa7fc0..96ad42d8b 100644 --- a/tests/otel_integrations/test_django.py +++ b/tests/otel_integrations/test_django.py @@ -180,6 +180,7 @@ def test_error_route(client: Client, exporter: TestExporter): 'http.route': 'django_test_app/bad/', 'http.status_code': 400, 'http.response.status_code': 400, + 'logfire.exception.fingerprint': '0000000000000000000000000000000000000000000000000000000000000000', 'http.target': '/django_test_app/bad/', 'logfire.level_num': 17, }, diff --git a/tests/otel_integrations/test_fastapi.py b/tests/otel_integrations/test_fastapi.py index cc77f64f9..7c335a691 100644 --- a/tests/otel_integrations/test_fastapi.py +++ b/tests/otel_integrations/test_fastapi.py @@ -22,7 +22,6 @@ import logfire._internal import logfire._internal.integrations import logfire._internal.integrations.fastapi -from logfire._internal.constants import LEVEL_NUMBERS from logfire._internal.main import set_user_attributes_on_raw_span from logfire.testing import TestExporter @@ -74,8 +73,8 @@ async def echo_body(request: Request): return await request.body() -async def bad_request_error(): - raise HTTPException(400) +async def http_exception(code: int): + raise HTTPException(code) async def websocket_endpoint(websocket: WebSocket, name: str): @@ -109,7 +108,7 @@ def app(): app.get('/other', name='other_route_name', operation_id='other_route_operation_id')(other_route) app.get('/exception')(exception) app.get('/validation_error')(validation_error) - app.get('/bad_request_error')(bad_request_error) + app.get('/http_exception/{code}')(http_exception) app.get('/with_path_param/{param}')(with_path_param) app.get('/secret/{path_param}', name='secret')(get_secret) app.get('/bad_dependency_route/{good}')(bad_dependency_route) @@ -249,6 +248,7 @@ def test_bad_dependency_route(client: TestClient, exporter: TestExporter) -> Non }, 'fastapi.arguments.start_timestamp': '1970-01-01T00:00:03.000000Z', 'fastapi.arguments.end_timestamp': '1970-01-01T00:00:04.000000Z', + 'logfire.exception.fingerprint': '0000000000000000000000000000000000000000000000000000000000000000', 'http.status_code': 500, 'http.response.status_code': 500, 'error.type': '500', @@ -342,13 +342,183 @@ def test_404(client: TestClient, exporter: TestExporter) -> None: ) -def test_400(client: TestClient, exporter: TestExporter) -> None: - response = client.get('/bad_request_error') - assert response.status_code == 400 +def test_http_exceptions(client: TestClient, exporter: TestExporter) -> None: + assert client.get('/http_exception/200').status_code == 200 + assert client.get('/http_exception/400').status_code == 400 + assert client.get('/http_exception/500').status_code == 500 - for span in exporter.exported_spans: - if span.events: - assert span.attributes and span.attributes['logfire.level_num'] == LEVEL_NUMBERS['warn'] + assert [ + span for span in exporter.exported_spans_as_dict() if span['name'] == 'GET /http_exception/{code}' + ] == snapshot( + [ + { + 'name': 'GET /http_exception/{code}', + 'context': {'trace_id': 1, 'span_id': 1, 'is_remote': False}, + 'parent': None, + 'start_time': 1000000000, + 'end_time': 16000000000, + 'attributes': { + 'logfire.span_type': 'span', + 'logfire.msg': 'GET /http_exception/200', + 'http.scheme': 'http', + 'url.scheme': 'http', + 'http.host': 'testserver', + 'server.address': 'testserver', + 'net.host.port': 80, + 'server.port': 80, + 'http.flavor': '1.1', + 'network.protocol.version': '1.1', + 'http.target': '/http_exception/200', + 'url.path': '/http_exception/200', + 'http.url': 'http://testserver/http_exception/200', + 'http.method': 'GET', + 'http.request.method': 'GET', + 'http.server_name': 'testserver', + 'http.user_agent': 'testclient', + 'user_agent.original': 'testclient', + 'net.peer.ip': 'testclient', + 'client.address': 'testclient', + 'net.peer.port': 50000, + 'client.port': 50000, + 'http.route': '/http_exception/{code}', + 'fastapi.route.name': 'http_exception', + 'fastapi.route.operation_id': 'null', + 'logfire.json_schema': '{"type":"object","properties":{"fastapi.route.name":{},"fastapi.route.operation_id":{"type":"null"}}}', + 'fastapi.arguments.start_timestamp': '1970-01-01T00:00:03.000000Z', + 'fastapi.arguments.end_timestamp': '1970-01-01T00:00:04.000000Z', + 'fastapi.endpoint_function.start_timestamp': '1970-01-01T00:00:07.000000Z', + 'fastapi.endpoint_function.end_timestamp': '1970-01-01T00:00:08.000000Z', + 'http.status_code': 200, + 'http.response.status_code': 200, + }, + 'events': [ + { + 'name': 'exception', + 'timestamp': 9000000000, + 'attributes': { + 'exception.type': 'fastapi.exceptions.HTTPException', + 'exception.message': '200: OK', + 'exception.stacktrace': 'fastapi.exceptions.HTTPException: 200: OK', + 'exception.escaped': 'False', + 'recorded_by_logfire_fastapi': True, + }, + } + ], + }, + { + 'name': 'GET /http_exception/{code}', + 'context': {'trace_id': 2, 'span_id': 11, 'is_remote': False}, + 'parent': None, + 'start_time': 17000000000, + 'end_time': 32000000000, + 'attributes': { + 'logfire.span_type': 'span', + 'logfire.msg': 'GET /http_exception/400', + 'http.scheme': 'http', + 'url.scheme': 'http', + 'http.host': 'testserver', + 'server.address': 'testserver', + 'net.host.port': 80, + 'server.port': 80, + 'http.flavor': '1.1', + 'network.protocol.version': '1.1', + 'http.target': '/http_exception/400', + 'url.path': '/http_exception/400', + 'http.url': 'http://testserver/http_exception/400', + 'http.method': 'GET', + 'http.request.method': 'GET', + 'http.server_name': 'testserver', + 'http.user_agent': 'testclient', + 'user_agent.original': 'testclient', + 'net.peer.ip': 'testclient', + 'client.address': 'testclient', + 'net.peer.port': 50000, + 'client.port': 50000, + 'http.route': '/http_exception/{code}', + 'fastapi.route.name': 'http_exception', + 'fastapi.route.operation_id': 'null', + 'logfire.json_schema': '{"type":"object","properties":{"fastapi.route.name":{},"fastapi.route.operation_id":{"type":"null"}}}', + 'fastapi.arguments.start_timestamp': '1970-01-01T00:00:19.000000Z', + 'fastapi.arguments.end_timestamp': '1970-01-01T00:00:20.000000Z', + 'fastapi.endpoint_function.start_timestamp': '1970-01-01T00:00:23.000000Z', + 'fastapi.endpoint_function.end_timestamp': '1970-01-01T00:00:24.000000Z', + 'logfire.level_num': 13, + 'http.status_code': 400, + 'http.response.status_code': 400, + }, + 'events': [ + { + 'name': 'exception', + 'timestamp': 25000000000, + 'attributes': { + 'exception.type': 'fastapi.exceptions.HTTPException', + 'exception.message': '400: Bad Request', + 'exception.stacktrace': 'fastapi.exceptions.HTTPException: 400: Bad Request', + 'exception.escaped': 'False', + 'recorded_by_logfire_fastapi': True, + }, + } + ], + }, + { + 'name': 'GET /http_exception/{code}', + 'context': {'trace_id': 3, 'span_id': 21, 'is_remote': False}, + 'parent': None, + 'start_time': 33000000000, + 'end_time': 48000000000, + 'attributes': { + 'logfire.span_type': 'span', + 'logfire.msg': 'GET /http_exception/500', + 'http.scheme': 'http', + 'url.scheme': 'http', + 'http.host': 'testserver', + 'server.address': 'testserver', + 'net.host.port': 80, + 'server.port': 80, + 'http.flavor': '1.1', + 'network.protocol.version': '1.1', + 'http.target': '/http_exception/500', + 'url.path': '/http_exception/500', + 'http.url': 'http://testserver/http_exception/500', + 'http.method': 'GET', + 'http.request.method': 'GET', + 'http.server_name': 'testserver', + 'http.user_agent': 'testclient', + 'user_agent.original': 'testclient', + 'net.peer.ip': 'testclient', + 'client.address': 'testclient', + 'net.peer.port': 50000, + 'client.port': 50000, + 'http.route': '/http_exception/{code}', + 'fastapi.route.name': 'http_exception', + 'fastapi.route.operation_id': 'null', + 'logfire.json_schema': '{"type":"object","properties":{"fastapi.route.name":{},"fastapi.route.operation_id":{"type":"null"}}}', + 'fastapi.arguments.start_timestamp': '1970-01-01T00:00:35.000000Z', + 'fastapi.arguments.end_timestamp': '1970-01-01T00:00:36.000000Z', + 'fastapi.endpoint_function.start_timestamp': '1970-01-01T00:00:39.000000Z', + 'fastapi.endpoint_function.end_timestamp': '1970-01-01T00:00:40.000000Z', + 'http.status_code': 500, + 'logfire.exception.fingerprint': '0000000000000000000000000000000000000000000000000000000000000000', + 'http.response.status_code': 500, + 'error.type': '500', + 'logfire.level_num': 17, + }, + 'events': [ + { + 'name': 'exception', + 'timestamp': 41000000000, + 'attributes': { + 'exception.type': 'fastapi.exceptions.HTTPException', + 'exception.message': '500: Internal Server Error', + 'exception.stacktrace': 'fastapi.exceptions.HTTPException: 500: Internal Server Error', + 'exception.escaped': 'False', + 'recorded_by_logfire_fastapi': True, + }, + } + ], + }, + ] + ) def test_path_param(client: TestClient, exporter: TestExporter) -> None: @@ -1553,6 +1723,7 @@ def test_fastapi_unhandled_exception(client: TestClient, exporter: TestExporter) 'fastapi.arguments.end_timestamp': '1970-01-01T00:00:04.000000Z', 'fastapi.endpoint_function.start_timestamp': '1970-01-01T00:00:07.000000Z', 'fastapi.endpoint_function.end_timestamp': '1970-01-01T00:00:08.000000Z', + 'logfire.exception.fingerprint': '0000000000000000000000000000000000000000000000000000000000000000', 'http.status_code': 500, 'http.response.status_code': 500, 'error.type': '500', diff --git a/tests/otel_integrations/test_starlette.py b/tests/otel_integrations/test_starlette.py index 70c32df37..411d6ff29 100644 --- a/tests/otel_integrations/test_starlette.py +++ b/tests/otel_integrations/test_starlette.py @@ -214,6 +214,7 @@ def test_scrubbing(client: TestClient, exporter: TestExporter) -> None: 'client.port': 50000, 'http.route': '/secret/{path_param}', 'logfire.level_num': 17, + 'logfire.exception.fingerprint': '0000000000000000000000000000000000000000000000000000000000000000', 'http.request.header.testauthorization': ("[Scrubbed due to 'auth']",), 'logfire.scrubbed': IsJson( [{'path': ['attributes', 'http.request.header.testauthorization'], 'matched_substring': 'auth'}] diff --git a/tests/test_auto_trace.py b/tests/test_auto_trace.py index 50438c7a4..38352c395 100644 --- a/tests/test_auto_trace.py +++ b/tests/test_auto_trace.py @@ -137,6 +137,7 @@ def test_auto_trace_sample(exporter: TestExporter) -> None: 'logfire.span_type': 'span', 'logfire.msg': 'Calling tests.auto_trace_samples.foo.bar', 'logfire.level_num': 17, + 'logfire.exception.fingerprint': '0000000000000000000000000000000000000000000000000000000000000000', }, 'events': [ { diff --git a/tests/test_console_exporter.py b/tests/test_console_exporter.py index 3616c9076..f1f945ba6 100644 --- a/tests/test_console_exporter.py +++ b/tests/test_console_exporter.py @@ -738,6 +738,7 @@ def test_exception(exporter: TestExporter) -> None: 'code.lineno': 123, 'a': 'test', 'logfire.json_schema': '{"type":"object","properties":{"a":{}}}', + 'logfire.exception.fingerprint': '0000000000000000000000000000000000000000000000000000000000000000', }, events=[ { diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index c1065e527..7a46786ba 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -114,6 +114,7 @@ def exception_callback(helper: ExceptionCallbackHelper) -> None: 'logfire.msg': 'inner', 'logfire.span_type': 'span', 'logfire.level_num': 17, + 'logfire.exception.fingerprint': '0000000000000000000000000000000000000000000000000000000000000000', }, 'events': [ { @@ -235,6 +236,7 @@ def exception_callback(helper: ExceptionCallbackHelper) -> None: 'logfire.msg': 'caught error', 'logfire.span_type': 'log', 'logfire.level_num': 10, + 'logfire.exception.fingerprint': '0000000000000000000000000000000000000000000000000000000000000000', }, 'events': [ { diff --git a/tests/test_logfire.py b/tests/test_logfire.py index c76252d75..dd53ef44d 100644 --- a/tests/test_logfire.py +++ b/tests/test_logfire.py @@ -1239,6 +1239,7 @@ def run(a: str) -> Model: } ] ), + 'logfire.exception.fingerprint': '0000000000000000000000000000000000000000000000000000000000000000', }, 'events': [ { @@ -1310,6 +1311,7 @@ def run(a: str) -> None: } ] ), + 'logfire.exception.fingerprint': '0000000000000000000000000000000000000000000000000000000000000000', }, 'events': [ { @@ -3262,6 +3264,7 @@ def patched_record_exception(*args: Any, **kwargs: Any) -> Any: 'logfire.msg': 'foo', 'logfire.span_type': 'span', 'logfire.level_num': 17, + 'logfire.exception.fingerprint': '0000000000000000000000000000000000000000000000000000000000000000', }, 'events': [ { @@ -3309,6 +3312,7 @@ def patched_record_exception(*args: Any, **kwargs: Any) -> Any: 'logfire.msg_template': 'foo', 'logfire.msg': 'foo', 'logfire.span_type': 'span', + 'logfire.exception.fingerprint': '0000000000000000000000000000000000000000000000000000000000000000', }, 'events': [ { diff --git a/tests/test_loguru.py b/tests/test_loguru.py index 1ab709c8a..ae0bbbd29 100644 --- a/tests/test_loguru.py +++ b/tests/test_loguru.py @@ -84,6 +84,7 @@ def test_loguru(exporter: TestExporter) -> None: 'logfire.logger_name': 'tests.test_loguru', 'foo': 'bar', 'logfire.json_schema': '{"type":"object","properties":{"logfire.logger_name":{},"foo":{}}}', + 'logfire.exception.fingerprint': '0000000000000000000000000000000000000000000000000000000000000000', }, 'events': [ { diff --git a/tests/test_pydantic_plugin.py b/tests/test_pydantic_plugin.py index eed79c3e4..e2f93cc99 100644 --- a/tests/test_pydantic_plugin.py +++ b/tests/test_pydantic_plugin.py @@ -876,6 +876,7 @@ def validate_x(cls, v: Any) -> Any: 'logfire.json_schema': '{"type":"object","properties":{"schema_name":{},"validation_method":{},"input_data":{"type":"object"},"success":{}}}', 'validation_method': 'validate_python', 'input_data': '{"x":1}', + 'logfire.exception.fingerprint': '0000000000000000000000000000000000000000000000000000000000000000', 'success': False, }, 'events': [ @@ -925,6 +926,7 @@ def validate_x(cls, v: Any) -> Any: 'schema_name': 'MyModel', 'logfire.json_schema': '{"type":"object","properties":{"schema_name":{},"exception_type":{}}}', 'exception_type': 'TypeError', + 'logfire.exception.fingerprint': '0000000000000000000000000000000000000000000000000000000000000000', }, 'events': [ { @@ -1035,6 +1037,7 @@ def validate_x(cls, v: Any) -> Any: 'success': False, 'logfire.msg': 'Pydantic MyModel validate_python raised TypeError', 'logfire.level_num': 17, + 'logfire.exception.fingerprint': '0000000000000000000000000000000000000000000000000000000000000000', 'logfire.json_schema': '{"type":"object","properties":{"schema_name":{},"validation_method":{},"input_data":{"type":"object"},"success":{}}}', }, 'events': [ diff --git a/tests/test_secret_scrubbing.py b/tests/test_secret_scrubbing.py index b5eb88a0f..06e0b0304 100644 --- a/tests/test_secret_scrubbing.py +++ b/tests/test_secret_scrubbing.py @@ -209,6 +209,7 @@ def get_password(): 'logfire.msg': 'get_password', 'logfire.span_type': 'span', 'logfire.level_num': 17, + 'logfire.exception.fingerprint': '0000000000000000000000000000000000000000000000000000000000000000', 'logfire.scrubbed': IsJson( [ { diff --git a/tests/test_structlog.py b/tests/test_structlog.py index 7bca580e7..c66489a2b 100644 --- a/tests/test_structlog.py +++ b/tests/test_structlog.py @@ -86,6 +86,7 @@ def test_structlog(exporter: TestExporter, logger: structlog.BoundLogger) -> Non 'code.function': 'test_structlog', 'code.lineno': 36, 'logfire.disable_console_log': True, + 'logfire.exception.fingerprint': '0000000000000000000000000000000000000000000000000000000000000000', }, 'events': [ { diff --git a/tests/test_testing.py b/tests/test_testing.py index 23418ab77..d768ffc76 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -63,6 +63,7 @@ def test_capfire_fixture(capfire: CaptureLogfire) -> None: 'logfire.msg': 'a span!', 'logfire.span_type': 'span', 'logfire.level_num': 17, + 'logfire.exception.fingerprint': '0000000000000000000000000000000000000000000000000000000000000000', }, 'events': [ { From b5129071de952d18d0570f82274f498ccbe242db Mon Sep 17 00:00:00 2001 From: Alex Hall Date: Thu, 2 Oct 2025 14:52:37 +0200 Subject: [PATCH 12/14] comment --- logfire/_internal/tracer.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/logfire/_internal/tracer.py b/logfire/_internal/tracer.py index c391dce02..54eba7292 100644 --- a/logfire/_internal/tracer.py +++ b/logfire/_internal/tracer.py @@ -429,6 +429,10 @@ def record_exception( # But do record them as warnings. span.set_attributes(log_level_attributes('warn')) elif exception.status_code >= 500: + # Set this as an error now for ExceptionCallbackHelper.create_issue to see, + # particularly so that if this is raised in a FastAPI pseudo_span and the event is marked with + # the recorded_by_logfire_fastapi it will still create an issue in this case. + # FastAPI will 'handle' this exception meaning it won't get recorded again by OTel. set_exception_status(span, exception) span.set_attributes(log_level_attributes('error')) From c5e7251a5ca14142788665b9329052ba558be98b Mon Sep 17 00:00:00 2001 From: Alex Hall Date: Fri, 3 Oct 2025 12:47:05 +0200 Subject: [PATCH 13/14] comment --- logfire/types.py | 45 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/logfire/types.py b/logfire/types.py index 5d4d62021..55d2ead47 100644 --- a/logfire/types.py +++ b/logfire/types.py @@ -93,9 +93,12 @@ class ExceptionCallbackHelper: def level(self) -> SpanLevel: """Convenient way to see and compare the level of the span. - Usually the level is error. - FastAPI/Starlette 4xx HTTPExceptions are warnings. - Will be a different level if this is created by e.g. `logfire.info(..., _exc_info=True)`. + - When using `logfire.span` or `logfire.exception`, this is usually `error`. + - Spans created directly by an OpenTelemetry tracer (e.g. from any `logfire.instrument_*()` method) + typically don't have a level set, so this will return the default of `info`, + but `level_is_unset` will be `True`. + - FastAPI/Starlette 4xx HTTPExceptions are warnings. + - Will be a different level if this is created by e.g. `logfire.info(..., _exc_info=True)`. """ return SpanLevel.from_span(self.span) @@ -111,9 +114,17 @@ def level(self, value: LevelName | int) -> None: @property def level_is_unset(self) -> bool: - """Determine if the level has not been explicitly set on the span. + """Determine if the level has not been explicitly set on the span (yet). - This generally happens when a span is not marked as escaping. + For messy technical reasons, this is typically `True` for spans created directly by an OpenTelemetry tracer + (e.g. from any `logfire.instrument_*()` method) + although the level will usually still eventually be `error` by the time it's exported. + + Spans created by `logfire.span()` get the level set to `error` immediately when an exception passes through, + so this will be `False` in that case. + + This is also typically `True` when calling `span.record_exception()` directly on any span + instead of letting an exception bubble through. """ return ATTRIBUTES_LOG_LEVEL_NUM_KEY not in (self.span.attributes or {}) @@ -129,7 +140,14 @@ def parent_span(self) -> ReadableSpan | None: def issue_fingerprint_source(self) -> str: """Returns a string that will be hashed to create the issue fingerprint. - By default this is a canonical representation of the exception traceback. + By default this is a canonical representation of the exception traceback: + + - The source line is used, but not the line number, so that changes elsewhere in a file are irrelevant. + - The module is used instead of the filename. + - The same line appearing multiple times in a stack is ignored. + - Exception group sub-exceptions are sorted and deduplicated. + - If the exception has a cause or (not suppressed) context, it is included in the representation. + - Cause and context are treated as different. """ if self._issue_fingerprint_source is not None: return self._issue_fingerprint_source @@ -159,8 +177,11 @@ def issue_fingerprint_source(self, value: str): def create_issue(self) -> bool: """Whether to create an issue for this exception. - By default, issues are only created for exceptions on spans with level 'error' or higher, - and for which no parent span exists in the current process. + By default, issues are only created for exceptions on spans where: + + - The level is 'error' or higher or is unset (see `level_is_unset` for details), + - No parent span exists in the current process, + - The exception isn't handled by FastAPI, except if it's a 5xx HTTPException. Example: if helper.create_issue: @@ -169,8 +190,6 @@ def create_issue(self) -> bool: if self._create_issue is not None: return self._create_issue - # Note: the level might not be set if dealing with a non-escaping exception, but that's expected for e.g. - # the root spans of web frameworks. return ( self._record_exception and (self.level_is_unset or self.level >= 'error') @@ -199,6 +218,9 @@ def no_record_exception(self) -> None: This will also prevent creating an issue for this exception. The span itself will still be recorded, just without the exception information. This doesn't affect the level of the span, it will still be 'error' by default. + To still record exception info without creating an issue, use `helper.create_issue = False` instead. + To still record the exception info but at a different level, use `helper.level = 'warning'` + or some other level instead. """ self._record_exception = False self._create_issue = False @@ -210,7 +232,8 @@ def no_record_exception(self) -> None: Usage: - def my_callback(helper: logfire.ExceptionCallbackHelper): + def my_callback(helper: logfire.types.ExceptionCallbackHelper): + # Use `helper` here to customize how an exception is recorded on a span. ... logfire.configure(advanced=logfire.AdvancedOptions(exception_callback=my_callback)) From 8cd41178aeca7e75f37187631b8b2280fdec988b Mon Sep 17 00:00:00 2001 From: Alex Hall Date: Fri, 3 Oct 2025 13:13:53 +0200 Subject: [PATCH 14/14] test_record_exception_directly --- tests/test_exceptions.py | 52 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 7a46786ba..f659c9940 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -13,6 +13,7 @@ def test_exception_callback_set_level(exporter: TestExporter, config_kwargs: dict[str, Any]): def exception_callback(helper: ExceptionCallbackHelper) -> None: assert helper.level.name == 'error' + assert not helper.level_is_unset assert helper.create_issue helper.level = 'warning' assert helper.level.name == 'warn' @@ -297,3 +298,54 @@ def exception_callback(helper: ExceptionCallbackHelper) -> None: } ] ) + + +def test_record_exception_directly(exporter: TestExporter, config_kwargs: dict[str, Any]): + def exception_callback(helper: ExceptionCallbackHelper) -> None: + assert helper.level_is_unset + assert helper.create_issue + + config_kwargs['advanced'].exception_callback = exception_callback + logfire.configure(**config_kwargs) + + with logfire.span('span') as span: + try: + raise ValueError('test') + except ValueError as e: + span.record_exception(e) + + (_pending, span) = exporter.exported_spans + assert span.attributes + + assert exporter.exported_spans_as_dict() == snapshot( + [ + { + 'name': 'span', + 'context': {'trace_id': 1, 'span_id': 1, 'is_remote': False}, + 'parent': None, + 'start_time': 1000000000, + 'end_time': 3000000000, + 'attributes': { + 'code.filepath': 'test_exceptions.py', + 'code.function': 'test_record_exception_directly', + 'code.lineno': 123, + 'logfire.msg_template': 'span', + 'logfire.msg': 'span', + 'logfire.span_type': 'span', + 'logfire.exception.fingerprint': '0000000000000000000000000000000000000000000000000000000000000000', + }, + 'events': [ + { + 'name': 'exception', + 'timestamp': 2000000000, + 'attributes': { + 'exception.type': 'ValueError', + 'exception.message': 'test', + 'exception.stacktrace': 'ValueError: test', + 'exception.escaped': 'False', + }, + } + ], + } + ] + )