Skip to content

Surface X-Logfire-Warning response headers from the Logfire API - #153

Closed
adriangb wants to merge 1 commit into
mainfrom
claude/logfire-1906-rust-support-8831d1
Closed

adriangb wants to merge 1 commit into
mainfrom
claude/logfire-1906-rust-support-8831d1

Conversation

@adriangb

Copy link
Copy Markdown
Member

Rust support for pydantic/logfire#1906: the backend attaches an x-logfire-warning header to API responses to signal something it wants the user to see (e.g. a deprecated endpoint) without relying on the response body.

What this adds

  • logfire-core::server_response — the shared header name and emit_warning(), which prints Logfire server warning: <message> to stderr, deduplicated so a chatty server only warns once per process (the stand-in for the dedupe Python gets from warnings).
  • OTLP export responses over HTTP and logfire-client query responses are now inspected, and surface any warning by default.
  • AdvancedOptions::with_server_response_hook mirrors the Python SDK's server_response_hook: it replaces the default behaviour, and a hook can call ServerResponse::default_hook() to keep it or do nothing to opt out entirely.

Notes for review

  • To see response headers the OTLP exporters now use an HTTP client built by this SDK rather than the one opentelemetry-otlp builds internally. That client is otherwise identical, but it does mean the export timeout (OTEL_EXPORTER_OTLP_<SIGNAL>_TIMEOUT / OTEL_EXPORTER_OTLP_TIMEOUT, default 10s) is now resolved in exporters::http_client rather than upstream — a small duplication of opentelemetry-otlp's own resolution.
  • The hook runs before error_for_status() (unlike the upstream HttpClient for reqwest::Client impl), so a warning sent alongside an error response is not lost.
  • gRPC exports are not covered: WithTonicConfig::with_channel takes a concrete tonic::transport::Channel and tonic interceptors only see requests, so there is nowhere to inspect responses. This is documented on the module and on the option. HTTP is the default protocol, so the default path is covered.
  • No CHANGELOG.md entry, since entries there are written with PR links at release prep.

Tests

Unit tests for the dedupe, header parsing and hook dispatch, plus an integration test asserting the hook fires for both HTTP protocols including on a 500. cargo test (all-features, no-default-features and doctests), clippy across each feature combination, fmt and the docs build are all clean.

🤖 Generated with Claude Code

The Logfire backend attaches an `x-logfire-warning` header to API responses
to signal things it wants the user to see (e.g. a deprecated endpoint)
without relying on the response body, as added to the Python SDK in
pydantic/logfire#1906.

Responses to OTLP exports over HTTP and to `logfire-client` queries are now
inspected, and any warning is printed to stderr (deduplicated, so a chatty
server only warns once per process).

`AdvancedOptions::with_server_response_hook` replaces that default
behaviour, mirroring the Python SDK's `server_response_hook`; the hook can
call `ServerResponse::default_hook` to keep it, or do nothing to opt out.

To see response headers the OTLP exporters now use an HTTP client built by
this SDK rather than the one `opentelemetry-otlp` builds internally; there
is no equivalent for gRPC exports, which is documented.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 82.05128% with 35 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
logfire/src/exporters.rs 78.46% 6 Missing and 8 partials ⚠️
logfire/src/server_response.rs 86.30% 10 Missing ⚠️
logfire-core/src/server_response.rs 70.58% 5 Missing ⚠️
logfire-client/src/client.rs 82.35% 3 Missing ⚠️
logfire/src/logfire.rs 82.35% 0 Missing and 3 partials ⚠️

📢 Thoughts on this report? Let us know!

Comment thread logfire/src/exporters.rs
) -> Result<crate::server_response::LogfireHttpClient, ConfigureError> {
let mut timeout = DEFAULT_EXPORT_TIMEOUT;
for var_name in [signal_timeout_var, "OTEL_EXPORTER_OTLP_TIMEOUT"] {
if let Some(value) = get_optional_env(var_name, None)?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High src/exporters.rs:387

An inherited non-UTF-8 OTEL_EXPORTER_OTLP_*_TIMEOUT value makes every HTTP exporter construction return an error and prevents Logfire initialization. get_optional_env(var_name, None)? propagates the decoding error instead of treating that value like an invalid timeout and continuing to the next variable or the default; ignore the environment lookup error before parsing the value.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @logfire/src/exporters.rs around line 387:

An inherited non-UTF-8 `OTEL_EXPORTER_OTLP_*_TIMEOUT` value makes every HTTP exporter construction return an error and prevents Logfire initialization. `get_optional_env(var_name, None)?` propagates the decoding error instead of treating that value like an invalid timeout and continuing to the next variable or the default; ignore the environment lookup error before parsing the value.

/// process (matching the deduplication which Python's `warnings` module does for the Python SDK).
pub fn emit_warning(message: &str) {
if is_first_occurrence(message) {
eprintln!("Logfire server warning: {message}");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High src/server_response.rs:21

emit_warning panics when writing the warning to stderr fails, so a valid warning header can crash the query/export path when stderr is closed, full, or non-blocking. Replace eprintln! with fallible stderr I/O and ignore or otherwise handle the write error.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @logfire-core/src/server_response.rs around line 21:

`emit_warning` panics when writing the warning to `stderr` fails, so a valid warning header can crash the query/export path when `stderr` is closed, full, or non-blocking. Replace `eprintln!` with fallible stderr I/O and ignore or otherwise handle the write error.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use warn! instead?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logfire-core has no logging facade dependency, and warn! would be a no-op without a configured logger, changing the promised stderr behavior. A fallible writeln!(std::io::stderr(), ...) while ignoring the error preserves it without panicking.

SEEN.get_or_init(Mutex::default)
.lock()
.unwrap_or_else(PoisonError::into_inner)
.insert(message.to_owned())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High src/server_response.rs:31

is_first_occurrence retains every distinct server-supplied warning in the process-global HashSet, so unique x-logfire-warning values accumulate for the entire process lifetime and can cause unbounded memory growth or OOM. Bound the deduplication state or add eviction so changing backend warnings cannot permanently consume memory.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @logfire-core/src/server_response.rs around line 31:

`is_first_occurrence` retains every distinct server-supplied warning in the process-global `HashSet`, so unique `x-logfire-warning` values accumulate for the entire process lifetime and can cause unbounded memory growth or OOM. Bound the deduplication state or add eviction so changing backend warnings cannot permanently consume memory.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, this is not acceptable. It's unlikely warnings are high cardinality, I wonder if a fixed size ordered list wouldn't be better.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Would you like me to prepare that bounded deduplication change?

/// process (matching the deduplication which Python's `warnings` module does for the Python SDK).
pub fn emit_warning(message: &str) {
if is_first_occurrence(message) {
eprintln!("Logfire server warning: {message}");

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use warn! instead?

SEEN.get_or_init(Mutex::default)
.lock()
.unwrap_or_else(PoisonError::into_inner)
.insert(message.to_owned())

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, this is not acceptable. It's unlikely warnings are high cardinality, I wonder if a fixed size ordered list wouldn't be better.


#[cfg(any(feature = "export-http-protobuf", feature = "export-http-json"))]
#[async_trait::async_trait]
impl opentelemetry_http::HttpClient for LogfireHttpClient {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess this duplication / vendoring is unavoidable if we want to support this.

Comment on lines +76 to +79
#[cfg_attr(
not(any(feature = "export-http-protobuf", feature = "export-http-json")),
allow(dead_code, reason = "only used by the HTTP exporters")
)]

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Presumably we could have a version of this that works for gRPC as well? Not sure if there's a blocker there or we avoided it for complexity reasons.

Comment thread logfire/src/exporters.rs
/// `opentelemetry-otlp` would build one itself, but logfire needs its own so that responses can
/// be passed to the [server response hook][crate::config::AdvancedOptions::with_server_response_hook].
/// That means resolving the export timeout here too, the same way `opentelemetry-otlp` does.
#[cfg(any(feature = "export-http-protobuf", feature = "export-http-json"))]

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like a lot of new code touching unrelated things (export timeouts, etc.). I guess this is also something we'd have to vendor?

@adriangb

Copy link
Copy Markdown
Member Author

Overall seems like too much faff & risk to take no right now w/ no one asking for this. I'm going to prioritize stability and not introducing bugs over supporting this feature.

@adriangb adriangb closed this Sep 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants