16
16
from opentelemetry .sdk .resources import Resource
17
17
from opentelemetry .sdk .trace import (
18
18
ReadableSpan ,
19
+ Span as SDKSpan ,
19
20
SpanProcessor ,
20
21
Tracer as SDKTracer ,
21
22
TracerProvider as SDKTracerProvider ,
35
36
ATTRIBUTES_VALIDATION_ERROR_KEY ,
36
37
log_level_attributes ,
37
38
)
38
- from .utils import canonicalize_exception_traceback , handle_internal_errors , sha256_string
39
+ from .utils import handle_internal_errors , sha256_string
39
40
40
41
if TYPE_CHECKING :
42
+ from starlette .exceptions import HTTPException
43
+ from typing_extensions import TypeIs
44
+
45
+ from ..types import ExceptionCallback
41
46
from .config import LogfireConfig
42
47
43
48
try :
@@ -146,6 +151,7 @@ class _LogfireWrappedSpan(trace_api.Span, ReadableSpan):
146
151
ns_timestamp_generator : Callable [[], int ]
147
152
record_metrics : bool
148
153
metrics : dict [str , SpanMetric ] = field (default_factory = lambda : defaultdict (SpanMetric ))
154
+ exception_callback : ExceptionCallback | None = None
149
155
150
156
def __post_init__ (self ):
151
157
OPEN_SPANS [self ._open_spans_key ()] = self
@@ -203,14 +209,21 @@ def record_exception(
203
209
escaped : bool = False ,
204
210
) -> None :
205
211
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
+ )
207
220
208
221
def increment_metric (self , name : str , attributes : Mapping [str , otel_types .AttributeValue ], value : float ) -> None :
209
222
if not (self .is_recording () and (self .record_metrics or name == 'operation.cost' )):
210
223
return
211
224
212
225
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 ):
214
227
parent .increment_metric (name , attributes , value )
215
228
216
229
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:
225
238
return getattr (self .span , name )
226
239
227
240
241
+ def get_parent_span (span : ReadableSpan ) -> _LogfireWrappedSpan | None :
242
+ return span .parent and OPEN_SPANS .get (_open_spans_key (span .parent ))
243
+
244
+
228
245
def _open_spans_key (ctx : SpanContext ) -> tuple [int , int ]:
229
246
return ctx .trace_id , ctx .span_id
230
247
@@ -257,10 +274,11 @@ def start_span(
257
274
start_time : int | None = None ,
258
275
record_exception : bool = True ,
259
276
set_status_on_exception : bool = True ,
260
- ) -> Span :
277
+ ) -> _LogfireWrappedSpan :
261
278
config = self .provider .config
262
279
ns_timestamp_generator = config .advanced .ns_timestamp_generator
263
280
record_metrics : bool = not isinstance (config .metrics , (bool , type (None ))) and config .metrics .collect_in_spans
281
+ exception_callback = config .advanced .exception_callback
264
282
265
283
start_time = start_time or ns_timestamp_generator ()
266
284
@@ -289,6 +307,7 @@ def start_span(
289
307
span ,
290
308
ns_timestamp_generator = ns_timestamp_generator ,
291
309
record_metrics = record_metrics ,
310
+ exception_callback = exception_callback ,
292
311
)
293
312
294
313
# This means that `with start_as_current_span(...):`
@@ -399,10 +418,23 @@ def record_exception(
399
418
attributes : otel_types .Attributes = None ,
400
419
timestamp : int | None = None ,
401
420
escaped : bool = False ,
421
+ callback : ExceptionCallback | None = None ,
402
422
) -> None :
403
423
"""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' ))
406
438
407
439
# From https://opentelemetry.io/docs/specs/semconv/attributes-registry/exception/
408
440
# `escaped=True` means that the exception is escaping the scope of the span.
@@ -412,7 +444,20 @@ def record_exception(
412
444
set_exception_status (span , exception )
413
445
span .set_attributes (log_level_attributes ('error' ))
414
446
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
416
461
if ValidationError is not None and isinstance (exception , ValidationError ):
417
462
# insert a more detailed breakdown of pydantic errors
418
463
try :
@@ -430,7 +475,9 @@ def record_exception(
430
475
stacktrace = '' .join (traceback .format_exception (type (exception ), exception , exception .__traceback__ ))
431
476
attributes ['exception.stacktrace' ] = stacktrace
432
477
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
+
434
481
span .record_exception (exception , attributes = attributes , timestamp = timestamp , escaped = escaped )
435
482
436
483
@@ -443,10 +490,10 @@ def set_exception_status(span: trace_api.Span, exception: BaseException):
443
490
)
444
491
445
492
446
- def is_starlette_http_exception_400 (exception : BaseException ) -> bool :
493
+ def is_starlette_http_exception (exception : BaseException ) -> TypeIs [ HTTPException ] :
447
494
if 'starlette.exceptions' not in sys .modules : # pragma: no cover
448
495
return False
449
496
450
497
from starlette .exceptions import HTTPException
451
498
452
- return isinstance (exception , HTTPException ) and 400 <= exception . status_code < 500
499
+ return isinstance (exception , HTTPException )
0 commit comments