From 95bae3755cc5e440ac3230a4ab838f97233c516f Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Mon, 4 May 2026 21:50:09 +0200 Subject: [PATCH 1/6] Emit configuration span after `logfire.configure()` Adds a "Logfire configured" log span at the end of `LogfireConfig._initialize` containing the SDK version, installed package versions, and the active configuration (with `token` and `api_key` redacted). Useful for diagnosing "why is my data not showing up" issues from a single span in the UI. --- logfire/_internal/config.py | 24 ++++++++++++++++++++++++ tests/conftest.py | 32 +++++++++++++++++++++++++++++++- tests/test_configure.py | 25 ++++++++++++++++++++++++- 3 files changed, 79 insertions(+), 2 deletions(-) diff --git a/logfire/_internal/config.py b/logfire/_internal/config.py index 42209f53e..bdb1f5a7c 100644 --- a/logfire/_internal/config.py +++ b/logfire/_internal/config.py @@ -1352,6 +1352,30 @@ def fix_pid(): # pragma: no cover self._ensure_flush_after_aws_lambda() + self._emit_configuration_span() + + def _emit_configuration_span(self) -> None: + """Emit a span describing the active Logfire configuration and installed packages.""" + from logfire._internal.collect_system_info import collect_package_info + from logfire._internal.main import Logfire + + with handle_internal_errors: + config_dict: dict[str, Any] = {} + for f in dataclasses.fields(_LogfireConfigData): + value = getattr(self, f.name, None) + if f.name in ('token', 'api_key') and value: + config_dict[f.name] = '[REDACTED]' + elif dataclasses.is_dataclass(value) and not isinstance(value, type): + config_dict[f.name] = {sf.name: getattr(value, sf.name) for sf in dataclasses.fields(value)} + else: + config_dict[f.name] = value + Logfire(config=self).info( + 'Logfire configured', + logfire_version=VERSION, + logfire_config=config_dict, + package_versions=collect_package_info(), + ) + def force_flush(self, timeout_millis: int = 30_000) -> bool: """Force flush all spans and metrics. diff --git a/tests/conftest.py b/tests/conftest.py index ffbd87bc5..b03f227ff 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -122,8 +122,38 @@ def config_kwargs( ) +_original_emit_configuration_span: Any = None + + +@pytest.fixture(autouse=True) +def _disable_configured_span(monkeypatch: pytest.MonkeyPatch) -> None: # pyright: ignore[reportUnusedFunction] + """Skip the "Logfire configured" span in tests. + + The span is useful in production but adds noise to nearly every snapshot test + and would shift trace/span ids when using `IncrementalIdGenerator`. + A dedicated test in `test_configure.py` exercises the emit path explicitly. + """ + from logfire._internal.config import LogfireConfig + + global _original_emit_configuration_span + if _original_emit_configuration_span is None: + _original_emit_configuration_span = LogfireConfig._emit_configuration_span # pyright: ignore[reportPrivateUsage] + + def _no_op(self: LogfireConfig) -> None: + pass + + monkeypatch.setattr(LogfireConfig, '_emit_configuration_span', _no_op) + + +@pytest.fixture +def real_emit_configuration_span() -> Any: + """Return the unpatched `_emit_configuration_span` so a single test can exercise it.""" + assert _original_emit_configuration_span is not None + return _original_emit_configuration_span + + @pytest.fixture(autouse=True) -def config(config_kwargs: dict[str, Any], metrics_reader: InMemoryMetricReader) -> None: +def config(_disable_configured_span: None, config_kwargs: dict[str, Any], metrics_reader: InMemoryMetricReader) -> None: logfire.variables_clear() configure( **config_kwargs, diff --git a/tests/test_configure.py b/tests/test_configure.py index 90cdac9d8..5e5a2b437 100644 --- a/tests/test_configure.py +++ b/tests/test_configure.py @@ -1597,6 +1597,29 @@ def test_configure_twice_no_warning(caplog: LogCaptureFixture): assert not caplog.messages +def test_configuration_span_emitted(real_emit_configuration_span: Any, exporter: TestExporter): + from logfire._internal.config import GLOBAL_CONFIG + + GLOBAL_CONFIG.token = 'pylf_v1_us_FAKE_TOKEN_xyz' + try: + real_emit_configuration_span(GLOBAL_CONFIG) + finally: + GLOBAL_CONFIG.token = None + + spans = [ + s for s in exporter.exported_spans_as_dict(parse_json_attributes=True) if s['name'] == 'Logfire configured' + ] + assert len(spans) == 1 + attrs = spans[0]['attributes'] + assert attrs['logfire_version'] + assert isinstance(attrs['package_versions'], dict) + assert 'logfire' in attrs['package_versions'] + cfg = attrs['logfire_config'] + assert isinstance(cfg, dict) + assert 'service_name' in cfg + assert cfg['token'] == '[REDACTED]' + + def test_exit_open_spans_exports_suspended_generator_span_before_shutdown() -> None: script_path = Path(__file__).parent / 'import_used_for_tests' / 'open_span_at_shutdown.py' @@ -1609,7 +1632,7 @@ def test_exit_open_spans_exports_suspended_generator_span_before_shutdown() -> N ) assert result.returncode == 0, result.stderr - assert result.stdout == 'open span at shutdown\n' + assert result.stdout == 'Logfire configured\nopen span at shutdown\n' def test_send_to_logfire_under_pytest(): From 8c225ee457493d9df6d1ae7a4b569c8fe9153755 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Mon, 4 May 2026 22:38:38 +0200 Subject: [PATCH 2/6] Move `collect_package_info` import to module level --- logfire/_internal/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/logfire/_internal/config.py b/logfire/_internal/config.py index bdb1f5a7c..291aa318c 100644 --- a/logfire/_internal/config.py +++ b/logfire/_internal/config.py @@ -62,6 +62,7 @@ from logfire._internal.auth import PYDANTIC_LOGFIRE_TOKEN_PATTERN, REGIONS from logfire._internal.baggage import DirectBaggageAttributesSpanProcessor +from logfire._internal.collect_system_info import collect_package_info from logfire.exceptions import LogfireConfigError from logfire.sampling import SamplingOptions from logfire.sampling._tail_sampling import TailSamplingProcessor @@ -1356,7 +1357,6 @@ def fix_pid(): # pragma: no cover def _emit_configuration_span(self) -> None: """Emit a span describing the active Logfire configuration and installed packages.""" - from logfire._internal.collect_system_info import collect_package_info from logfire._internal.main import Logfire with handle_internal_errors: From 1f7645afb2964a6ec296a15ee5dfc8e27849b3c4 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Wed, 6 May 2026 16:42:08 +0200 Subject: [PATCH 3/6] Make configuration span opt-in with curated payload Add `AdvancedOptions.emit_configuration_span` flag (default `False`) and replace the dynamic config-dict serialization with an explicit allowlist of non-sensitive flags. Identity and secrets - token, api_key, service_name, service_version, environment, base_url - are never sent. --- logfire/_internal/config.py | 42 ++++++++++++++++++++++++++++--------- tests/conftest.py | 32 +--------------------------- tests/test_configure.py | 23 +++++++++++--------- 3 files changed, 46 insertions(+), 51 deletions(-) diff --git a/logfire/_internal/config.py b/logfire/_internal/config.py index 291aa318c..00f32271f 100644 --- a/logfire/_internal/config.py +++ b/logfire/_internal/config.py @@ -202,6 +202,15 @@ class AdvancedOptions: serialized configuration sent to child processes. See the [distributed tracing guide](https://logfire.pydantic.dev/docs/how-to-guides/distributed-tracing/#thread-and-pool-executors) for more details. """ + emit_configuration_span: bool = False + """Whether to emit a `Logfire configured` log span after `logfire.configure()`. + + The span includes the SDK version, installed package versions, and a small set of + non-sensitive configuration flags (sampling rate, console enabled, etc.). Identity + and secrets (token, api_key, service name, environment, base URL) are never sent. + Useful for diagnosing setup issues from the Logfire UI. Off by default. + """ + def generate_base_url(self, token: str) -> str: if self.base_url is not None: return self.base_url @@ -1356,19 +1365,32 @@ def fix_pid(): # pragma: no cover self._emit_configuration_span() def _emit_configuration_span(self) -> None: - """Emit a span describing the active Logfire configuration and installed packages.""" + """Emit a span describing the active Logfire configuration and installed packages. + + Only runs when `advanced.emit_configuration_span` is `True`. Sends a curated set + of non-sensitive configuration fields - never the token, api_key, or opaque + objects like span/log processors. + """ + if not self.advanced.emit_configuration_span: + return + from logfire._internal.main import Logfire with handle_internal_errors: - config_dict: dict[str, Any] = {} - for f in dataclasses.fields(_LogfireConfigData): - value = getattr(self, f.name, None) - if f.name in ('token', 'api_key') and value: - config_dict[f.name] = '[REDACTED]' - elif dataclasses.is_dataclass(value) and not isinstance(value, type): - config_dict[f.name] = {sf.name: getattr(value, sf.name) for sf in dataclasses.fields(value)} - else: - config_dict[f.name] = value + sampling = self.sampling + config_dict: dict[str, Any] = { + 'send_to_logfire': self.send_to_logfire, + 'console_enabled': bool(self.console), + 'scrubbing_enabled': bool(self.scrubbing), + 'inspect_arguments': self.inspect_arguments, + 'min_level': self.min_level, + 'add_baggage_to_attributes': self.add_baggage_to_attributes, + 'distributed_tracing': self.distributed_tracing, + 'head_sample_rate': sampling.head if isinstance(sampling.head, (int, float)) else None, + 'tail_sampling_enabled': sampling.tail is not None, + 'code_source_set': self.code_source is not None, + 'variables_set': self.variables is not None, + } Logfire(config=self).info( 'Logfire configured', logfire_version=VERSION, diff --git a/tests/conftest.py b/tests/conftest.py index b03f227ff..ffbd87bc5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -122,38 +122,8 @@ def config_kwargs( ) -_original_emit_configuration_span: Any = None - - -@pytest.fixture(autouse=True) -def _disable_configured_span(monkeypatch: pytest.MonkeyPatch) -> None: # pyright: ignore[reportUnusedFunction] - """Skip the "Logfire configured" span in tests. - - The span is useful in production but adds noise to nearly every snapshot test - and would shift trace/span ids when using `IncrementalIdGenerator`. - A dedicated test in `test_configure.py` exercises the emit path explicitly. - """ - from logfire._internal.config import LogfireConfig - - global _original_emit_configuration_span - if _original_emit_configuration_span is None: - _original_emit_configuration_span = LogfireConfig._emit_configuration_span # pyright: ignore[reportPrivateUsage] - - def _no_op(self: LogfireConfig) -> None: - pass - - monkeypatch.setattr(LogfireConfig, '_emit_configuration_span', _no_op) - - -@pytest.fixture -def real_emit_configuration_span() -> Any: - """Return the unpatched `_emit_configuration_span` so a single test can exercise it.""" - assert _original_emit_configuration_span is not None - return _original_emit_configuration_span - - @pytest.fixture(autouse=True) -def config(_disable_configured_span: None, config_kwargs: dict[str, Any], metrics_reader: InMemoryMetricReader) -> None: +def config(config_kwargs: dict[str, Any], metrics_reader: InMemoryMetricReader) -> None: logfire.variables_clear() configure( **config_kwargs, diff --git a/tests/test_configure.py b/tests/test_configure.py index 5e5a2b437..5844dd983 100644 --- a/tests/test_configure.py +++ b/tests/test_configure.py @@ -1597,14 +1597,14 @@ def test_configure_twice_no_warning(caplog: LogCaptureFixture): assert not caplog.messages -def test_configuration_span_emitted(real_emit_configuration_span: Any, exporter: TestExporter): - from logfire._internal.config import GLOBAL_CONFIG +def test_configuration_span_not_emitted_by_default(config_kwargs: dict[str, Any], exporter: TestExporter): + configure(**config_kwargs) + assert not [s for s in exporter.exported_spans_as_dict() if s['name'] == 'Logfire configured'] - GLOBAL_CONFIG.token = 'pylf_v1_us_FAKE_TOKEN_xyz' - try: - real_emit_configuration_span(GLOBAL_CONFIG) - finally: - GLOBAL_CONFIG.token = None + +def test_configuration_span_emitted_when_opted_in(config_kwargs: dict[str, Any], exporter: TestExporter): + advanced = dataclasses.replace(config_kwargs['advanced'], emit_configuration_span=True) + configure(**{**config_kwargs, 'advanced': advanced}) spans = [ s for s in exporter.exported_spans_as_dict(parse_json_attributes=True) if s['name'] == 'Logfire configured' @@ -1616,8 +1616,11 @@ def test_configuration_span_emitted(real_emit_configuration_span: Any, exporter: assert 'logfire' in attrs['package_versions'] cfg = attrs['logfire_config'] assert isinstance(cfg, dict) - assert 'service_name' in cfg - assert cfg['token'] == '[REDACTED]' + assert cfg['send_to_logfire'] is False + assert cfg['inspect_arguments'] is True + # No identity/secrets in the payload. + for forbidden in ('token', 'api_key', 'service_name', 'service_version', 'environment', 'base_url'): + assert forbidden not in cfg def test_exit_open_spans_exports_suspended_generator_span_before_shutdown() -> None: @@ -1632,7 +1635,7 @@ def test_exit_open_spans_exports_suspended_generator_span_before_shutdown() -> N ) assert result.returncode == 0, result.stderr - assert result.stdout == 'Logfire configured\nopen span at shutdown\n' + assert result.stdout == 'open span at shutdown\n' def test_send_to_logfire_under_pytest(): From bacb094644c7e0603a01aff2d56ee5ad381d9e29 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Wed, 6 May 2026 16:52:25 +0200 Subject: [PATCH 4/6] Allow enabling configuration span via `LOGFIRE_EMIT_CONFIGURATION_SPAN` env var Adds an `emit_configuration_span` ConfigParam so the flag can be set via env var or pyproject.toml without passing an explicit `advanced=` argument - useful for enabling on demo apps without touching the configure() call. --- logfire/_internal/config.py | 5 ++++- logfire/_internal/config_params.py | 3 +++ tests/test_configure.py | 9 +++++++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/logfire/_internal/config.py b/logfire/_internal/config.py index 00f32271f..3936f0fc3 100644 --- a/logfire/_internal/config.py +++ b/logfire/_internal/config.py @@ -813,7 +813,10 @@ def _load_configuration( if isinstance(id_generator, dict) and list(id_generator.keys()) == ['seed', '_ms_timestamp_generator']: # pyright: ignore[reportUnknownArgumentType] # pragma: no branch advanced.id_generator = SeededRandomIdGenerator(**id_generator) # pyright: ignore[reportUnknownArgumentType] elif advanced is None: - advanced = AdvancedOptions(base_url=param_manager.load_param('base_url')) + advanced = AdvancedOptions( + base_url=param_manager.load_param('base_url'), + emit_configuration_span=param_manager.load_param('emit_configuration_span'), + ) self.advanced = advanced self.additional_span_processors = additional_span_processors diff --git a/logfire/_internal/config_params.py b/logfire/_internal/config_params.py index 8ba681f04..532d3afa0 100644 --- a/logfire/_internal/config_params.py +++ b/logfire/_internal/config_params.py @@ -107,6 +107,8 @@ class _DefaultCallback: """The base URL of the Logfire backend. Primarily for testing purposes.""" DISTRIBUTED_TRACING = ConfigParam(env_vars=['LOGFIRE_DISTRIBUTED_TRACING'], allow_file_config=True, default=None, tp=bool) """Whether to extract incoming trace context. By default, will extract but warn about it.""" +EMIT_CONFIGURATION_SPAN = ConfigParam(env_vars=['LOGFIRE_EMIT_CONFIGURATION_SPAN'], allow_file_config=True, default=False, tp=bool) +"""Whether to emit a `Logfire configured` log span after `logfire.configure()`.""" # Instrumentation packages parameters HTTPX_CAPTURE_ALL = ConfigParam(env_vars=['LOGFIRE_HTTPX_CAPTURE_ALL'], allow_file_config=True, default=False, tp=bool) @@ -140,6 +142,7 @@ class _DefaultCallback: 'inspect_arguments': INSPECT_ARGUMENTS, 'ignore_no_config': IGNORE_NO_CONFIG, 'distributed_tracing': DISTRIBUTED_TRACING, + 'emit_configuration_span': EMIT_CONFIGURATION_SPAN, # Instrumentation packages parameters 'httpx_capture_all': HTTPX_CAPTURE_ALL, 'aiohttp_client_capture_all': AIOHTTP_CLIENT_CAPTURE_ALL, diff --git a/tests/test_configure.py b/tests/test_configure.py index 5844dd983..c15d44f0b 100644 --- a/tests/test_configure.py +++ b/tests/test_configure.py @@ -1623,6 +1623,15 @@ def test_configuration_span_emitted_when_opted_in(config_kwargs: dict[str, Any], assert forbidden not in cfg +def test_configuration_span_enabled_via_env_var(monkeypatch: pytest.MonkeyPatch) -> None: + from logfire._internal.config import GLOBAL_CONFIG + + monkeypatch.setenv('LOGFIRE_EMIT_CONFIGURATION_SPAN', '1') + # No `advanced=` so the param manager picks up the env var. + configure(send_to_logfire=False, console=False, inspect_arguments=False) + assert GLOBAL_CONFIG.advanced.emit_configuration_span is True + + def test_exit_open_spans_exports_suspended_generator_span_before_shutdown() -> None: script_path = Path(__file__).parent / 'import_used_for_tests' / 'open_span_at_shutdown.py' From 3ea2771f74f1445a69e54abde401fc965757d00d Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Wed, 6 May 2026 16:55:39 +0200 Subject: [PATCH 5/6] Move `emit_configuration_span` to top-level `configure()` parameter Promote the flag from `AdvancedOptions` to a first-class `configure()` keyword argument plus a `_LogfireConfigData` field, matching the pattern used by `distributed_tracing`. The `LOGFIRE_EMIT_CONFIGURATION_SPAN` env var now drives the field directly instead of going through `AdvancedOptions`. --- logfire/_internal/config.py | 35 +++++++++++++++++++---------------- tests/test_configure.py | 6 ++---- 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/logfire/_internal/config.py b/logfire/_internal/config.py index 3936f0fc3..9a243c5a7 100644 --- a/logfire/_internal/config.py +++ b/logfire/_internal/config.py @@ -202,15 +202,6 @@ class AdvancedOptions: serialized configuration sent to child processes. See the [distributed tracing guide](https://logfire.pydantic.dev/docs/how-to-guides/distributed-tracing/#thread-and-pool-executors) for more details. """ - emit_configuration_span: bool = False - """Whether to emit a `Logfire configured` log span after `logfire.configure()`. - - The span includes the SDK version, installed package versions, and a small set of - non-sensitive configuration flags (sampling rate, console enabled, etc.). Identity - and secrets (token, api_key, service name, environment, base URL) are never sent. - Useful for diagnosing setup issues from the Logfire UI. Off by default. - """ - def generate_base_url(self, token: str) -> str: if self.base_url is not None: return self.base_url @@ -405,6 +396,7 @@ def configure( code_source: CodeSource | None = None, variables: VariablesOptions | LocalVariablesOptions | None = None, distributed_tracing: bool | None = None, + emit_configuration_span: bool | None = None, advanced: AdvancedOptions | None = None, **deprecated_kwargs: Unpack[DeprecatedKwargs], ) -> Logfire: @@ -480,6 +472,10 @@ def configure( See [Unintentional Distributed Tracing](https://logfire.pydantic.dev/docs/how-to-guides/distributed-tracing/#unintentional-distributed-tracing) for more information. This setting always applies globally, and the last value set is used, including the default value. + emit_configuration_span: If `True`, emit a `Logfire configured` log span containing the SDK version, + installed package versions, and a curated set of non-sensitive configuration flags. Useful for + diagnosing setup issues from the Logfire UI. Defaults to the `LOGFIRE_EMIT_CONFIGURATION_SPAN` + environment variable, or `False`. advanced: Advanced options primarily used for testing by Logfire developers. """ from .. import DEFAULT_LOGFIRE_INSTANCE, Logfire @@ -613,6 +609,7 @@ def configure( code_source=code_source, variables=variables, distributed_tracing=distributed_tracing, + emit_configuration_span=emit_configuration_span, advanced=advanced, ) @@ -694,6 +691,9 @@ class _LogfireConfigData: distributed_tracing: bool | None """Whether to extract incoming trace context.""" + emit_configuration_span: bool + """Whether to emit a `Logfire configured` log span after `logfire.configure()`.""" + advanced: AdvancedOptions """Advanced options primarily used for testing by Logfire developers.""" @@ -721,6 +721,7 @@ def _load_configuration( code_source: CodeSource | None, variables: VariablesOptions | LocalVariablesOptions | None, distributed_tracing: bool | None, + emit_configuration_span: bool | None, advanced: AdvancedOptions | None, ) -> None: """Merge the given parameters with the environment variables file configurations.""" @@ -736,6 +737,7 @@ def _load_configuration( self.data_dir = param_manager.load_param('data_dir', data_dir) self.inspect_arguments = param_manager.load_param('inspect_arguments', inspect_arguments) self.distributed_tracing = param_manager.load_param('distributed_tracing', distributed_tracing) + self.emit_configuration_span = param_manager.load_param('emit_configuration_span', emit_configuration_span) self.ignore_no_config = param_manager.load_param('ignore_no_config') min_level = param_manager.load_param('min_level', min_level) if min_level is None: @@ -813,10 +815,7 @@ def _load_configuration( if isinstance(id_generator, dict) and list(id_generator.keys()) == ['seed', '_ms_timestamp_generator']: # pyright: ignore[reportUnknownArgumentType] # pragma: no branch advanced.id_generator = SeededRandomIdGenerator(**id_generator) # pyright: ignore[reportUnknownArgumentType] elif advanced is None: - advanced = AdvancedOptions( - base_url=param_manager.load_param('base_url'), - emit_configuration_span=param_manager.load_param('emit_configuration_span'), - ) + advanced = AdvancedOptions(base_url=param_manager.load_param('base_url')) self.advanced = advanced self.additional_span_processors = additional_span_processors @@ -857,6 +856,7 @@ def __init__( variables: VariablesOptions | None = None, code_source: CodeSource | None = None, distributed_tracing: bool | None = None, + emit_configuration_span: bool | None = None, advanced: AdvancedOptions | None = None, ) -> None: """Create a new LogfireConfig. @@ -887,6 +887,7 @@ def __init__( code_source=code_source, variables=variables, distributed_tracing=distributed_tracing, + emit_configuration_span=emit_configuration_span, advanced=advanced, ) # initialize with no-ops so that we don't impact OTEL's global config just because logfire is installed @@ -923,6 +924,7 @@ def configure( code_source: CodeSource | None, variables: VariablesOptions | LocalVariablesOptions | None, distributed_tracing: bool | None, + emit_configuration_span: bool | None, advanced: AdvancedOptions | None, ) -> None: with self._lock: @@ -947,6 +949,7 @@ def configure( code_source, variables, distributed_tracing, + emit_configuration_span, advanced, ) self.initialize() @@ -1370,11 +1373,11 @@ def fix_pid(): # pragma: no cover def _emit_configuration_span(self) -> None: """Emit a span describing the active Logfire configuration and installed packages. - Only runs when `advanced.emit_configuration_span` is `True`. Sends a curated set - of non-sensitive configuration fields - never the token, api_key, or opaque + Only runs when `emit_configuration_span` is `True`. Sends a curated set of + non-sensitive configuration fields - never the token, api_key, or opaque objects like span/log processors. """ - if not self.advanced.emit_configuration_span: + if not self.emit_configuration_span: return from logfire._internal.main import Logfire diff --git a/tests/test_configure.py b/tests/test_configure.py index c15d44f0b..f196e2563 100644 --- a/tests/test_configure.py +++ b/tests/test_configure.py @@ -1603,8 +1603,7 @@ def test_configuration_span_not_emitted_by_default(config_kwargs: dict[str, Any] def test_configuration_span_emitted_when_opted_in(config_kwargs: dict[str, Any], exporter: TestExporter): - advanced = dataclasses.replace(config_kwargs['advanced'], emit_configuration_span=True) - configure(**{**config_kwargs, 'advanced': advanced}) + configure(**config_kwargs, emit_configuration_span=True) spans = [ s for s in exporter.exported_spans_as_dict(parse_json_attributes=True) if s['name'] == 'Logfire configured' @@ -1627,9 +1626,8 @@ def test_configuration_span_enabled_via_env_var(monkeypatch: pytest.MonkeyPatch) from logfire._internal.config import GLOBAL_CONFIG monkeypatch.setenv('LOGFIRE_EMIT_CONFIGURATION_SPAN', '1') - # No `advanced=` so the param manager picks up the env var. configure(send_to_logfire=False, console=False, inspect_arguments=False) - assert GLOBAL_CONFIG.advanced.emit_configuration_span is True + assert GLOBAL_CONFIG.emit_configuration_span is True def test_exit_open_spans_exports_suspended_generator_span_before_shutdown() -> None: From 2b47b1945c23f04989a1c68ad8f506ff32bffbe7 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Thu, 7 May 2026 17:15:03 +0200 Subject: [PATCH 6/6] Move emit_configuration_span under AdvancedOptions and expand payload --- logfire/_internal/config.py | 60 +++++++++++++++++++++++-------------- tests/test_configure.py | 11 +++++-- 2 files changed, 46 insertions(+), 25 deletions(-) diff --git a/logfire/_internal/config.py b/logfire/_internal/config.py index 9a243c5a7..2e73135d2 100644 --- a/logfire/_internal/config.py +++ b/logfire/_internal/config.py @@ -202,6 +202,13 @@ class AdvancedOptions: serialized configuration sent to child processes. See the [distributed tracing guide](https://logfire.pydantic.dev/docs/how-to-guides/distributed-tracing/#thread-and-pool-executors) for more details. """ + emit_configuration_span: bool | None = None + """If `True`, emit a `Logfire configured` log span after `logfire.configure()` containing the SDK + version, installed package versions, and a curated set of non-sensitive configuration flags. + + Defaults to the `LOGFIRE_EMIT_CONFIGURATION_SPAN` environment variable, or `False`. + """ + def generate_base_url(self, token: str) -> str: if self.base_url is not None: return self.base_url @@ -396,7 +403,6 @@ def configure( code_source: CodeSource | None = None, variables: VariablesOptions | LocalVariablesOptions | None = None, distributed_tracing: bool | None = None, - emit_configuration_span: bool | None = None, advanced: AdvancedOptions | None = None, **deprecated_kwargs: Unpack[DeprecatedKwargs], ) -> Logfire: @@ -472,10 +478,6 @@ def configure( See [Unintentional Distributed Tracing](https://logfire.pydantic.dev/docs/how-to-guides/distributed-tracing/#unintentional-distributed-tracing) for more information. This setting always applies globally, and the last value set is used, including the default value. - emit_configuration_span: If `True`, emit a `Logfire configured` log span containing the SDK version, - installed package versions, and a curated set of non-sensitive configuration flags. Useful for - diagnosing setup issues from the Logfire UI. Defaults to the `LOGFIRE_EMIT_CONFIGURATION_SPAN` - environment variable, or `False`. advanced: Advanced options primarily used for testing by Logfire developers. """ from .. import DEFAULT_LOGFIRE_INSTANCE, Logfire @@ -609,7 +611,6 @@ def configure( code_source=code_source, variables=variables, distributed_tracing=distributed_tracing, - emit_configuration_span=emit_configuration_span, advanced=advanced, ) @@ -618,6 +619,8 @@ def configure( else: logfire_instance = DEFAULT_LOGFIRE_INSTANCE + config.emit_configuration_span(logfire_instance, local=local) + # Start the variable provider now that we have the logfire instance # Pass None if instrumentation is disabled to avoid logging errors via logfire # Only start if the user explicitly configured variables — lazy-init providers @@ -691,9 +694,6 @@ class _LogfireConfigData: distributed_tracing: bool | None """Whether to extract incoming trace context.""" - emit_configuration_span: bool - """Whether to emit a `Logfire configured` log span after `logfire.configure()`.""" - advanced: AdvancedOptions """Advanced options primarily used for testing by Logfire developers.""" @@ -721,7 +721,6 @@ def _load_configuration( code_source: CodeSource | None, variables: VariablesOptions | LocalVariablesOptions | None, distributed_tracing: bool | None, - emit_configuration_span: bool | None, advanced: AdvancedOptions | None, ) -> None: """Merge the given parameters with the environment variables file configurations.""" @@ -737,7 +736,6 @@ def _load_configuration( self.data_dir = param_manager.load_param('data_dir', data_dir) self.inspect_arguments = param_manager.load_param('inspect_arguments', inspect_arguments) self.distributed_tracing = param_manager.load_param('distributed_tracing', distributed_tracing) - self.emit_configuration_span = param_manager.load_param('emit_configuration_span', emit_configuration_span) self.ignore_no_config = param_manager.load_param('ignore_no_config') min_level = param_manager.load_param('min_level', min_level) if min_level is None: @@ -816,6 +814,8 @@ def _load_configuration( advanced.id_generator = SeededRandomIdGenerator(**id_generator) # pyright: ignore[reportUnknownArgumentType] elif advanced is None: advanced = AdvancedOptions(base_url=param_manager.load_param('base_url')) + if advanced.emit_configuration_span is None: + advanced.emit_configuration_span = param_manager.load_param('emit_configuration_span') self.advanced = advanced self.additional_span_processors = additional_span_processors @@ -856,7 +856,6 @@ def __init__( variables: VariablesOptions | None = None, code_source: CodeSource | None = None, distributed_tracing: bool | None = None, - emit_configuration_span: bool | None = None, advanced: AdvancedOptions | None = None, ) -> None: """Create a new LogfireConfig. @@ -887,7 +886,6 @@ def __init__( code_source=code_source, variables=variables, distributed_tracing=distributed_tracing, - emit_configuration_span=emit_configuration_span, advanced=advanced, ) # initialize with no-ops so that we don't impact OTEL's global config just because logfire is installed @@ -924,7 +922,6 @@ def configure( code_source: CodeSource | None, variables: VariablesOptions | LocalVariablesOptions | None, distributed_tracing: bool | None, - emit_configuration_span: bool | None, advanced: AdvancedOptions | None, ) -> None: with self._lock: @@ -949,7 +946,6 @@ def configure( code_source, variables, distributed_tracing, - emit_configuration_span, advanced, ) self.initialize() @@ -1368,23 +1364,39 @@ def fix_pid(): # pragma: no cover self._ensure_flush_after_aws_lambda() - self._emit_configuration_span() - - def _emit_configuration_span(self) -> None: + def emit_configuration_span(self, logfire_instance: Logfire, *, local: bool) -> None: """Emit a span describing the active Logfire configuration and installed packages. - Only runs when `emit_configuration_span` is `True`. Sends a curated set of + Only runs when `advanced.emit_configuration_span` is `True`. Sends a curated set of non-sensitive configuration fields - never the token, api_key, or opaque objects like span/log processors. """ - if not self.emit_configuration_span: + if not self.advanced.emit_configuration_span: return - from logfire._internal.main import Logfire - with handle_internal_errors: sampling = self.sampling + if isinstance(self.token, str): + token_count = 1 + elif self.token is None: + token_count = 0 + else: + token_count = len(self.token) + # Names of identity-shaped fields the user provided. We only report whether + # they were set (never the values). Keys are chosen to avoid the default + # scrubber patterns (`api_key`, `credential`, `secret`, etc.). + fields_provided = sorted( + name + for name, value in { + 'api_key': self.api_key, + 'service_name': self.service_name, + 'service_version': self.service_version, + 'environment': self.environment, + }.items() + if value is not None + ) config_dict: dict[str, Any] = { + 'local': local, 'send_to_logfire': self.send_to_logfire, 'console_enabled': bool(self.console), 'scrubbing_enabled': bool(self.scrubbing), @@ -1396,8 +1408,10 @@ def _emit_configuration_span(self) -> None: 'tail_sampling_enabled': sampling.tail is not None, 'code_source_set': self.code_source is not None, 'variables_set': self.variables is not None, + 'token_count': token_count, + 'fields_provided': fields_provided, } - Logfire(config=self).info( + logfire_instance.info( 'Logfire configured', logfire_version=VERSION, logfire_config=config_dict, diff --git a/tests/test_configure.py b/tests/test_configure.py index f196e2563..748964874 100644 --- a/tests/test_configure.py +++ b/tests/test_configure.py @@ -1603,7 +1603,9 @@ def test_configuration_span_not_emitted_by_default(config_kwargs: dict[str, Any] def test_configuration_span_emitted_when_opted_in(config_kwargs: dict[str, Any], exporter: TestExporter): - configure(**config_kwargs, emit_configuration_span=True) + advanced = config_kwargs.pop('advanced') + advanced.emit_configuration_span = True + configure(**config_kwargs, advanced=advanced) spans = [ s for s in exporter.exported_spans_as_dict(parse_json_attributes=True) if s['name'] == 'Logfire configured' @@ -1617,6 +1619,11 @@ def test_configuration_span_emitted_when_opted_in(config_kwargs: dict[str, Any], assert isinstance(cfg, dict) assert cfg['send_to_logfire'] is False assert cfg['inspect_arguments'] is True + assert cfg['local'] is False + assert cfg['token_count'] == 0 + assert isinstance(cfg['fields_provided'], list) + assert 'api_key' not in cfg['fields_provided'] + assert 'environment' not in cfg['fields_provided'] # No identity/secrets in the payload. for forbidden in ('token', 'api_key', 'service_name', 'service_version', 'environment', 'base_url'): assert forbidden not in cfg @@ -1627,7 +1634,7 @@ def test_configuration_span_enabled_via_env_var(monkeypatch: pytest.MonkeyPatch) monkeypatch.setenv('LOGFIRE_EMIT_CONFIGURATION_SPAN', '1') configure(send_to_logfire=False, console=False, inspect_arguments=False) - assert GLOBAL_CONFIG.emit_configuration_span is True + assert GLOBAL_CONFIG.advanced.emit_configuration_span is True def test_exit_open_spans_exports_suspended_generator_span_before_shutdown() -> None: