1616from opentelemetry .sdk .resources import Resource
1717from opentelemetry .sdk .trace import (
1818 ReadableSpan ,
19+ Span as SDKSpan ,
1920 SpanProcessor ,
2021 Tracer as SDKTracer ,
2122 TracerProvider as SDKTracerProvider ,
3536 ATTRIBUTES_VALIDATION_ERROR_KEY ,
3637 log_level_attributes ,
3738)
38- from .utils import canonicalize_exception_traceback , handle_internal_errors , sha256_string
39+ from .utils import handle_internal_errors , sha256_string
3940
4041if TYPE_CHECKING :
42+ from starlette .exceptions import HTTPException
43+ from typing_extensions import TypeIs
44+
45+ from ..types import ExceptionCallback
4146 from .config import LogfireConfig
4247
4348try :
@@ -146,6 +151,7 @@ class _LogfireWrappedSpan(trace_api.Span, ReadableSpan):
146151 ns_timestamp_generator : Callable [[], int ]
147152 record_metrics : bool
148153 metrics : dict [str , SpanMetric ] = field (default_factory = lambda : defaultdict (SpanMetric ))
154+ exception_callback : ExceptionCallback | None = None
149155
150156 def __post_init__ (self ):
151157 OPEN_SPANS [self ._open_spans_key ()] = self
@@ -203,14 +209,21 @@ def record_exception(
203209 escaped : bool = False ,
204210 ) -> None :
205211 timestamp = timestamp or self .ns_timestamp_generator ()
206- record_exception (self .span , exception , attributes = attributes , timestamp = timestamp , escaped = escaped )
212+ record_exception (
213+ self .span ,
214+ exception ,
215+ attributes = attributes ,
216+ timestamp = timestamp ,
217+ escaped = escaped ,
218+ callback = self .exception_callback ,
219+ )
207220
208221 def increment_metric (self , name : str , attributes : Mapping [str , otel_types .AttributeValue ], value : float ) -> None :
209222 if not (self .is_recording () and (self .record_metrics or name == 'operation.cost' )):
210223 return
211224
212225 self .metrics [name ].increment (attributes , value )
213- if self . parent and ( parent := OPEN_SPANS . get ( _open_spans_key ( self . parent )) ):
226+ if parent := get_parent_span ( self ):
214227 parent .increment_metric (name , attributes , value )
215228
216229 def __exit__ (self , exc_type : type [BaseException ] | None , exc_value : BaseException | None , traceback : Any ) -> None :
@@ -225,6 +238,10 @@ def __getattr__(self, name: str) -> Any:
225238 return getattr (self .span , name )
226239
227240
241+ def get_parent_span (span : ReadableSpan ) -> _LogfireWrappedSpan | None :
242+ return span .parent and OPEN_SPANS .get (_open_spans_key (span .parent ))
243+
244+
228245def _open_spans_key (ctx : SpanContext ) -> tuple [int , int ]:
229246 return ctx .trace_id , ctx .span_id
230247
@@ -257,10 +274,11 @@ def start_span(
257274 start_time : int | None = None ,
258275 record_exception : bool = True ,
259276 set_status_on_exception : bool = True ,
260- ) -> Span :
277+ ) -> _LogfireWrappedSpan :
261278 config = self .provider .config
262279 ns_timestamp_generator = config .advanced .ns_timestamp_generator
263280 record_metrics : bool = not isinstance (config .metrics , (bool , type (None ))) and config .metrics .collect_in_spans
281+ exception_callback = config .advanced .exception_callback
264282
265283 start_time = start_time or ns_timestamp_generator ()
266284
@@ -289,6 +307,7 @@ def start_span(
289307 span ,
290308 ns_timestamp_generator = ns_timestamp_generator ,
291309 record_metrics = record_metrics ,
310+ exception_callback = exception_callback ,
292311 )
293312
294313 # This means that `with start_as_current_span(...):`
@@ -399,10 +418,23 @@ def record_exception(
399418 attributes : otel_types .Attributes = None ,
400419 timestamp : int | None = None ,
401420 escaped : bool = False ,
421+ callback : ExceptionCallback | None = None ,
402422) -> None :
403423 """Similar to the OTEL SDK Span.record_exception method, with our own additions."""
404- if is_starlette_http_exception_400 (exception ):
405- span .set_attributes (log_level_attributes ('warn' ))
424+ from ..types import ExceptionCallbackHelper
425+
426+ if is_starlette_http_exception (exception ):
427+ if 400 <= exception .status_code < 500 :
428+ # Don't mark 4xx HTTP exceptions as errors, they are expected to happen in normal operation.
429+ # But do record them as warnings.
430+ span .set_attributes (log_level_attributes ('warn' ))
431+ elif exception .status_code >= 500 :
432+ # Set this as an error now for ExceptionCallbackHelper.create_issue to see,
433+ # particularly so that if this is raised in a FastAPI pseudo_span and the event is marked with
434+ # the recorded_by_logfire_fastapi it will still create an issue in this case.
435+ # FastAPI will 'handle' this exception meaning it won't get recorded again by OTel.
436+ set_exception_status (span , exception )
437+ span .set_attributes (log_level_attributes ('error' ))
406438
407439 # From https://opentelemetry.io/docs/specs/semconv/attributes-registry/exception/
408440 # `escaped=True` means that the exception is escaping the scope of the span.
@@ -412,7 +444,20 @@ def record_exception(
412444 set_exception_status (span , exception )
413445 span .set_attributes (log_level_attributes ('error' ))
414446
415- attributes = {** (attributes or {})}
447+ helper = ExceptionCallbackHelper (
448+ span = cast (SDKSpan , span ),
449+ exception = exception ,
450+ event_attributes = {** (attributes or {})},
451+ )
452+
453+ if callback is not None :
454+ with handle_internal_errors :
455+ callback (helper )
456+
457+ if not helper ._record_exception : # type: ignore
458+ return
459+
460+ attributes = helper .event_attributes
416461 if ValidationError is not None and isinstance (exception , ValidationError ):
417462 # insert a more detailed breakdown of pydantic errors
418463 try :
@@ -430,7 +475,9 @@ def record_exception(
430475 stacktrace = '' .join (traceback .format_exception (type (exception ), exception , exception .__traceback__ ))
431476 attributes ['exception.stacktrace' ] = stacktrace
432477
433- span .set_attribute (ATTRIBUTES_EXCEPTION_FINGERPRINT_KEY , sha256_string (canonicalize_exception_traceback (exception )))
478+ if helper .create_issue :
479+ span .set_attribute (ATTRIBUTES_EXCEPTION_FINGERPRINT_KEY , sha256_string (helper .issue_fingerprint_source ))
480+
434481 span .record_exception (exception , attributes = attributes , timestamp = timestamp , escaped = escaped )
435482
436483
@@ -443,10 +490,10 @@ def set_exception_status(span: trace_api.Span, exception: BaseException):
443490 )
444491
445492
446- def is_starlette_http_exception_400 (exception : BaseException ) -> bool :
493+ def is_starlette_http_exception (exception : BaseException ) -> TypeIs [ HTTPException ] :
447494 if 'starlette.exceptions' not in sys .modules : # pragma: no cover
448495 return False
449496
450497 from starlette .exceptions import HTTPException
451498
452- return isinstance (exception , HTTPException ) and 400 <= exception . status_code < 500
499+ return isinstance (exception , HTTPException )
0 commit comments