From ad15daab5c73ebb690bfb05d22ea797c5bec4f42 Mon Sep 17 00:00:00 2001 From: Adrian Garcia Badaracco <1755071+adriangb@users.noreply.github.com> Date: Mon, 4 May 2026 20:17:08 -0500 Subject: [PATCH 1/2] feat: add X-Logfire-Telemetry header and surface server warnings/errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an `X-Logfire-Telemetry: key=val,...` header to every request the SDK sends (OTLP exports, token validation, CRUD calls, variable provider, CLI), carrying the SDK version and a curated set of non-sensitive `_LogfireConfigData` fields so the backend can drive product analytics and deprecation decisions. Tokens, API keys, service name, environment, etc. are explicitly excluded. Also installs a response hook on every Logfire SDK session that surfaces `X-Logfire-Warning` headers via `warnings.warn` (deduplicated by Python's default filter) and raises `LogfireServerError` on `X-Logfire-Error` headers, so the server can deprecate endpoints or signal hard failures out-of-band. Co-Authored-By: Claude Opus 4.7 (1M context) trim telemetry pairs, add service.instance.id, document rationale per field * Limit `_LogfireConfigData`-derived pairs to `code_source_set`, `variables_set`, and `token_count` — the only ones with a concrete product question they answer. Each remaining field now has an inline comment explaining why we send it. * Carry the resolved `service.instance.id` (UUID generated inside `_initialize`, shared with OTLP resource attributes) so the backend can correlate the header on non-OTLP requests with the spans this SDK instance exports. * Drop the `None` branch of `_format_value` and the idempotency guard in `install_logfire_response_hook` — both were unreachable, which was tripping the coverage gate. Co-Authored-By: Claude Opus 4.7 (1M context) move service_instance_id into _config_telemetry_pairs It only ships when a LogfireConfig is available, so it belongs in the config-derived group rather than the always-sent base group. Co-Authored-By: Claude Opus 4.7 (1M context) Revert "Add user agent to query client (#1875)" This reverts commit ff5c802b. With X-Logfire-Telemetry now carrying SDK version, Python version, implementation and OS, the custom User-Agent on the query client is duplicate plumbing for the same product analytics question. Letting httpx send its default UA again, and the next commit adds the telemetry header here. Co-Authored-By: Claude Opus 4.7 (1M context) address review: JSON header value, implementation field, cache base pairs, query client - Encode the X-Logfire-Telemetry value as compact JSON instead of a comma-separated key=val list. Removes a silent footgun if any value ever contains "," or "=", and lets us send native bool/int instead of formatting them as strings. - Rename the `runtime` field to `implementation` to match Python's own wording for `sys.implementation.name`. - Cache `_base_telemetry_pairs()` with `functools.cache` since the value is constant per-process. - Wire the same telemetry header into the experimental query client now that the previous custom User-Agent has been reverted. Co-Authored-By: Claude Opus 4.7 (1M context) split out response-header handling into its own PR Per review (#1905#discussion_r3189043209), the X-Logfire-Warning / X-Logfire-Error response-hook handling is logically independent from the X-Logfire-Telemetry request header — they just happened to be introduced together. Moved that code (plus its exceptions, install calls, and tests) to a separate PR (#1906) so each can be reviewed and landed on its own. Co-Authored-By: Claude Opus 4.7 (1M context) --- logfire/_internal/cli/__init__.py | 2 + logfire/_internal/client.py | 9 ++- logfire/_internal/config.py | 32 +++++++- logfire/_internal/telemetry_header.py | 91 ++++++++++++++++++++++ logfire/experimental/query_client.py | 6 +- logfire/variables/remote.py | 16 +++- tests/test_telemetry_header.py | 104 ++++++++++++++++++++++++++ 7 files changed, 250 insertions(+), 10 deletions(-) create mode 100644 logfire/_internal/telemetry_header.py create mode 100644 tests/test_telemetry_header.py diff --git a/logfire/_internal/cli/__init__.py b/logfire/_internal/cli/__init__.py index 0898b2715..4d3125ab4 100644 --- a/logfire/_internal/cli/__init__.py +++ b/logfire/_internal/cli/__init__.py @@ -26,6 +26,7 @@ from ..config import REGIONS, LogfireCredentials, get_base_url_from_token from ..config_params import ParamManager from ..server_response import install_logfire_response_hook +from ..telemetry_header import TELEMETRY_HEADER_NAME, build_telemetry_header from ..tracer import SDKTracerProvider from .auth import parse_auth, parse_logout from .prompt import parse_prompt @@ -448,6 +449,7 @@ def log_trace_id(response: requests.Response, context: ContextCarrier, *args: An context = get_context() session.hooks = {'response': [functools.partial(log_trace_id, context=context)]} session.headers.update(context) + session.headers[TELEMETRY_HEADER_NAME] = build_telemetry_header() install_logfire_response_hook(session) namespace._session = session namespace.func(namespace) diff --git a/logfire/_internal/client.py b/logfire/_internal/client.py index a66a35b57..7f968c11c 100644 --- a/logfire/_internal/client.py +++ b/logfire/_internal/client.py @@ -11,6 +11,7 @@ from .auth import UserToken, UserTokenCollection from .server_response import ServerResponseCallback, install_logfire_response_hook +from .telemetry_header import TELEMETRY_HEADER_NAME, build_telemetry_header from .utils import UnexpectedResponse UA_HEADER = f'logfire/{VERSION}' @@ -44,7 +45,13 @@ def __init__( self.base_url = user_token.base_url self._token = user_token.token self._session = Session() - self._session.headers.update({'Authorization': self._token, 'User-Agent': UA_HEADER}) + self._session.headers.update( + { + 'Authorization': self._token, + 'User-Agent': UA_HEADER, + TELEMETRY_HEADER_NAME: build_telemetry_header(), + } + ) install_logfire_response_hook(self._session, server_response_hook) @classmethod diff --git a/logfire/_internal/config.py b/logfire/_internal/config.py index 05c55488c..3a4a9e8f6 100644 --- a/logfire/_internal/config.py +++ b/logfire/_internal/config.py @@ -112,6 +112,7 @@ from .scrubbing import NOOP_SCRUBBER, BaseScrubber, Scrubber, ScrubbingOptions from .server_response import ServerResponseCallback, install_logfire_response_hook from .stack_info import warn_at_user_stacklevel +from .telemetry_header import TELEMETRY_HEADER_NAME, build_telemetry_header from .tracer import OPEN_SPANS, PendingSpanProcessor, ProxyTracerProvider from .utils import ( SeededRandomIdGenerator, @@ -935,6 +936,9 @@ def __init__( # This ensures that we only call OTEL's global set_tracer_provider once to avoid warnings. self._has_set_providers = False self._initialized = False + # Resolved in `_initialize` once the resource (and therefore its `service.instance.id`) + # exists; until then there is no value to advertise to the backend. + self._service_instance_id: str = '' self._lock = RLock() def configure( @@ -1042,6 +1046,9 @@ def _initialize(self) -> None: # https://github.com/open-telemetry/semantic-conventions/blob/e44693245eef815071402b88c3a44a8f7f8f24c8/docs/resource/README.md#service-experimental # Both recommend generating a UUID. resource = Resource({'service.instance.id': uuid4().hex}).merge(resource) + # Cache the resolved service.instance.id so the X-Logfire-Telemetry header + # advertises the same UUID the OTLP resource attributes carry. + self._service_instance_id = str(resource.attributes.get('service.instance.id', '')) head = self.sampling.head sampler: Sampler | None = None @@ -1171,9 +1178,14 @@ def check_tokens(): thread.start() # Create exporters for each token + telemetry_header_value = build_telemetry_header(self) for token in token_list: base_url = self.advanced.generate_base_url(token) - headers = {'User-Agent': f'logfire/{VERSION}', 'Authorization': token} + headers = { + 'User-Agent': f'logfire/{VERSION}', + 'Authorization': token, + TELEMETRY_HEADER_NAME: telemetry_header_value, + } session = OTLPExporterHttpSession() install_logfire_response_hook(session, self.advanced.server_response_hook) span_exporter = BodySizeCheckingOTLPSpanExporter( @@ -1353,6 +1365,7 @@ def fix_pid(): # pragma: no cover token=self.api_key, options=self.variables, server_response_hook=self.advanced.server_response_hook, + telemetry_header=build_telemetry_header(self), ) multi_log_processor = SynchronousMultiLogRecordProcessor() for processor in log_record_processors: @@ -1486,6 +1499,7 @@ def _lazy_init_variable_provider(self) -> VariableProvider: token=api_key, options=options, server_response_hook=self.advanced.server_response_hook, + telemetry_header=build_telemetry_header(self), ) self._variable_provider = provider provider.start(Logfire(config=self)) @@ -1504,7 +1518,12 @@ def warn_if_not_initialized(self, message: str): def _initialize_credentials_from_token(self, token: str) -> LogfireCredentials | None: session = requests.Session() install_logfire_response_hook(session, self.advanced.server_response_hook) - return LogfireCredentials.from_token(token, session, self.advanced.generate_base_url(token)) + return LogfireCredentials.from_token( + token, + session, + self.advanced.generate_base_url(token), + telemetry_header=build_telemetry_header(self), + ) def _ensure_flush_after_aws_lambda(self): """Ensure that `force_flush` is called after an AWS Lambda invocation. @@ -1698,7 +1717,9 @@ def load_creds_file(cls, creds_dir: Path) -> Self | None: raise LogfireConfigError(f'Invalid credentials file: {path} - {e}') from e @classmethod - def from_token(cls, token: str, session: requests.Session, base_url: str) -> Self | None: + def from_token( + cls, token: str, session: requests.Session, base_url: str, telemetry_header: str | None = None + ) -> Self | None: """Check that the token is valid. Issue a warning if the Logfire API is unreachable, or we get a response other than 200 or 401. @@ -1708,11 +1729,14 @@ def from_token(cls, token: str, session: requests.Session, base_url: str) -> Sel Raises: LogfireConfigError: If the token is invalid. """ + headers: dict[str, str] = {**COMMON_REQUEST_HEADERS, 'Authorization': token} + if telemetry_header is not None: + headers[TELEMETRY_HEADER_NAME] = telemetry_header try: response = session.get( urljoin(base_url, '/v1/info'), timeout=10, - headers={**COMMON_REQUEST_HEADERS, 'Authorization': token}, + headers=headers, ) except requests.RequestException as e: warnings.warn(f'Logfire API is unreachable, you may have trouble sending data. Error: {e}') diff --git a/logfire/_internal/telemetry_header.py b/logfire/_internal/telemetry_header.py new file mode 100644 index 000000000..de66822e9 --- /dev/null +++ b/logfire/_internal/telemetry_header.py @@ -0,0 +1,91 @@ +"""Build the `X-Logfire-Telemetry` request header. + +The header carries non-sensitive information about the SDK and how it is +configured, encoded as a compact JSON object. The backend uses it to answer +questions like which SDK versions are still in active use, which Python +versions we can drop, and which configuration options users actually enable. +Secrets (`token`, `api_key`, `service_name`, etc.) are never included. +""" + +from __future__ import annotations + +import functools +import json +import sys +from typing import TYPE_CHECKING, Any + +from logfire.version import VERSION + +if TYPE_CHECKING: + from .config import LogfireConfig + + +TELEMETRY_HEADER_NAME = 'X-Logfire-Telemetry' + + +@functools.cache +def _base_telemetry_pairs() -> dict[str, Any]: + # Each field below has an explicit rationale; do not add a field unless you have one. + return { + # SDK version: the primary signal for deprecation planning — which versions + # are still in active use so we know when it is safe to drop one. + 'sdk_version': VERSION, + # SDK language: lets the same backend ingestion logic distinguish python + # from future SDKs (JS, Rust) without having to parse User-Agent. + 'sdk_language': 'python', + # Python version: tells us when we can drop support for an older Python. + 'python_version': f'{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}', + # Implementation: spotting non-CPython users (pypy, graalpy) before + # changing anything that depends on CPython-specific behaviour. + 'implementation': sys.implementation.name, + # OS: same idea — confirm Windows / Linux / macOS coverage before + # touching platform-sensitive code paths. + 'os': sys.platform, + } + + +def _config_telemetry_pairs(config: LogfireConfig) -> dict[str, Any]: + """Pick fields of `LogfireConfig` that are useful for product analytics. + + Each field below has an explicit rationale; do not add a field unless you have + one. Everything else either duplicates information the server already knows, + isn't actionable, or risks leaking sensitive data (token, api_key, + service_name, environment, etc.). + """ + # Multi-project usage: how many users configure more than one write token in + # a single SDK instance. Drives auth/routing roadmap decisions. + token = config.token + if isinstance(token, list): + token_count = len(token) + elif token: + token_count = 1 + else: + token_count = 0 + + pairs: dict[str, Any] = { + # Adoption signal for the `code_source=` option (newer feature): tells us + # whether the integration with the source-code link UI is worth investing in. + 'code_source_set': config.code_source is not None, + # Adoption signal for the variables / feature-flag feature (newer feature): + # informs whether to keep building on it. + 'variables_set': config.variables is not None, + 'token_count': token_count, + } + + if config._service_instance_id: # pyright: ignore[reportPrivateUsage] + # Mirrors the OTLP resource attribute of the same name + # (https://opentelemetry.io/docs/specs/semconv/registry/attributes/service/#service-instance-id). + # Carrying it on the header lets the backend correlate metadata with the spans + # this SDK instance is exporting, even on requests that don't carry an OTLP body + # (token validation, variables fetch, CRUD endpoints). + pairs['service_instance_id'] = config._service_instance_id # pyright: ignore[reportPrivateUsage] + + return pairs + + +def build_telemetry_header(config: LogfireConfig | None = None) -> str: + """Return the JSON-encoded value for the `X-Logfire-Telemetry` header.""" + pairs: dict[str, Any] = {**_base_telemetry_pairs()} + if config is not None: + pairs.update(_config_telemetry_pairs(config)) + return json.dumps(pairs, separators=(',', ':')) diff --git a/logfire/experimental/query_client.py b/logfire/experimental/query_client.py index 89b4166bf..39a3cee07 100644 --- a/logfire/experimental/query_client.py +++ b/logfire/experimental/query_client.py @@ -1,14 +1,13 @@ from __future__ import annotations -import platform from datetime import datetime from types import TracebackType from typing import TYPE_CHECKING, Any, Generic, Literal, TypedDict, TypeVar from typing_extensions import Self -from logfire import VERSION from logfire._internal.config import get_base_url_from_token +from logfire._internal.telemetry_header import TELEMETRY_HEADER_NAME, build_telemetry_header try: from httpx import AsyncClient, Client, Response, Timeout @@ -87,7 +86,6 @@ def _rows_to_columns(result: RowQueryResults) -> QueryResults: _ACCEPT = Literal['application/json', 'application/vnd.apache.arrow.stream', 'text/csv'] -_USER_AGENT = f'logfire-sdk-python/{VERSION} (Python {platform.python_version()}, os {platform.platform()}, arch {platform.machine()})' class _BaseLogfireQueryClient(Generic[T]): @@ -97,7 +95,7 @@ def __init__(self, base_url: str, read_token: str, timeout: Timeout, client: typ self.timeout = timeout headers = client_kwargs.pop('headers', {}) headers['authorization'] = read_token - headers.setdefault('user-agent', _USER_AGENT) + headers[TELEMETRY_HEADER_NAME] = build_telemetry_header() self.client: T = client(timeout=timeout, base_url=base_url, headers=headers, **client_kwargs) def _build_query_params( diff --git a/logfire/variables/remote.py b/logfire/variables/remote.py index 0de792837..3948aa9df 100644 --- a/logfire/variables/remote.py +++ b/logfire/variables/remote.py @@ -18,6 +18,7 @@ from logfire._internal.client import UA_HEADER from logfire._internal.config import VariablesOptions from logfire._internal.server_response import ServerResponseCallback, install_logfire_response_hook +from logfire._internal.telemetry_header import TELEMETRY_HEADER_NAME, build_telemetry_header from logfire._internal.utils import UnexpectedResponse from logfire.variables.abstract import ( ResolvedVariable, @@ -61,6 +62,7 @@ def __init__( token: str, options: VariablesOptions, server_response_hook: ServerResponseCallback | None = None, + telemetry_header: str | None = None, ): """Create a new remote variable provider. @@ -70,6 +72,10 @@ def __init__( options: Options for retrieving remote variables. server_response_hook: Optional override for the API response hook (see `AdvancedOptions.server_response_hook`). + telemetry_header: Pre-built `X-Logfire-Telemetry` header value carrying the + SDK's `service.instance.id` so it matches the OTLP resource attribute. + When None (e.g. lazily instantiated outside of `_initialize`), a base + header without config-derived fields is built here. """ block_before_first_resolve = options.block_before_first_resolve polling_interval = options.polling_interval @@ -77,8 +83,15 @@ def __init__( self._base_url = base_url self._token = token self._server_response_hook = server_response_hook + self._telemetry_header = telemetry_header if telemetry_header is not None else build_telemetry_header() self._session = Session() - self._session.headers.update({'Authorization': f'bearer {token}', 'User-Agent': UA_HEADER}) + self._session.headers.update( + { + 'Authorization': f'bearer {token}', + 'User-Agent': UA_HEADER, + TELEMETRY_HEADER_NAME: self._telemetry_header, + } + ) install_logfire_response_hook(self._session, server_response_hook) self._timeout = options.timeout self._block_before_first_fetch = block_before_first_resolve @@ -204,6 +217,7 @@ def _sse_listener(self): # pragma: no cover { 'Authorization': f'bearer {self._token}', 'User-Agent': UA_HEADER, + TELEMETRY_HEADER_NAME: self._telemetry_header, 'Accept': 'text/event-stream', 'Cache-Control': 'no-cache', } diff --git a/tests/test_telemetry_header.py b/tests/test_telemetry_header.py new file mode 100644 index 000000000..4e3ec8ef6 --- /dev/null +++ b/tests/test_telemetry_header.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import json +from typing import Any +from unittest.mock import patch + +import requests +import requests_mock + +import logfire +from logfire._internal.config import GLOBAL_CONFIG, LogfireCredentials +from logfire._internal.telemetry_header import TELEMETRY_HEADER_NAME, build_telemetry_header +from logfire.version import VERSION + + +def _parse_header(value: str) -> dict[str, Any]: + return json.loads(value) + + +def test_build_telemetry_header_without_config(): + pairs = _parse_header(build_telemetry_header()) + assert pairs['sdk_version'] == VERSION + assert pairs['sdk_language'] == 'python' + assert pairs['python_version'] + assert pairs['implementation'] + assert pairs['os'] + + +def test_build_telemetry_header_with_config(): + pairs = _parse_header(build_telemetry_header(GLOBAL_CONFIG)) + assert pairs['sdk_version'] == VERSION + for key in ('code_source_set', 'variables_set', 'token_count'): + assert key in pairs + + +def test_telemetry_header_excludes_secrets(): + """The header must never carry the token, api key, environment or service name.""" + secrets = ['shhh-secret-token', 'secret-api-key', 'top-secret-env', 'secret-service-name'] + with patch.dict('os.environ', {}, clear=False): + logfire.configure( + send_to_logfire=False, + token=secrets[0], + api_key=secrets[1], + environment=secrets[2], + service_name=secrets[3], + console=False, + ) + try: + header = build_telemetry_header(GLOBAL_CONFIG) + for secret in secrets: + assert secret not in header + finally: + # Reset to the default test config. + logfire.configure(send_to_logfire=False, console=False) + + +def test_otlp_export_sends_telemetry_header(): + captured: list[dict[str, str]] = [] + + with requests_mock.Mocker() as m: + m.get( + 'https://logfire-us.pydantic.dev/v1/info', + json={'project_name': 'myproject', 'project_url': 'fake_project_url'}, + ) + + def _capture(request: requests.PreparedRequest, _context: object) -> str: + captured.append(dict(request.headers)) + return '' + + m.post('https://logfire-us.pydantic.dev/v1/traces', text=_capture, status_code=200) + + logfire.configure(send_to_logfire=True, token='abc1', console=False) + for thread in __import__('threading').enumerate(): + if thread.name == 'check_logfire_token': # pragma: no cover + thread.join() + + with logfire.span('a span'): + pass + logfire.force_flush() + + assert any(TELEMETRY_HEADER_NAME in headers for headers in captured) + [headers] = [headers for headers in captured if TELEMETRY_HEADER_NAME in headers] + pairs = _parse_header(headers[TELEMETRY_HEADER_NAME]) + assert pairs['sdk_version'] == VERSION + assert pairs['token_count'] == 1 + assert 'abc1' not in headers[TELEMETRY_HEADER_NAME] + # The header must advertise the same `service.instance.id` carried by OTLP + # resource attributes so the backend can correlate the two. + resource = GLOBAL_CONFIG.get_tracer_provider().resource + assert pairs['service_instance_id'] == resource.attributes['service.instance.id'] + + +def test_from_token_sends_telemetry_header(): + with requests_mock.Mocker() as m: + m.get( + 'https://logfire-us.pydantic.dev/v1/info', + json={'project_name': 'myproject', 'project_url': 'fake_project_url'}, + ) + session = requests.Session() + LogfireCredentials.from_token( + 'pylf_v1_us_xxx', session, 'https://logfire-us.pydantic.dev', telemetry_header='{"sdk_version":"1.2.3"}' + ) + [history] = m.request_history + assert history.headers[TELEMETRY_HEADER_NAME] == '{"sdk_version":"1.2.3"}' From 51bf641538d6e182a1e69dbc9a08e5faf7a84068 Mon Sep 17 00:00:00 2001 From: Adrian Garcia Badaracco <1755071+adriangb@users.noreply.github.com> Date: Wed, 13 May 2026 12:31:38 -0400 Subject: [PATCH 2/2] Restore rich User-Agent; keep config-only fields in X-Logfire-Telemetry Per Alex's review comment, SDK/runtime identity (sdk version, language, Python version, implementation, os, arch) now travels on the standard User-Agent header rather than a parallel custom JSON header. The X-Logfire-Telemetry header still carries config-derived fields that either aren't UA-shaped (booleans, counts) or are high-cardinality (service_instance_id), and is now omitted entirely when no LogfireConfig is available. Co-Authored-By: Claude Opus 4.7 (1M context) --- logfire/_internal/cli/__init__.py | 5 +-- logfire/_internal/client.py | 9 +++-- logfire/_internal/config.py | 11 +++--- logfire/_internal/telemetry_header.py | 55 +++++++++------------------ logfire/experimental/query_client.py | 4 +- logfire/variables/remote.py | 23 +++++------ tests/test_telemetry_header.py | 30 +++++++++------ 7 files changed, 61 insertions(+), 76 deletions(-) diff --git a/logfire/_internal/cli/__init__.py b/logfire/_internal/cli/__init__.py index 4d3125ab4..97b8b3ca2 100644 --- a/logfire/_internal/cli/__init__.py +++ b/logfire/_internal/cli/__init__.py @@ -22,11 +22,10 @@ from ...version import VERSION from ..auth import HOME_LOGFIRE -from ..client import LogfireClient +from ..client import UA_HEADER, LogfireClient from ..config import REGIONS, LogfireCredentials, get_base_url_from_token from ..config_params import ParamManager from ..server_response import install_logfire_response_hook -from ..telemetry_header import TELEMETRY_HEADER_NAME, build_telemetry_header from ..tracer import SDKTracerProvider from .auth import parse_auth, parse_logout from .prompt import parse_prompt @@ -449,7 +448,7 @@ def log_trace_id(response: requests.Response, context: ContextCarrier, *args: An context = get_context() session.hooks = {'response': [functools.partial(log_trace_id, context=context)]} session.headers.update(context) - session.headers[TELEMETRY_HEADER_NAME] = build_telemetry_header() + session.headers['User-Agent'] = UA_HEADER install_logfire_response_hook(session) namespace._session = session namespace.func(namespace) diff --git a/logfire/_internal/client.py b/logfire/_internal/client.py index 7f968c11c..431eb87ed 100644 --- a/logfire/_internal/client.py +++ b/logfire/_internal/client.py @@ -1,5 +1,6 @@ from __future__ import annotations +import platform from typing import Any from urllib.parse import urljoin @@ -11,10 +12,13 @@ from .auth import UserToken, UserTokenCollection from .server_response import ServerResponseCallback, install_logfire_response_hook -from .telemetry_header import TELEMETRY_HEADER_NAME, build_telemetry_header from .utils import UnexpectedResponse -UA_HEADER = f'logfire/{VERSION}' +UA_HEADER = ( + f'logfire-sdk-python/{VERSION} ' + f'({platform.python_implementation()} {platform.python_version()}, ' + f'os {platform.platform()}, arch {platform.machine()})' +) class ProjectAlreadyExists(Exception): @@ -49,7 +53,6 @@ def __init__( { 'Authorization': self._token, 'User-Agent': UA_HEADER, - TELEMETRY_HEADER_NAME: build_telemetry_header(), } ) install_logfire_response_hook(self._session, server_response_hook) diff --git a/logfire/_internal/config.py b/logfire/_internal/config.py index 3a4a9e8f6..960ae01ce 100644 --- a/logfire/_internal/config.py +++ b/logfire/_internal/config.py @@ -71,7 +71,7 @@ from ..propagate import NoExtractTraceContextPropagator, WarnOnExtractTraceContextPropagator from ..types import ExceptionCallback -from .client import InvalidProjectName, LogfireClient, ProjectAlreadyExists +from .client import UA_HEADER, InvalidProjectName, LogfireClient, ProjectAlreadyExists from .config_params import ParamManager, PydanticPluginRecordValues, normalize_token from .constants import ( ATTRIBUTES_CONFIG, @@ -132,7 +132,7 @@ CREDENTIALS_FILENAME = 'logfire_credentials.json' """Default base URL for the Logfire API.""" -COMMON_REQUEST_HEADERS = {'User-Agent': f'logfire/{VERSION}'} +COMMON_REQUEST_HEADERS = {'User-Agent': UA_HEADER} """Common request headers for requests to the Logfire API.""" PROJECT_NAME_PATTERN = r'^[a-z0-9]+(?:-[a-z0-9]+)*$' @@ -1181,11 +1181,12 @@ def check_tokens(): telemetry_header_value = build_telemetry_header(self) for token in token_list: base_url = self.advanced.generate_base_url(token) - headers = { - 'User-Agent': f'logfire/{VERSION}', + headers: dict[str, str] = { + 'User-Agent': UA_HEADER, 'Authorization': token, - TELEMETRY_HEADER_NAME: telemetry_header_value, } + if telemetry_header_value is not None: + headers[TELEMETRY_HEADER_NAME] = telemetry_header_value session = OTLPExporterHttpSession() install_logfire_response_hook(session, self.advanced.server_response_hook) span_exporter = BodySizeCheckingOTLPSpanExporter( diff --git a/logfire/_internal/telemetry_header.py b/logfire/_internal/telemetry_header.py index de66822e9..fcd98c5c7 100644 --- a/logfire/_internal/telemetry_header.py +++ b/logfire/_internal/telemetry_header.py @@ -1,21 +1,17 @@ """Build the `X-Logfire-Telemetry` request header. -The header carries non-sensitive information about the SDK and how it is -configured, encoded as a compact JSON object. The backend uses it to answer -questions like which SDK versions are still in active use, which Python -versions we can drop, and which configuration options users actually enable. +The header carries non-sensitive, config-derived signals about how this SDK +instance is configured, encoded as a compact JSON object. SDK/runtime identity +(version, language, Python version, OS, etc.) lives on the standard +`User-Agent` header instead — see `UA_HEADER` in `_internal/client.py`. Secrets (`token`, `api_key`, `service_name`, etc.) are never included. """ from __future__ import annotations -import functools import json -import sys from typing import TYPE_CHECKING, Any -from logfire.version import VERSION - if TYPE_CHECKING: from .config import LogfireConfig @@ -23,27 +19,6 @@ TELEMETRY_HEADER_NAME = 'X-Logfire-Telemetry' -@functools.cache -def _base_telemetry_pairs() -> dict[str, Any]: - # Each field below has an explicit rationale; do not add a field unless you have one. - return { - # SDK version: the primary signal for deprecation planning — which versions - # are still in active use so we know when it is safe to drop one. - 'sdk_version': VERSION, - # SDK language: lets the same backend ingestion logic distinguish python - # from future SDKs (JS, Rust) without having to parse User-Agent. - 'sdk_language': 'python', - # Python version: tells us when we can drop support for an older Python. - 'python_version': f'{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}', - # Implementation: spotting non-CPython users (pypy, graalpy) before - # changing anything that depends on CPython-specific behaviour. - 'implementation': sys.implementation.name, - # OS: same idea — confirm Windows / Linux / macOS coverage before - # touching platform-sensitive code paths. - 'os': sys.platform, - } - - def _config_telemetry_pairs(config: LogfireConfig) -> dict[str, Any]: """Pick fields of `LogfireConfig` that are useful for product analytics. @@ -75,17 +50,21 @@ def _config_telemetry_pairs(config: LogfireConfig) -> dict[str, Any]: if config._service_instance_id: # pyright: ignore[reportPrivateUsage] # Mirrors the OTLP resource attribute of the same name # (https://opentelemetry.io/docs/specs/semconv/registry/attributes/service/#service-instance-id). - # Carrying it on the header lets the backend correlate metadata with the spans - # this SDK instance is exporting, even on requests that don't carry an OTLP body - # (token validation, variables fetch, CRUD endpoints). + # High-cardinality per-process identifier — kept here rather than in + # User-Agent because user-agent strings are typically aggregated and a + # per-instance id would explode the cardinality of any UA-based analytics. pairs['service_instance_id'] = config._service_instance_id # pyright: ignore[reportPrivateUsage] return pairs -def build_telemetry_header(config: LogfireConfig | None = None) -> str: - """Return the JSON-encoded value for the `X-Logfire-Telemetry` header.""" - pairs: dict[str, Any] = {**_base_telemetry_pairs()} - if config is not None: - pairs.update(_config_telemetry_pairs(config)) - return json.dumps(pairs, separators=(',', ':')) +def build_telemetry_header(config: LogfireConfig | None = None) -> str | None: + """Return the JSON-encoded `X-Logfire-Telemetry` value, or None if no config. + + Without a `LogfireConfig` there is nothing config-specific to report — the + SDK/runtime identity is already in `User-Agent`, so callers should simply + omit the header in that case. + """ + if config is None: + return None + return json.dumps(_config_telemetry_pairs(config), separators=(',', ':')) diff --git a/logfire/experimental/query_client.py b/logfire/experimental/query_client.py index 39a3cee07..e59373a66 100644 --- a/logfire/experimental/query_client.py +++ b/logfire/experimental/query_client.py @@ -6,8 +6,8 @@ from typing_extensions import Self +from logfire._internal.client import UA_HEADER from logfire._internal.config import get_base_url_from_token -from logfire._internal.telemetry_header import TELEMETRY_HEADER_NAME, build_telemetry_header try: from httpx import AsyncClient, Client, Response, Timeout @@ -95,7 +95,7 @@ def __init__(self, base_url: str, read_token: str, timeout: Timeout, client: typ self.timeout = timeout headers = client_kwargs.pop('headers', {}) headers['authorization'] = read_token - headers[TELEMETRY_HEADER_NAME] = build_telemetry_header() + headers['user-agent'] = UA_HEADER self.client: T = client(timeout=timeout, base_url=base_url, headers=headers, **client_kwargs) def _build_query_params( diff --git a/logfire/variables/remote.py b/logfire/variables/remote.py index 3948aa9df..cbac4d786 100644 --- a/logfire/variables/remote.py +++ b/logfire/variables/remote.py @@ -18,7 +18,7 @@ from logfire._internal.client import UA_HEADER from logfire._internal.config import VariablesOptions from logfire._internal.server_response import ServerResponseCallback, install_logfire_response_hook -from logfire._internal.telemetry_header import TELEMETRY_HEADER_NAME, build_telemetry_header +from logfire._internal.telemetry_header import TELEMETRY_HEADER_NAME from logfire._internal.utils import UnexpectedResponse from logfire.variables.abstract import ( ResolvedVariable, @@ -73,9 +73,9 @@ def __init__( server_response_hook: Optional override for the API response hook (see `AdvancedOptions.server_response_hook`). telemetry_header: Pre-built `X-Logfire-Telemetry` header value carrying the - SDK's `service.instance.id` so it matches the OTLP resource attribute. - When None (e.g. lazily instantiated outside of `_initialize`), a base - header without config-derived fields is built here. + SDK's config-derived signals (including `service.instance.id` so it + matches the OTLP resource attribute). When None, the header is omitted — + SDK/runtime identity is still sent on the standard `User-Agent` header. """ block_before_first_resolve = options.block_before_first_resolve polling_interval = options.polling_interval @@ -83,15 +83,11 @@ def __init__( self._base_url = base_url self._token = token self._server_response_hook = server_response_hook - self._telemetry_header = telemetry_header if telemetry_header is not None else build_telemetry_header() + self._telemetry_header = telemetry_header self._session = Session() - self._session.headers.update( - { - 'Authorization': f'bearer {token}', - 'User-Agent': UA_HEADER, - TELEMETRY_HEADER_NAME: self._telemetry_header, - } - ) + self._session.headers.update({'Authorization': f'bearer {token}', 'User-Agent': UA_HEADER}) + if self._telemetry_header is not None: + self._session.headers[TELEMETRY_HEADER_NAME] = self._telemetry_header install_logfire_response_hook(self._session, server_response_hook) self._timeout = options.timeout self._block_before_first_fetch = block_before_first_resolve @@ -217,11 +213,12 @@ def _sse_listener(self): # pragma: no cover { 'Authorization': f'bearer {self._token}', 'User-Agent': UA_HEADER, - TELEMETRY_HEADER_NAME: self._telemetry_header, 'Accept': 'text/event-stream', 'Cache-Control': 'no-cache', } ) + if self._telemetry_header is not None: + sse_session.headers[TELEMETRY_HEADER_NAME] = self._telemetry_header install_logfire_response_hook(sse_session, self._server_response_hook) # Open streaming connection diff --git a/tests/test_telemetry_header.py b/tests/test_telemetry_header.py index 4e3ec8ef6..92834008a 100644 --- a/tests/test_telemetry_header.py +++ b/tests/test_telemetry_header.py @@ -8,6 +8,7 @@ import requests_mock import logfire +from logfire._internal.client import UA_HEADER from logfire._internal.config import GLOBAL_CONFIG, LogfireCredentials from logfire._internal.telemetry_header import TELEMETRY_HEADER_NAME, build_telemetry_header from logfire.version import VERSION @@ -17,18 +18,14 @@ def _parse_header(value: str) -> dict[str, Any]: return json.loads(value) -def test_build_telemetry_header_without_config(): - pairs = _parse_header(build_telemetry_header()) - assert pairs['sdk_version'] == VERSION - assert pairs['sdk_language'] == 'python' - assert pairs['python_version'] - assert pairs['implementation'] - assert pairs['os'] +def test_build_telemetry_header_without_config_is_none(): + assert build_telemetry_header() is None def test_build_telemetry_header_with_config(): - pairs = _parse_header(build_telemetry_header(GLOBAL_CONFIG)) - assert pairs['sdk_version'] == VERSION + header = build_telemetry_header(GLOBAL_CONFIG) + assert header is not None + pairs = _parse_header(header) for key in ('code_source_set', 'variables_set', 'token_count'): assert key in pairs @@ -47,6 +44,7 @@ def test_telemetry_header_excludes_secrets(): ) try: header = build_telemetry_header(GLOBAL_CONFIG) + assert header is not None for secret in secrets: assert secret not in header finally: @@ -54,6 +52,12 @@ def test_telemetry_header_excludes_secrets(): logfire.configure(send_to_logfire=False, console=False) +def test_user_agent_carries_sdk_identity(): + assert UA_HEADER.startswith(f'logfire-sdk-python/{VERSION} (') + assert 'os ' in UA_HEADER + assert 'arch ' in UA_HEADER + + def test_otlp_export_sends_telemetry_header(): captured: list[dict[str, str]] = [] @@ -81,9 +85,11 @@ def _capture(request: requests.PreparedRequest, _context: object) -> str: assert any(TELEMETRY_HEADER_NAME in headers for headers in captured) [headers] = [headers for headers in captured if TELEMETRY_HEADER_NAME in headers] pairs = _parse_header(headers[TELEMETRY_HEADER_NAME]) - assert pairs['sdk_version'] == VERSION assert pairs['token_count'] == 1 assert 'abc1' not in headers[TELEMETRY_HEADER_NAME] + # SDK identity travels on User-Agent, not the telemetry header. + assert 'sdk_version' not in pairs + assert headers['User-Agent'].startswith(f'logfire-sdk-python/{VERSION}') # The header must advertise the same `service.instance.id` carried by OTLP # resource attributes so the backend can correlate the two. resource = GLOBAL_CONFIG.get_tracer_provider().resource @@ -98,7 +104,7 @@ def test_from_token_sends_telemetry_header(): ) session = requests.Session() LogfireCredentials.from_token( - 'pylf_v1_us_xxx', session, 'https://logfire-us.pydantic.dev', telemetry_header='{"sdk_version":"1.2.3"}' + 'pylf_v1_us_xxx', session, 'https://logfire-us.pydantic.dev', telemetry_header='{"token_count":1}' ) [history] = m.request_history - assert history.headers[TELEMETRY_HEADER_NAME] == '{"sdk_version":"1.2.3"}' + assert history.headers[TELEMETRY_HEADER_NAME] == '{"token_count":1}'