diff --git a/logfire/_internal/config.py b/logfire/_internal/config.py index 42209f53e..2e73135d2 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 @@ -201,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 @@ -611,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 @@ -804,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 @@ -1352,6 +1364,60 @@ def fix_pid(): # pragma: no cover self._ensure_flush_after_aws_lambda() + 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 `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 + + 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), + '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, + 'token_count': token_count, + 'fields_provided': fields_provided, + } + logfire_instance.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/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 90cdac9d8..748964874 100644 --- a/tests/test_configure.py +++ b/tests/test_configure.py @@ -1597,6 +1597,46 @@ def test_configure_twice_no_warning(caplog: LogCaptureFixture): assert not caplog.messages +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'] + + +def test_configuration_span_emitted_when_opted_in(config_kwargs: dict[str, Any], exporter: TestExporter): + 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' + ] + 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 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 + + +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') + 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'