Skip to content

Commit 026d8a6

Browse files
authored
Release v4.11.0 (#1458)
1 parent 3dd9bb2 commit 026d8a6

File tree

10 files changed

+182
-35
lines changed

10 files changed

+182
-35
lines changed

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# Release Notes
22

3+
## [v4.11.0] (2025-10-03)
4+
5+
* Add experimental `exception_callback` configuration by @alexmojaki in [#1355](https://github.com/pydantic/logfire/pull/1355)
6+
* Support Instrumenting Async SqlAlchemy Engines by @dhruv-ahuja in [#1425](https://github.com/pydantic/logfire/pull/1425)
7+
* Always collect `operation.cost` metric in spans by @alexmojaki in [#1435](https://github.com/pydantic/logfire/pull/1435)
8+
* Update `pyproject.toml` to be PEP639 compliant by @Kludex in [#1429](https://github.com/pydantic/logfire/pull/1429)
9+
* Improve `canonicalize_exception_traceback` for `RecursionError` by @alexmojaki in [#1455](https://github.com/pydantic/logfire/pull/1455)
10+
311
## [v4.10.0] (2025-09-24)
412

513
* Trigger `auth` command from `prompt` by @Kludex in [#1423](https://github.com/pydantic/logfire/pull/1423)
@@ -919,3 +927,4 @@ First release from new repo!
919927
[v4.8.0]: https://github.com/pydantic/logfire/compare/v4.7.0...v4.8.0
920928
[v4.9.0]: https://github.com/pydantic/logfire/compare/v4.8.0...v4.9.0
921929
[v4.10.0]: https://github.com/pydantic/logfire/compare/v4.9.0...v4.10.0
930+
[v4.11.0]: https://github.com/pydantic/logfire/compare/v4.10.0...v4.11.0

logfire-api/logfire_api/_internal/config.pyi

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import dataclasses
22
import requests
33
from ..propagate import NoExtractTraceContextPropagator as NoExtractTraceContextPropagator, WarnOnExtractTraceContextPropagator as WarnOnExtractTraceContextPropagator
4+
from ..types import ExceptionCallback as ExceptionCallback
45
from .client import InvalidProjectName as InvalidProjectName, LogfireClient as LogfireClient, ProjectAlreadyExists as ProjectAlreadyExists
56
from .config_params import ParamManager as ParamManager, PydanticPluginRecordValues as PydanticPluginRecordValues
67
from .constants import LEVEL_NUMBERS as LEVEL_NUMBERS, LevelName as LevelName, RESOURCE_ATTRIBUTES_CODE_ROOT_PATH as RESOURCE_ATTRIBUTES_CODE_ROOT_PATH, RESOURCE_ATTRIBUTES_CODE_WORK_DIR as RESOURCE_ATTRIBUTES_CODE_WORK_DIR, RESOURCE_ATTRIBUTES_DEPLOYMENT_ENVIRONMENT_NAME as RESOURCE_ATTRIBUTES_DEPLOYMENT_ENVIRONMENT_NAME, RESOURCE_ATTRIBUTES_VCS_REPOSITORY_REF_REVISION as RESOURCE_ATTRIBUTES_VCS_REPOSITORY_REF_REVISION, RESOURCE_ATTRIBUTES_VCS_REPOSITORY_URL as RESOURCE_ATTRIBUTES_VCS_REPOSITORY_URL
@@ -61,6 +62,7 @@ class AdvancedOptions:
6162
id_generator: IdGenerator = dataclasses.field(default_factory=Incomplete)
6263
ns_timestamp_generator: Callable[[], int] = ...
6364
log_record_processors: Sequence[LogRecordProcessor] = ...
65+
exception_callback: ExceptionCallback | None = ...
6466
def generate_base_url(self, token: str) -> str: ...
6567

6668
@dataclass

logfire-api/logfire_api/_internal/integrations/sqlalchemy.pyi

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
from collections.abc import Iterable
12
from logfire.integrations.sqlalchemy import CommenterOptions as CommenterOptions
23
from sqlalchemy import Engine
34
from sqlalchemy.ext.asyncio import AsyncEngine
4-
from typing import Any, Iterable
5+
from typing import Any
56

67
def instrument_sqlalchemy(engine: AsyncEngine | Engine | None, engines: Iterable[AsyncEngine | Engine] | None, enable_commenter: bool, commenter_options: CommenterOptions, **kwargs: Any) -> None:
78
"""Instrument the `sqlalchemy` module so that spans are automatically created for each query.

logfire-api/logfire_api/_internal/main.pyi

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ from .json_encoder import logfire_json_dumps as logfire_json_dumps
3030
from .json_schema import JsonSchemaProperties as JsonSchemaProperties, attributes_json_schema as attributes_json_schema, attributes_json_schema_properties as attributes_json_schema_properties, create_json_schema as create_json_schema
3131
from .metrics import ProxyMeterProvider as ProxyMeterProvider
3232
from .stack_info import get_user_stack_info as get_user_stack_info
33-
from .tracer import ProxyTracerProvider as ProxyTracerProvider, record_exception as record_exception, set_exception_status as set_exception_status
33+
from .tracer import ProxyTracerProvider as ProxyTracerProvider, _ProxyTracer, set_exception_status as set_exception_status
3434
from .utils import SysExcInfo as SysExcInfo, get_version as get_version, handle_internal_errors as handle_internal_errors, log_internal_error as log_internal_error, uniquify_sequence as uniquify_sequence
3535
from collections.abc import Iterable, Sequence
3636
from contextlib import AbstractContextManager
@@ -41,7 +41,7 @@ from opentelemetry.context import Context as Context
4141
from opentelemetry.instrumentation.asgi.types import ClientRequestHook, ClientResponseHook, ServerRequestHook
4242
from opentelemetry.metrics import CallbackT as CallbackT, Counter, Histogram, UpDownCounter, _Gauge as Gauge
4343
from opentelemetry.sdk.trace import ReadableSpan, Span
44-
from opentelemetry.trace import SpanContext, Tracer
44+
from opentelemetry.trace import SpanContext
4545
from opentelemetry.util import types as otel_types
4646
from pymongo.monitoring import CommandFailedEvent as CommandFailedEvent, CommandStartedEvent as CommandStartedEvent, CommandSucceededEvent as CommandSucceededEvent
4747
from sqlalchemy import Engine
@@ -785,7 +785,8 @@ class Logfire:
785785
library, specifically `SQLAlchemyInstrumentor().instrument()`, to which it passes `**kwargs`.
786786
787787
Args:
788-
engine: The `sqlalchemy` engine to instrument, or `None` to instrument all engines.
788+
engine: The `sqlalchemy` engine to instrument.
789+
engines: An iterable of `sqlalchemy` engines to instrument.
789790
enable_commenter: Adds comments to SQL queries performed by SQLAlchemy, so that database logs have additional context.
790791
commenter_options: Configure the tags to be added to the SQL comments.
791792
**kwargs: Additional keyword arguments to pass to the OpenTelemetry `instrument` methods.
@@ -1123,7 +1124,7 @@ class FastLogfireSpan:
11231124
def __exit__(self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: Any) -> None: ...
11241125

11251126
class LogfireSpan(ReadableSpan):
1126-
def __init__(self, span_name: str, otlp_attributes: dict[str, otel_types.AttributeValue], tracer: Tracer, json_schema_properties: JsonSchemaProperties, links: Sequence[tuple[SpanContext, otel_types.Attributes]]) -> None: ...
1127+
def __init__(self, span_name: str, otlp_attributes: dict[str, otel_types.AttributeValue], tracer: _ProxyTracer, json_schema_properties: JsonSchemaProperties, links: Sequence[tuple[SpanContext, otel_types.Attributes]]) -> None: ...
11271128
def __getattr__(self, name: str) -> Any: ...
11281129
def __enter__(self) -> LogfireSpan: ...
11291130
@handle_internal_errors

logfire-api/logfire_api/_internal/tracer.pyi

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import opentelemetry.trace as trace_api
2+
from ..types import ExceptionCallback as ExceptionCallback
23
from .config import LogfireConfig as LogfireConfig
34
from .constants import ATTRIBUTES_EXCEPTION_FINGERPRINT_KEY as ATTRIBUTES_EXCEPTION_FINGERPRINT_KEY, ATTRIBUTES_MESSAGE_KEY as ATTRIBUTES_MESSAGE_KEY, ATTRIBUTES_PENDING_SPAN_REAL_PARENT_KEY as ATTRIBUTES_PENDING_SPAN_REAL_PARENT_KEY, ATTRIBUTES_SAMPLE_RATE_KEY as ATTRIBUTES_SAMPLE_RATE_KEY, ATTRIBUTES_SPAN_TYPE_KEY as ATTRIBUTES_SPAN_TYPE_KEY, ATTRIBUTES_VALIDATION_ERROR_KEY as ATTRIBUTES_VALIDATION_ERROR_KEY, log_level_attributes as log_level_attributes
4-
from .utils import canonicalize_exception_traceback as canonicalize_exception_traceback, handle_internal_errors as handle_internal_errors, sha256_string as sha256_string
5+
from .utils import handle_internal_errors as handle_internal_errors, sha256_string as sha256_string
56
from _typeshed import Incomplete
67
from collections.abc import Mapping, Sequence
78
from dataclasses import dataclass, field
@@ -13,8 +14,10 @@ from opentelemetry.sdk.trace.id_generator import IdGenerator
1314
from opentelemetry.trace import Link as Link, Span, SpanContext, SpanKind, Tracer, TracerProvider
1415
from opentelemetry.trace.status import Status, StatusCode
1516
from opentelemetry.util import types as otel_types
17+
from starlette.exceptions import HTTPException
1618
from threading import Lock
1719
from typing import Any, Callable
20+
from typing_extensions import TypeIs
1821
from weakref import WeakKeyDictionary, WeakValueDictionary
1922

2023
OPEN_SPANS: WeakValueDictionary[tuple[int, int], _LogfireWrappedSpan]
@@ -55,6 +58,7 @@ class _LogfireWrappedSpan(trace_api.Span, ReadableSpan):
5558
ns_timestamp_generator: Callable[[], int]
5659
record_metrics: bool
5760
metrics: dict[str, SpanMetric] = field(default_factory=Incomplete)
61+
exception_callback: ExceptionCallback | None = ...
5862
def __post_init__(self) -> None: ...
5963
def end(self, end_time: int | None = None) -> None: ...
6064
def get_span_context(self) -> SpanContext: ...
@@ -70,6 +74,8 @@ class _LogfireWrappedSpan(trace_api.Span, ReadableSpan):
7074
def __exit__(self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: Any) -> None: ...
7175
def __getattr__(self, name: str) -> Any: ...
7276

77+
def get_parent_span(span: ReadableSpan) -> _LogfireWrappedSpan | None: ...
78+
7379
@dataclass
7480
class _ProxyTracer(Tracer):
7581
"""A tracer that wraps another internal tracer allowing it to be re-assigned."""
@@ -80,7 +86,7 @@ class _ProxyTracer(Tracer):
8086
def __hash__(self) -> int: ...
8187
def __eq__(self, other: object) -> bool: ...
8288
def set_tracer(self, tracer: Tracer) -> None: ...
83-
def start_span(self, name: str, context: Context | None = None, kind: SpanKind = ..., attributes: otel_types.Attributes = None, links: Sequence[Link] | None = None, start_time: int | None = None, record_exception: bool = True, set_status_on_exception: bool = True) -> Span: ...
89+
def start_span(self, name: str, context: Context | None = None, kind: SpanKind = ..., attributes: otel_types.Attributes = None, links: Sequence[Link] | None = None, start_time: int | None = None, record_exception: bool = True, set_status_on_exception: bool = True) -> _LogfireWrappedSpan: ...
8490
start_as_current_span = ...
8591

8692
class SuppressedTracer(Tracer):
@@ -107,7 +113,7 @@ def should_sample(span_context: SpanContext, attributes: Mapping[str, otel_types
107113
"""
108114
def get_sample_rate_from_attributes(attributes: otel_types.Attributes) -> float | None: ...
109115
@handle_internal_errors
110-
def record_exception(span: trace_api.Span, exception: BaseException, *, attributes: otel_types.Attributes = None, timestamp: int | None = None, escaped: bool = False) -> None:
116+
def record_exception(span: trace_api.Span, exception: BaseException, *, attributes: otel_types.Attributes = None, timestamp: int | None = None, escaped: bool = False, callback: ExceptionCallback | None = None) -> None:
111117
"""Similar to the OTEL SDK Span.record_exception method, with our own additions."""
112118
def set_exception_status(span: trace_api.Span, exception: BaseException): ...
113-
def is_starlette_http_exception_400(exception: BaseException) -> bool: ...
119+
def is_starlette_http_exception(exception: BaseException) -> TypeIs[HTTPException]: ...

logfire-api/logfire_api/sampling/_tail_sampling.pyi

Lines changed: 2 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,15 @@
11
from _typeshed import Incomplete
22
from dataclasses import dataclass
33
from functools import cached_property
4-
from logfire._internal.constants import ATTRIBUTES_LOG_LEVEL_NUM_KEY as ATTRIBUTES_LOG_LEVEL_NUM_KEY, LEVEL_NUMBERS as LEVEL_NUMBERS, LevelName as LevelName, NUMBER_TO_LEVEL as NUMBER_TO_LEVEL, ONE_SECOND_IN_NANOSECONDS as ONE_SECOND_IN_NANOSECONDS
4+
from logfire._internal.constants import LevelName as LevelName, ONE_SECOND_IN_NANOSECONDS as ONE_SECOND_IN_NANOSECONDS
55
from logfire._internal.exporters.wrapper import WrapperSpanProcessor as WrapperSpanProcessor
6+
from logfire.types import SpanLevel as SpanLevel
67
from opentelemetry import context
78
from opentelemetry.sdk.trace import ReadableSpan, Span, SpanProcessor
89
from opentelemetry.sdk.trace.sampling import Sampler
910
from typing import Callable, Literal
1011
from typing_extensions import Self
1112

12-
@dataclass
13-
class SpanLevel:
14-
"""A convenience class for comparing span/log levels.
15-
16-
Can be compared to log level names (strings) such as 'info' or 'error' using
17-
`<`, `>`, `<=`, or `>=`, so e.g. `level >= 'error'` is valid.
18-
19-
Will raise an exception if compared to a non-string or an invalid level name.
20-
"""
21-
number: int
22-
@property
23-
def name(self) -> LevelName | None:
24-
"""The human-readable name of the level, or `None` if the number is invalid."""
25-
def __eq__(self, other: object): ...
26-
def __hash__(self): ...
27-
def __lt__(self, other: LevelName): ...
28-
def __gt__(self, other: LevelName): ...
29-
def __ge__(self, other: LevelName): ...
30-
def __le__(self, other: LevelName): ...
31-
3213
@dataclass
3314
class TraceBuffer:
3415
"""Arguments of `SpanProcessor.on_start` and `SpanProcessor.on_end` for spans in a single trace.

logfire-api/logfire_api/types.pyi

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
from dataclasses import dataclass
2+
from logfire._internal.constants import ATTRIBUTES_LOG_LEVEL_NUM_KEY as ATTRIBUTES_LOG_LEVEL_NUM_KEY, LEVEL_NUMBERS as LEVEL_NUMBERS, LevelName as LevelName, NUMBER_TO_LEVEL as NUMBER_TO_LEVEL, log_level_attributes as log_level_attributes
3+
from logfire._internal.tracer import get_parent_span as get_parent_span
4+
from logfire._internal.utils import canonicalize_exception_traceback as canonicalize_exception_traceback
5+
from opentelemetry.sdk.trace import ReadableSpan, Span
6+
from opentelemetry.util import types as otel_types
7+
from typing import Callable
8+
9+
@dataclass
10+
class SpanLevel:
11+
"""A convenience class for comparing span/log levels.
12+
13+
Can be compared to log level names (strings) such as 'info' or 'error' using
14+
`<`, `>`, `<=`, or `>=`, so e.g. `level >= 'error'` is valid.
15+
16+
Will raise an exception if compared to a non-string or an invalid level name.
17+
"""
18+
number: int
19+
@classmethod
20+
def from_span(cls, span: ReadableSpan) -> SpanLevel:
21+
"""Create a SpanLevel from an OpenTelemetry span.
22+
23+
If the span has no level set, defaults to 'info'.
24+
"""
25+
@property
26+
def name(self) -> LevelName | None:
27+
"""The human-readable name of the level, or `None` if the number is invalid."""
28+
def __eq__(self, other: object): ...
29+
def __hash__(self): ...
30+
def __lt__(self, other: LevelName): ...
31+
def __gt__(self, other: LevelName): ...
32+
def __ge__(self, other: LevelName): ...
33+
def __le__(self, other: LevelName): ...
34+
35+
@dataclass
36+
class ExceptionCallbackHelper:
37+
"""Helper object passed to the exception callback.
38+
39+
This is experimental and may change significantly in future releases.
40+
"""
41+
span: Span
42+
exception: BaseException
43+
event_attributes: dict[str, otel_types.AttributeValue]
44+
@property
45+
def level(self) -> SpanLevel:
46+
"""Convenient way to see and compare the level of the span.
47+
48+
- When using `logfire.span` or `logfire.exception`, this is usually `error`.
49+
- Spans created directly by an OpenTelemetry tracer (e.g. from any `logfire.instrument_*()` method)
50+
typically don't have a level set, so this will return the default of `info`,
51+
but `level_is_unset` will be `True`.
52+
- FastAPI/Starlette 4xx HTTPExceptions are warnings.
53+
- Will be a different level if this is created by e.g. `logfire.info(..., _exc_info=True)`.
54+
"""
55+
@level.setter
56+
def level(self, value: LevelName | int) -> None:
57+
"""Override the level of the span.
58+
59+
For example:
60+
61+
helper.level = 'warning'
62+
"""
63+
@property
64+
def level_is_unset(self) -> bool:
65+
"""Determine if the level has not been explicitly set on the span (yet).
66+
67+
For messy technical reasons, this is typically `True` for spans created directly by an OpenTelemetry tracer
68+
(e.g. from any `logfire.instrument_*()` method)
69+
although the level will usually still eventually be `error` by the time it's exported.
70+
71+
Spans created by `logfire.span()` get the level set to `error` immediately when an exception passes through,
72+
so this will be `False` in that case.
73+
74+
This is also typically `True` when calling `span.record_exception()` directly on any span
75+
instead of letting an exception bubble through.
76+
"""
77+
@property
78+
def parent_span(self) -> ReadableSpan | None:
79+
"""The parent span of the span the exception was recorded on.
80+
81+
This is `None` if there is no parent span, or if the parent span is in a different process.
82+
"""
83+
@property
84+
def issue_fingerprint_source(self) -> str:
85+
"""Returns a string that will be hashed to create the issue fingerprint.
86+
87+
By default this is a canonical representation of the exception traceback:
88+
89+
- The source line is used, but not the line number, so that changes elsewhere in a file are irrelevant.
90+
- The module is used instead of the filename.
91+
- The same line appearing multiple times in a stack is ignored.
92+
- Exception group sub-exceptions are sorted and deduplicated.
93+
- If the exception has a cause or (not suppressed) context, it is included in the representation.
94+
- Cause and context are treated as different.
95+
"""
96+
@issue_fingerprint_source.setter
97+
def issue_fingerprint_source(self, value: str):
98+
'''Override the string that will be hashed to create the issue fingerprint.
99+
100+
For example, if you want all exceptions of a certain type to be grouped into the same issue,
101+
you could do something like:
102+
103+
if isinstance(helper.exception, MyCustomError):
104+
helper.issue_fingerprint_source = "MyCustomError"
105+
106+
Or if you want to add the exception message to make grouping more granular:
107+
108+
helper.issue_fingerprint_source += str(helper.exception)
109+
110+
Note that setting this property automatically sets `create_issue` to True.
111+
'''
112+
@property
113+
def create_issue(self) -> bool:
114+
'''Whether to create an issue for this exception.
115+
116+
By default, issues are only created for exceptions on spans where:
117+
118+
- The level is \'error\' or higher or is unset (see `level_is_unset` for details),
119+
- No parent span exists in the current process,
120+
- The exception isn\'t handled by FastAPI, except if it\'s a 5xx HTTPException.
121+
122+
Example:
123+
if helper.create_issue:
124+
helper.issue_fingerprint_source = "MyCustomError"
125+
'''
126+
@create_issue.setter
127+
def create_issue(self, value: bool):
128+
"""Override whether to create an issue for this exception.
129+
130+
For example, if you want to create issues for all exceptions, even warnings:
131+
132+
helper.create_issue = True
133+
134+
Issues can only be created if the exception is recorded on the span.
135+
"""
136+
def no_record_exception(self) -> None:
137+
"""Call this method to prevent recording the exception on the span.
138+
139+
This improves performance and reduces noise in Logfire.
140+
This will also prevent creating an issue for this exception.
141+
The span itself will still be recorded, just without the exception information.
142+
This doesn't affect the level of the span, it will still be 'error' by default.
143+
To still record exception info without creating an issue, use `helper.create_issue = False` instead.
144+
To still record the exception info but at a different level, use `helper.level = 'warning'`
145+
or some other level instead.
146+
"""
147+
ExceptionCallback = Callable[[ExceptionCallbackHelper], None]

logfire-api/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "logfire-api"
7-
version = "4.10.0"
7+
version = "4.11.0"
88
description = "Shim for the Logfire SDK which does nothing unless Logfire is installed"
99
authors = [
1010
{ name = "Pydantic Team", email = "[email protected]" },

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "logfire"
7-
version = "4.10.0"
7+
version = "4.11.0"
88
description = "The best Python observability tool! 🪵🔥"
99
requires-python = ">=3.9"
1010
authors = [

0 commit comments

Comments
 (0)