From 36000dd647e3f824919c3c699dd5db7c61a493c0 Mon Sep 17 00:00:00 2001 From: imp-joshi Date: Sat, 18 Apr 2026 17:18:48 +0530 Subject: [PATCH 1/9] add _level param to @logfire.instrument() - adds _level: LevelName | int | None = None to Logfire.instrument() - level attrs merged into span attributes at decoration time (static) - _level=None leaves the existing fast path untouched - test: warn-level span carries logfire.level_num=13 --- logfire/_internal/instrument.py | 14 ++++++++++++-- logfire/_internal/main.py | 16 +++++++++++++++- tests/test_logfire.py | 28 ++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/logfire/_internal/instrument.py b/logfire/_internal/instrument.py index 3f80ceea0..3d4542abf 100644 --- a/logfire/_internal/instrument.py +++ b/logfire/_internal/instrument.py @@ -12,7 +12,12 @@ from opentelemetry.util import types as otel_types from typing_extensions import LiteralString, ParamSpec -from .constants import ATTRIBUTES_MESSAGE_TEMPLATE_KEY, ATTRIBUTES_TAGS_KEY +from .constants import ( + ATTRIBUTES_MESSAGE_TEMPLATE_KEY, + ATTRIBUTES_TAGS_KEY, + LevelName, + log_level_attributes, +) from .stack_info import get_filepath_attribute from .utils import safe_repr, uniquify_sequence @@ -52,6 +57,7 @@ def instrument( record_return: bool, allow_generator: bool, new_trace: bool, + _level: LevelName | int | None = None, ) -> Callable[[Callable[P, R]], Callable[P, R]]: from .main import set_user_attributes_on_raw_span @@ -63,7 +69,7 @@ def decorator(func: Callable[P, R]) -> Callable[P, R]: ) attributes = get_attributes(func, msg_template, tags) - open_span = get_open_span(logfire, attributes, span_name, extract_args, func, new_trace) + open_span = get_open_span(logfire, attributes, span_name, extract_args, func, new_trace, _level=_level) if inspect.isgeneratorfunction(func): if not allow_generator: @@ -122,9 +128,13 @@ def get_open_span( extract_args: bool | Iterable[str], func: Callable[P, R], new_trace: bool, + _level: LevelName | int | None = None, ) -> Callable[P, AbstractContextManager[Any]]: final_span_name: str = span_name or attributes[ATTRIBUTES_MESSAGE_TEMPLATE_KEY] # pyright: ignore[reportAssignmentType] + if _level is not None: + attributes = {**attributes, **log_level_attributes(_level)} + def get_logfire(): # This avoids having a `logfire` closure variable, which would make the instrumented # function unpicklable with cloudpickle. diff --git a/logfire/_internal/main.py b/logfire/_internal/main.py index b2bb307e0..919e6a9c0 100644 --- a/logfire/_internal/main.py +++ b/logfire/_internal/main.py @@ -608,6 +608,7 @@ def instrument( record_return: bool = False, allow_generator: bool = False, new_trace: bool = False, + _level: LevelName | int | None = None, ) -> Callable[[Callable[P, R]], Callable[P, R]]: """Decorator for instrumenting a function as a span. @@ -633,6 +634,8 @@ def my_function(a: int): Read https://logfire.pydantic.dev/docs/guides/advanced/generators/#using-logfireinstrument first. new_trace: Set to `True` to start a new trace with a span link to the current span instead of creating a child of the current span. + _level: The log level for the span. If provided, the span will be tagged with this level + and suppressed if the level is below the configured `min_log_level`. """ @overload @@ -660,6 +663,7 @@ def instrument( # pyright: ignore[reportInconsistentOverload] record_return: bool = False, allow_generator: bool = False, new_trace: bool = False, + _level: LevelName | int | None = None, ) -> Callable[[Callable[P, R]], Callable[P, R]] | Callable[P, R]: """Decorator for instrumenting a function as a span. @@ -685,11 +689,21 @@ def my_function(a: int): Read https://logfire.pydantic.dev/docs/guides/advanced/generators/#using-logfireinstrument first. new_trace: Set to `True` to start a new trace with a span link to the current span instead of creating a child of the current span. + _level: The log level for the span. If provided, the span will be tagged with this level + and suppressed if the level is below the configured `min_log_level`. """ if callable(msg_template): return self.instrument()(msg_template) return instrument( - self, tuple(self._tags), msg_template, span_name, extract_args, record_return, allow_generator, new_trace + self, + tuple(self._tags), + msg_template, + span_name, + extract_args, + record_return, + allow_generator, + new_trace, + _level=_level, ) def log( diff --git a/tests/test_logfire.py b/tests/test_logfire.py index a3a145558..62a38dae6 100644 --- a/tests/test_logfire.py +++ b/tests/test_logfire.py @@ -65,6 +65,34 @@ def test_log_methods_without_kwargs(method: str): """) +def test_instrument_with_level(exporter: TestExporter) -> None: + @logfire.instrument('my span', _level='warn', extract_args=False) + def my_func() -> str: + return 'ok' + + assert my_func() == 'ok' + assert exporter.exported_spans_as_dict() == snapshot( + [ + { + 'name': 'my span', + 'context': {'trace_id': 1, 'span_id': 1, 'is_remote': False}, + 'parent': None, + 'start_time': 1000000000, + 'end_time': 2000000000, + 'attributes': { + 'code.function': 'my_func', + 'logfire.msg_template': 'my span', + 'code.lineno': 123, + 'code.filepath': 'test_logfire.py', + 'logfire.level_num': 13, + 'logfire.span_type': 'span', + 'logfire.msg': 'my span', + }, + } + ] + ) + + def test_instrument_with_no_args(exporter: TestExporter) -> None: @logfire.instrument() def foo(x: int): From 6d1c69e75056e452b31fd99cf6e120de9740d415 Mon Sep 17 00:00:00 2001 From: imp-joshi Date: Sat, 18 Apr 2026 22:20:37 +0530 Subject: [PATCH 2/9] filter instrumented spans by min_log_level; fix NoopSpan._span - extracts level_num at decoration time for O(1) call-time check - guard (level_num < config.min_level) added to all three open_span variants - NoopSpan._span property returns trace_api.INVALID_SPAN - set_user_attributes_on_raw_span exits cleanly via is_recording(); fixes latent crash on record_return=True --- logfire/_internal/instrument.py | 18 +++++++++++++++++- logfire/_internal/main.py | 5 +++++ tests/test_logfire.py | 24 ++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/logfire/_internal/instrument.py b/logfire/_internal/instrument.py index 3d4542abf..731e4d8e2 100644 --- a/logfire/_internal/instrument.py +++ b/logfire/_internal/instrument.py @@ -13,6 +13,7 @@ from typing_extensions import LiteralString, ParamSpec from .constants import ( + ATTRIBUTES_LOG_LEVEL_NUM_KEY, ATTRIBUTES_MESSAGE_TEMPLATE_KEY, ATTRIBUTES_TAGS_KEY, LevelName, @@ -132,8 +133,11 @@ def get_open_span( ) -> Callable[P, AbstractContextManager[Any]]: final_span_name: str = span_name or attributes[ATTRIBUTES_MESSAGE_TEMPLATE_KEY] # pyright: ignore[reportAssignmentType] + level_num: int | None = None if _level is not None: - attributes = {**attributes, **log_level_attributes(_level)} + _level_attrs = log_level_attributes(_level) + level_num = int(_level_attrs[ATTRIBUTES_LOG_LEVEL_NUM_KEY]) + attributes = {**attributes, **_level_attrs} def get_logfire(): # This avoids having a `logfire` closure variable, which would make the instrumented @@ -166,6 +170,10 @@ def extra_span_kwargs() -> dict[str, Any]: # This is the fast case for when there are no arguments to extract def open_span(*_: P.args, **__: P.kwargs): # pyright: ignore[reportRedeclaration] + if level_num is not None and level_num < get_logfire().config.min_level: + from .main import NoopSpan + + return NoopSpan() return get_logfire()._fast_span(final_span_name, attributes, **extra_span_kwargs()) # pyright: ignore[reportPrivateUsage] if extract_args is True: @@ -173,6 +181,10 @@ def open_span(*_: P.args, **__: P.kwargs): # pyright: ignore[reportRedeclaratio if sig.parameters: # only extract args if there are any def open_span(*func_args: P.args, **func_kwargs: P.kwargs): + if level_num is not None and level_num < get_logfire().config.min_level: + from .main import NoopSpan + + return NoopSpan() bound = sig.bind(*func_args, **func_kwargs) bound.apply_defaults() args_dict = bound.arguments @@ -200,6 +212,10 @@ def open_span(*func_args: P.args, **func_kwargs: P.kwargs): if extract_args_final: # check that there are still arguments to extract def open_span(*func_args: P.args, **func_kwargs: P.kwargs): + if level_num is not None and level_num < get_logfire().config.min_level: + from .main import NoopSpan + + return NoopSpan() bound = sig.bind(*func_args, **func_kwargs) bound.apply_defaults() args_dict = bound.arguments diff --git a/logfire/_internal/main.py b/logfire/_internal/main.py index 919e6a9c0..6e8d2627d 100644 --- a/logfire/_internal/main.py +++ b/logfire/_internal/main.py @@ -3097,6 +3097,11 @@ def message(self) -> str: # pragma: no cover def message(self, message: str): pass + @property + def _span(self) -> Any: + # set_user_attributes_on_raw_span checks is_recording() first; INVALID_SPAN returns False + return trace_api.INVALID_SPAN + def is_recording(self) -> bool: return False diff --git a/tests/test_logfire.py b/tests/test_logfire.py index 62a38dae6..2846235c5 100644 --- a/tests/test_logfire.py +++ b/tests/test_logfire.py @@ -93,6 +93,30 @@ def my_func() -> str: ) +def test_instrument_level_filtered(exporter: TestExporter, config_kwargs: dict[str, Any]) -> None: + config_kwargs['min_level'] = 'info' + logfire.configure(**config_kwargs) + + @logfire.instrument('my span', _level='debug', extract_args=False) + def my_func() -> str: + return 'ok' + + assert my_func() == 'ok' + assert exporter.exported_spans_as_dict() == [] + + +def test_instrument_level_filtered_record_return(exporter: TestExporter, config_kwargs: dict[str, Any]) -> None: + config_kwargs['min_level'] = 'info' + logfire.configure(**config_kwargs) + + @logfire.instrument('my span', _level='debug', extract_args=False, record_return=True) + def my_func() -> str: + return 'ok' + + assert my_func() == 'ok' + assert exporter.exported_spans_as_dict() == [] + + def test_instrument_with_no_args(exporter: TestExporter) -> None: @logfire.instrument() def foo(x: int): From 06ac5701a50c8df5efa54a89e2643f05d8f5d86c Mon Sep 17 00:00:00 2001 From: imp-joshi Date: Sat, 18 Apr 2026 22:23:31 +0530 Subject: [PATCH 3/9] add async and extract_args tests for _level; update logfire-api shim - async path works via same open_span closure, verified with snapshot (logfire.level_num: 13) - extract_args + _level test verifies both attrs appear together - shim gets explicit _level=None kwarg --- logfire-api/logfire_api/__init__.py | 2 +- tests/test_logfire.py | 59 +++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/logfire-api/logfire_api/__init__.py b/logfire-api/logfire_api/__init__.py index 855c35c6d..24f5b27d2 100644 --- a/logfire-api/logfire_api/__init__.py +++ b/logfire-api/logfire_api/__init__.py @@ -131,7 +131,7 @@ def log_slow_async_callbacks(self, *args, **kwargs) -> None: # pragma: no cover def install_auto_tracing(self, *args, **kwargs) -> None: ... - def instrument(self, *args, **kwargs): + def instrument(self, *args, _level=None, **kwargs): def decorator(func): return func diff --git a/tests/test_logfire.py b/tests/test_logfire.py index 2846235c5..cc404f343 100644 --- a/tests/test_logfire.py +++ b/tests/test_logfire.py @@ -117,6 +117,65 @@ def my_func() -> str: assert exporter.exported_spans_as_dict() == [] +@pytest.mark.anyio +async def test_instrument_with_level_async(exporter: TestExporter) -> None: + @logfire.instrument('async span', _level='warn', extract_args=False) + async def my_async_func() -> str: + return 'ok' + + assert await my_async_func() == 'ok' + assert exporter.exported_spans_as_dict() == snapshot( + [ + { + 'name': 'async span', + 'context': {'trace_id': 1, 'span_id': 1, 'is_remote': False}, + 'parent': None, + 'start_time': 1000000000, + 'end_time': 2000000000, + 'attributes': { + 'code.function': 'my_async_func', + 'logfire.msg_template': 'async span', + 'code.lineno': 123, + 'code.filepath': 'test_logfire.py', + 'logfire.level_num': 13, + 'logfire.span_type': 'span', + 'logfire.msg': 'async span', + }, + } + ] + ) + + +def test_instrument_with_level_and_extract_args(exporter: TestExporter) -> None: + @logfire.instrument('span {x=}', _level='warn') + def my_func(x: int) -> int: + return x * 2 + + assert my_func(5) == 10 + assert exporter.exported_spans_as_dict() == snapshot( + [ + { + 'name': 'span {x=}', + 'context': {'trace_id': 1, 'span_id': 1, 'is_remote': False}, + 'parent': None, + 'start_time': 1000000000, + 'end_time': 2000000000, + 'attributes': { + 'code.function': 'my_func', + 'logfire.msg_template': 'span {x=}', + 'code.lineno': 123, + 'code.filepath': 'test_logfire.py', + 'logfire.level_num': 13, + 'logfire.msg': 'span x=5', + 'logfire.json_schema': '{"type":"object","properties":{"x":{}}}', + 'x': 5, + 'logfire.span_type': 'span', + }, + } + ] + ) + + def test_instrument_with_no_args(exporter: TestExporter) -> None: @logfire.instrument() def foo(x: int): From eff21b1b88dde98d9311c69cac2addc491745f8b Mon Sep 17 00:00:00 2001 From: imp-joshi Date: Sat, 18 Apr 2026 22:50:21 +0530 Subject: [PATCH 4/9] fix docstring: use min_level in instrument() _level param docs --- logfire/_internal/main.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/logfire/_internal/main.py b/logfire/_internal/main.py index 6e8d2627d..54bbed391 100644 --- a/logfire/_internal/main.py +++ b/logfire/_internal/main.py @@ -635,7 +635,7 @@ def my_function(a: int): new_trace: Set to `True` to start a new trace with a span link to the current span instead of creating a child of the current span. _level: The log level for the span. If provided, the span will be tagged with this level - and suppressed if the level is below the configured `min_log_level`. + and suppressed if the level is below the configured `min_level`. """ @overload @@ -690,7 +690,7 @@ def my_function(a: int): new_trace: Set to `True` to start a new trace with a span link to the current span instead of creating a child of the current span. _level: The log level for the span. If provided, the span will be tagged with this level - and suppressed if the level is below the configured `min_log_level`. + and suppressed if the level is below the configured `min_level`. """ if callable(msg_template): return self.instrument()(msg_template) From 33712b03a18735a393f6471faa899aca68c6b097 Mon Sep 17 00:00:00 2001 From: imp-joshi Date: Sat, 18 Apr 2026 23:18:35 +0530 Subject: [PATCH 5/9] fix snapshots: use full qualname and _strip_function_qualname=False for py3.9/3.10 compat --- tests/test_logfire.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/test_logfire.py b/tests/test_logfire.py index cc404f343..4e935e28e 100644 --- a/tests/test_logfire.py +++ b/tests/test_logfire.py @@ -71,7 +71,7 @@ def my_func() -> str: return 'ok' assert my_func() == 'ok' - assert exporter.exported_spans_as_dict() == snapshot( + assert exporter.exported_spans_as_dict(_strip_function_qualname=False) == snapshot( [ { 'name': 'my span', @@ -80,7 +80,7 @@ def my_func() -> str: 'start_time': 1000000000, 'end_time': 2000000000, 'attributes': { - 'code.function': 'my_func', + 'code.function': 'test_instrument_with_level..my_func', 'logfire.msg_template': 'my span', 'code.lineno': 123, 'code.filepath': 'test_logfire.py', @@ -124,7 +124,7 @@ async def my_async_func() -> str: return 'ok' assert await my_async_func() == 'ok' - assert exporter.exported_spans_as_dict() == snapshot( + assert exporter.exported_spans_as_dict(_strip_function_qualname=False) == snapshot( [ { 'name': 'async span', @@ -133,7 +133,7 @@ async def my_async_func() -> str: 'start_time': 1000000000, 'end_time': 2000000000, 'attributes': { - 'code.function': 'my_async_func', + 'code.function': 'test_instrument_with_level_async..my_async_func', 'logfire.msg_template': 'async span', 'code.lineno': 123, 'code.filepath': 'test_logfire.py', @@ -152,7 +152,7 @@ def my_func(x: int) -> int: return x * 2 assert my_func(5) == 10 - assert exporter.exported_spans_as_dict() == snapshot( + assert exporter.exported_spans_as_dict(_strip_function_qualname=False) == snapshot( [ { 'name': 'span {x=}', @@ -161,7 +161,7 @@ def my_func(x: int) -> int: 'start_time': 1000000000, 'end_time': 2000000000, 'attributes': { - 'code.function': 'my_func', + 'code.function': 'test_instrument_with_level_and_extract_args..my_func', 'logfire.msg_template': 'span {x=}', 'code.lineno': 123, 'code.filepath': 'test_logfire.py', From 001c22cd35f8c8311ca50bd1824c3ff20b95e1a3 Mon Sep 17 00:00:00 2001 From: imp-joshi Date: Sat, 18 Apr 2026 23:38:37 +0530 Subject: [PATCH 6/9] add coverage tests for min-level guard in extract_args variants --- tests/test_logfire.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/test_logfire.py b/tests/test_logfire.py index 4e935e28e..d5b3b90d1 100644 --- a/tests/test_logfire.py +++ b/tests/test_logfire.py @@ -117,6 +117,30 @@ def my_func() -> str: assert exporter.exported_spans_as_dict() == [] +def test_instrument_level_filtered_extract_args(exporter: TestExporter, config_kwargs: dict[str, Any]) -> None: + config_kwargs['min_level'] = 'info' + logfire.configure(**config_kwargs) + + @logfire.instrument('my span {x=}', _level='debug', extract_args=True) + def my_func(x: int) -> int: + return x * 2 + + assert my_func(5) == 10 + assert exporter.exported_spans_as_dict() == [] + + +def test_instrument_level_filtered_extract_args_iterable(exporter: TestExporter, config_kwargs: dict[str, Any]) -> None: + config_kwargs['min_level'] = 'info' + logfire.configure(**config_kwargs) + + @logfire.instrument('my span', _level='debug', extract_args=('x',)) + def my_func(x: int) -> int: + return x * 2 + + assert my_func(5) == 10 + assert exporter.exported_spans_as_dict() == [] + + @pytest.mark.anyio async def test_instrument_with_level_async(exporter: TestExporter) -> None: @logfire.instrument('async span', _level='warn', extract_args=False) From f9e9e2ad63b5616b7c65aecf77e7481498d56841 Mon Sep 17 00:00:00 2001 From: imp-joshi Date: Wed, 22 Apr 2026 21:26:48 +0530 Subject: [PATCH 7/9] rename instrument `_level` and revert shim --- logfire-api/logfire_api/__init__.py | 4 ++-- logfire/_internal/instrument.py | 14 +++++++------- logfire/_internal/main.py | 10 +++++----- tests/test_logfire.py | 14 +++++++------- 4 files changed, 21 insertions(+), 21 deletions(-) diff --git a/logfire-api/logfire_api/__init__.py b/logfire-api/logfire_api/__init__.py index 24f5b27d2..7931b4374 100644 --- a/logfire-api/logfire_api/__init__.py +++ b/logfire-api/logfire_api/__init__.py @@ -131,7 +131,7 @@ def log_slow_async_callbacks(self, *args, **kwargs) -> None: # pragma: no cover def install_auto_tracing(self, *args, **kwargs) -> None: ... - def instrument(self, *args, _level=None, **kwargs): + def instrument(self, *args, **kwargs): def decorator(func): return func @@ -322,5 +322,5 @@ def set_baggage(*args, **kwargs) -> ContextManager[None]: def get_context(*args, **kwargs) -> dict[str, Any]: return {} - def attach_context(*args, **kwargs)-> ContextManager[None]: + def attach_context(*args, **kwargs) -> ContextManager[None]: return nullcontext() diff --git a/logfire/_internal/instrument.py b/logfire/_internal/instrument.py index 731e4d8e2..89c39aa63 100644 --- a/logfire/_internal/instrument.py +++ b/logfire/_internal/instrument.py @@ -58,7 +58,7 @@ def instrument( record_return: bool, allow_generator: bool, new_trace: bool, - _level: LevelName | int | None = None, + level: LevelName | int | None = None, ) -> Callable[[Callable[P, R]], Callable[P, R]]: from .main import set_user_attributes_on_raw_span @@ -70,7 +70,7 @@ def decorator(func: Callable[P, R]) -> Callable[P, R]: ) attributes = get_attributes(func, msg_template, tags) - open_span = get_open_span(logfire, attributes, span_name, extract_args, func, new_trace, _level=_level) + open_span = get_open_span(logfire, attributes, span_name, extract_args, func, new_trace, level=level) if inspect.isgeneratorfunction(func): if not allow_generator: @@ -129,15 +129,15 @@ def get_open_span( extract_args: bool | Iterable[str], func: Callable[P, R], new_trace: bool, - _level: LevelName | int | None = None, + level: LevelName | int | None = None, ) -> Callable[P, AbstractContextManager[Any]]: final_span_name: str = span_name or attributes[ATTRIBUTES_MESSAGE_TEMPLATE_KEY] # pyright: ignore[reportAssignmentType] level_num: int | None = None - if _level is not None: - _level_attrs = log_level_attributes(_level) - level_num = int(_level_attrs[ATTRIBUTES_LOG_LEVEL_NUM_KEY]) - attributes = {**attributes, **_level_attrs} + if level is not None: + level_attrs = log_level_attributes(level) + level_num = int(level_attrs[ATTRIBUTES_LOG_LEVEL_NUM_KEY]) + attributes = {**attributes, **level_attrs} def get_logfire(): # This avoids having a `logfire` closure variable, which would make the instrumented diff --git a/logfire/_internal/main.py b/logfire/_internal/main.py index 54bbed391..aaa218243 100644 --- a/logfire/_internal/main.py +++ b/logfire/_internal/main.py @@ -608,7 +608,7 @@ def instrument( record_return: bool = False, allow_generator: bool = False, new_trace: bool = False, - _level: LevelName | int | None = None, + level: LevelName | int | None = None, ) -> Callable[[Callable[P, R]], Callable[P, R]]: """Decorator for instrumenting a function as a span. @@ -634,7 +634,7 @@ def my_function(a: int): Read https://logfire.pydantic.dev/docs/guides/advanced/generators/#using-logfireinstrument first. new_trace: Set to `True` to start a new trace with a span link to the current span instead of creating a child of the current span. - _level: The log level for the span. If provided, the span will be tagged with this level + level: The log level for the span. If provided, the span will be tagged with this level and suppressed if the level is below the configured `min_level`. """ @@ -663,7 +663,7 @@ def instrument( # pyright: ignore[reportInconsistentOverload] record_return: bool = False, allow_generator: bool = False, new_trace: bool = False, - _level: LevelName | int | None = None, + level: LevelName | int | None = None, ) -> Callable[[Callable[P, R]], Callable[P, R]] | Callable[P, R]: """Decorator for instrumenting a function as a span. @@ -689,7 +689,7 @@ def my_function(a: int): Read https://logfire.pydantic.dev/docs/guides/advanced/generators/#using-logfireinstrument first. new_trace: Set to `True` to start a new trace with a span link to the current span instead of creating a child of the current span. - _level: The log level for the span. If provided, the span will be tagged with this level + level: The log level for the span. If provided, the span will be tagged with this level and suppressed if the level is below the configured `min_level`. """ if callable(msg_template): @@ -703,7 +703,7 @@ def my_function(a: int): record_return, allow_generator, new_trace, - _level=_level, + level=level, ) def log( diff --git a/tests/test_logfire.py b/tests/test_logfire.py index d5b3b90d1..ef79d6e28 100644 --- a/tests/test_logfire.py +++ b/tests/test_logfire.py @@ -66,7 +66,7 @@ def test_log_methods_without_kwargs(method: str): def test_instrument_with_level(exporter: TestExporter) -> None: - @logfire.instrument('my span', _level='warn', extract_args=False) + @logfire.instrument('my span', level='warn', extract_args=False) def my_func() -> str: return 'ok' @@ -97,7 +97,7 @@ def test_instrument_level_filtered(exporter: TestExporter, config_kwargs: dict[s config_kwargs['min_level'] = 'info' logfire.configure(**config_kwargs) - @logfire.instrument('my span', _level='debug', extract_args=False) + @logfire.instrument('my span', level='debug', extract_args=False) def my_func() -> str: return 'ok' @@ -109,7 +109,7 @@ def test_instrument_level_filtered_record_return(exporter: TestExporter, config_ config_kwargs['min_level'] = 'info' logfire.configure(**config_kwargs) - @logfire.instrument('my span', _level='debug', extract_args=False, record_return=True) + @logfire.instrument('my span', level='debug', extract_args=False, record_return=True) def my_func() -> str: return 'ok' @@ -121,7 +121,7 @@ def test_instrument_level_filtered_extract_args(exporter: TestExporter, config_k config_kwargs['min_level'] = 'info' logfire.configure(**config_kwargs) - @logfire.instrument('my span {x=}', _level='debug', extract_args=True) + @logfire.instrument('my span {x=}', level='debug', extract_args=True) def my_func(x: int) -> int: return x * 2 @@ -133,7 +133,7 @@ def test_instrument_level_filtered_extract_args_iterable(exporter: TestExporter, config_kwargs['min_level'] = 'info' logfire.configure(**config_kwargs) - @logfire.instrument('my span', _level='debug', extract_args=('x',)) + @logfire.instrument('my span', level='debug', extract_args=('x',)) def my_func(x: int) -> int: return x * 2 @@ -143,7 +143,7 @@ def my_func(x: int) -> int: @pytest.mark.anyio async def test_instrument_with_level_async(exporter: TestExporter) -> None: - @logfire.instrument('async span', _level='warn', extract_args=False) + @logfire.instrument('async span', level='warn', extract_args=False) async def my_async_func() -> str: return 'ok' @@ -171,7 +171,7 @@ async def my_async_func() -> str: def test_instrument_with_level_and_extract_args(exporter: TestExporter) -> None: - @logfire.instrument('span {x=}', _level='warn') + @logfire.instrument('span {x=}', level='warn') def my_func(x: int) -> int: return x * 2 From 449b8cf86b0c36c3b2b03a6449d1bb6839f7e0c4 Mon Sep 17 00:00:00 2001 From: imp-joshi Date: Fri, 24 Apr 2026 21:01:04 +0530 Subject: [PATCH 8/9] review cleanup: precompute min-level in @instrument and tidy NoopSpan - hoist NoopSpan import to top of get_open_span instead of re-importing in each closure - drop duplicated per-call min-level guards from the three open_span variants - return a noop-only open_span early when level is below min_level - drop stale NoopSpan._span comment (is_recording just below already returns False) --- logfire/_internal/instrument.py | 21 +++++++++------------ logfire/_internal/main.py | 1 - 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/logfire/_internal/instrument.py b/logfire/_internal/instrument.py index 89c39aa63..dc0673bfb 100644 --- a/logfire/_internal/instrument.py +++ b/logfire/_internal/instrument.py @@ -131,6 +131,8 @@ def get_open_span( new_trace: bool, level: LevelName | int | None = None, ) -> Callable[P, AbstractContextManager[Any]]: + from .main import NoopSpan + final_span_name: str = span_name or attributes[ATTRIBUTES_MESSAGE_TEMPLATE_KEY] # pyright: ignore[reportAssignmentType] level_num: int | None = None @@ -153,6 +155,13 @@ def get_logfire(): def get_logfire(): return logfire + if level_num is not None and level_num < get_logfire().config.min_level: + + def open_span(*_: P.args, **__: P.kwargs): # pyright: ignore[reportRedeclaration] + return NoopSpan() + + return open_span + if new_trace: def extra_span_kwargs() -> dict[str, Any]: @@ -170,10 +179,6 @@ def extra_span_kwargs() -> dict[str, Any]: # This is the fast case for when there are no arguments to extract def open_span(*_: P.args, **__: P.kwargs): # pyright: ignore[reportRedeclaration] - if level_num is not None and level_num < get_logfire().config.min_level: - from .main import NoopSpan - - return NoopSpan() return get_logfire()._fast_span(final_span_name, attributes, **extra_span_kwargs()) # pyright: ignore[reportPrivateUsage] if extract_args is True: @@ -181,10 +186,6 @@ def open_span(*_: P.args, **__: P.kwargs): # pyright: ignore[reportRedeclaratio if sig.parameters: # only extract args if there are any def open_span(*func_args: P.args, **func_kwargs: P.kwargs): - if level_num is not None and level_num < get_logfire().config.min_level: - from .main import NoopSpan - - return NoopSpan() bound = sig.bind(*func_args, **func_kwargs) bound.apply_defaults() args_dict = bound.arguments @@ -212,10 +213,6 @@ def open_span(*func_args: P.args, **func_kwargs: P.kwargs): if extract_args_final: # check that there are still arguments to extract def open_span(*func_args: P.args, **func_kwargs: P.kwargs): - if level_num is not None and level_num < get_logfire().config.min_level: - from .main import NoopSpan - - return NoopSpan() bound = sig.bind(*func_args, **func_kwargs) bound.apply_defaults() args_dict = bound.arguments diff --git a/logfire/_internal/main.py b/logfire/_internal/main.py index aaa218243..5b135c994 100644 --- a/logfire/_internal/main.py +++ b/logfire/_internal/main.py @@ -3099,7 +3099,6 @@ def message(self, message: str): @property def _span(self) -> Any: - # set_user_attributes_on_raw_span checks is_recording() first; INVALID_SPAN returns False return trace_api.INVALID_SPAN def is_recording(self) -> bool: From e217c6d3cd1cc0d1d5ffb6f57fd6624fd685e44c Mon Sep 17 00:00:00 2001 From: imp-joshi Date: Fri, 24 Apr 2026 21:54:02 +0530 Subject: [PATCH 9/9] revert @instrument min-level check to call-time decorate-first-configure-later is a valid usage pattern, so the check has to read config.min_level at each call, not once at decoration time. --- logfire/_internal/instrument.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/logfire/_internal/instrument.py b/logfire/_internal/instrument.py index dc0673bfb..28e961420 100644 --- a/logfire/_internal/instrument.py +++ b/logfire/_internal/instrument.py @@ -155,13 +155,6 @@ def get_logfire(): def get_logfire(): return logfire - if level_num is not None and level_num < get_logfire().config.min_level: - - def open_span(*_: P.args, **__: P.kwargs): # pyright: ignore[reportRedeclaration] - return NoopSpan() - - return open_span - if new_trace: def extra_span_kwargs() -> dict[str, Any]: @@ -179,6 +172,8 @@ def extra_span_kwargs() -> dict[str, Any]: # This is the fast case for when there are no arguments to extract def open_span(*_: P.args, **__: P.kwargs): # pyright: ignore[reportRedeclaration] + if level_num is not None and level_num < get_logfire().config.min_level: + return NoopSpan() return get_logfire()._fast_span(final_span_name, attributes, **extra_span_kwargs()) # pyright: ignore[reportPrivateUsage] if extract_args is True: @@ -186,6 +181,8 @@ def open_span(*_: P.args, **__: P.kwargs): # pyright: ignore[reportRedeclaratio if sig.parameters: # only extract args if there are any def open_span(*func_args: P.args, **func_kwargs: P.kwargs): + if level_num is not None and level_num < get_logfire().config.min_level: + return NoopSpan() bound = sig.bind(*func_args, **func_kwargs) bound.apply_defaults() args_dict = bound.arguments @@ -213,6 +210,8 @@ def open_span(*func_args: P.args, **func_kwargs: P.kwargs): if extract_args_final: # check that there are still arguments to extract def open_span(*func_args: P.args, **func_kwargs: P.kwargs): + if level_num is not None and level_num < get_logfire().config.min_level: + return NoopSpan() bound = sig.bind(*func_args, **func_kwargs) bound.apply_defaults() args_dict = bound.arguments