Skip to content

Commit df8f36d

Browse files
alexmojakidmontagu
andauthored
Add experimental exception_callback configuration (#1355)
Co-authored-by: David Montague <[email protected]>
1 parent 3a09cbf commit df8f36d

21 files changed

+919
-104
lines changed

logfire/_internal/config.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
from logfire.version import VERSION
6767

6868
from ..propagate import NoExtractTraceContextPropagator, WarnOnExtractTraceContextPropagator
69+
from ..types import ExceptionCallback
6970
from .client import InvalidProjectName, LogfireClient, ProjectAlreadyExists
7071
from .config_params import ParamManager, PydanticPluginRecordValues
7172
from .constants import (
@@ -182,6 +183,11 @@ class AdvancedOptions:
182183
log_record_processors: Sequence[LogRecordProcessor] = ()
183184
"""Configuration for OpenTelemetry logging. This is experimental and may be removed."""
184185

186+
exception_callback: ExceptionCallback | None = None
187+
"""Callback function that is called when an exception is recorded on a span.
188+
189+
This is experimental and may be modified or removed."""
190+
185191
def generate_base_url(self, token: str) -> str:
186192
if self.base_url is not None:
187193
return self.base_url

logfire/_internal/exporters/test.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,8 @@ def build_attributes(
171171
k: process_attribute(k, v, strip_filepaths, fixed_line_number, strip_function_qualname, parse_json_attributes)
172172
for k, v in attributes.items()
173173
}
174-
attributes.pop(ATTRIBUTES_EXCEPTION_FINGERPRINT_KEY, None)
174+
if ATTRIBUTES_EXCEPTION_FINGERPRINT_KEY in attributes:
175+
attributes[ATTRIBUTES_EXCEPTION_FINGERPRINT_KEY] = '0' * 64
175176
if 'telemetry.sdk.version' in attributes:
176177
attributes['telemetry.sdk.version'] = '0.0.0'
177178
return attributes

logfire/_internal/main.py

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
from opentelemetry.context import Context
2727
from opentelemetry.metrics import CallbackT, Counter, Histogram, UpDownCounter
2828
from opentelemetry.sdk.trace import ReadableSpan, Span
29-
from opentelemetry.trace import SpanContext, Tracer
29+
from opentelemetry.trace import SpanContext
3030
from opentelemetry.util import types as otel_types
3131
from typing_extensions import LiteralString, ParamSpec
3232

@@ -60,7 +60,12 @@
6060
)
6161
from .metrics import ProxyMeterProvider
6262
from .stack_info import get_user_stack_info
63-
from .tracer import ProxyTracerProvider, _LogfireWrappedSpan, record_exception, set_exception_status # type: ignore
63+
from .tracer import (
64+
ProxyTracerProvider,
65+
_LogfireWrappedSpan, # type: ignore
66+
_ProxyTracer, # type: ignore
67+
set_exception_status,
68+
)
6469
from .utils import get_version, handle_internal_errors, log_internal_error, uniquify_sequence
6570

6671
if TYPE_CHECKING:
@@ -157,14 +162,14 @@ def _meter(self):
157162
return self._meter_provider.get_meter(self._otel_scope, VERSION)
158163

159164
@cached_property
160-
def _logs_tracer(self) -> Tracer:
165+
def _logs_tracer(self) -> _ProxyTracer:
161166
return self._get_tracer(is_span_tracer=False)
162167

163168
@cached_property
164-
def _spans_tracer(self) -> Tracer:
169+
def _spans_tracer(self) -> _ProxyTracer:
165170
return self._get_tracer(is_span_tracer=True)
166171

167-
def _get_tracer(self, *, is_span_tracer: bool) -> Tracer: # pragma: no cover
172+
def _get_tracer(self, *, is_span_tracer: bool) -> _ProxyTracer:
168173
return self._tracer_provider.get_tracer(
169174
self._otel_scope,
170175
VERSION,
@@ -759,7 +764,7 @@ def log(
759764
if isinstance(exc_info, tuple):
760765
exc_info = exc_info[1]
761766
if isinstance(exc_info, BaseException):
762-
record_exception(span, exc_info)
767+
span.record_exception(exc_info)
763768
if otlp_attributes[ATTRIBUTES_LOG_LEVEL_NUM_KEY] >= LEVEL_NUMBERS['error']: # type: ignore
764769
# Set the status description to the exception message.
765770
# OTEL only lets us set the description when the status code is ERROR,
@@ -2328,7 +2333,7 @@ def __init__(
23282333
self,
23292334
span_name: str,
23302335
otlp_attributes: dict[str, otel_types.AttributeValue],
2331-
tracer: Tracer,
2336+
tracer: _ProxyTracer,
23322337
json_schema_properties: JsonSchemaProperties,
23332338
links: Sequence[tuple[SpanContext, otel_types.Attributes]],
23342339
) -> None:
@@ -2340,7 +2345,7 @@ def __init__(
23402345

23412346
self._added_attributes = False
23422347
self._token: None | Token[Context] = None
2343-
self._span: None | trace_api.Span = None
2348+
self._span: None | _LogfireWrappedSpan = None
23442349

23452350
if not TYPE_CHECKING: # pragma: no branch
23462351

@@ -2458,11 +2463,7 @@ def record_exception(
24582463
if not self._span.is_recording():
24592464
return
24602465

2461-
span = self._span
2462-
while isinstance(span, _LogfireWrappedSpan):
2463-
span = span.span
2464-
record_exception(
2465-
span,
2466+
self._span.record_exception(
24662467
exception,
24672468
attributes=attributes,
24682469
timestamp=timestamp,

logfire/_internal/tracer.py

Lines changed: 57 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from opentelemetry.sdk.resources import Resource
1717
from opentelemetry.sdk.trace import (
1818
ReadableSpan,
19+
Span as SDKSpan,
1920
SpanProcessor,
2021
Tracer as SDKTracer,
2122
TracerProvider as SDKTracerProvider,
@@ -35,9 +36,13 @@
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

4041
if 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

4348
try:
@@ -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+
228245
def _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)

logfire/sampling/_tail_sampling.py

Lines changed: 2 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -11,58 +11,11 @@
1111
from typing_extensions import Self
1212

1313
from logfire._internal.constants import (
14-
ATTRIBUTES_LOG_LEVEL_NUM_KEY,
15-
LEVEL_NUMBERS,
16-
NUMBER_TO_LEVEL,
1714
ONE_SECOND_IN_NANOSECONDS,
1815
LevelName,
1916
)
2017
from logfire._internal.exporters.wrapper import WrapperSpanProcessor
21-
22-
23-
@dataclass
24-
class SpanLevel:
25-
"""A convenience class for comparing span/log levels.
26-
27-
Can be compared to log level names (strings) such as 'info' or 'error' using
28-
`<`, `>`, `<=`, or `>=`, so e.g. `level >= 'error'` is valid.
29-
30-
Will raise an exception if compared to a non-string or an invalid level name.
31-
"""
32-
33-
number: int
34-
"""
35-
The raw numeric value of the level. Higher values are more severe.
36-
"""
37-
38-
@property
39-
def name(self) -> LevelName | None:
40-
"""The human-readable name of the level, or `None` if the number is invalid."""
41-
return NUMBER_TO_LEVEL.get(self.number)
42-
43-
def __eq__(self, other: object):
44-
if isinstance(other, int):
45-
return self.number == other
46-
if isinstance(other, str):
47-
return self.name == other
48-
if isinstance(other, SpanLevel):
49-
return self.number == other.number
50-
return NotImplemented
51-
52-
def __hash__(self):
53-
return hash(self.number)
54-
55-
def __lt__(self, other: LevelName):
56-
return self.number < LEVEL_NUMBERS[other]
57-
58-
def __gt__(self, other: LevelName):
59-
return self.number > LEVEL_NUMBERS[other]
60-
61-
def __ge__(self, other: LevelName):
62-
return self.number >= LEVEL_NUMBERS[other]
63-
64-
def __le__(self, other: LevelName):
65-
return self.number <= LEVEL_NUMBERS[other]
18+
from logfire.types import SpanLevel
6619

6720

6821
@dataclass
@@ -111,11 +64,7 @@ class TailSamplingSpanInfo:
11164
@property
11265
def level(self) -> SpanLevel:
11366
"""The log level of the span."""
114-
attributes = self.span.attributes or {}
115-
level = attributes.get(ATTRIBUTES_LOG_LEVEL_NUM_KEY)
116-
if not isinstance(level, int):
117-
level = LEVEL_NUMBERS['info']
118-
return SpanLevel(level)
67+
return SpanLevel.from_span(self.span)
11968

12069
@property
12170
def duration(self) -> float:

0 commit comments

Comments
 (0)