Repository navigation
Surface X-Logfire-Warning response headers from the Logfire API
#153
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| //! Out-of-band signals which the Logfire backend attaches to its API responses. | ||
| //! | ||
| //! The backend sets the [`WARNING_HEADER_NAME`] header on responses to signal something it wants | ||
| //! the user to see (for example the deprecation of an endpoint), without relying on the response | ||
| //! body. SDKs surface the header value as a warning on stderr. | ||
|
|
||
| use std::{ | ||
| collections::HashSet, | ||
| sync::{Mutex, OnceLock, PoisonError}, | ||
| }; | ||
|
|
||
| /// The response header used by the Logfire backend to send a warning to the SDK. | ||
| pub const WARNING_HEADER_NAME: &str = "x-logfire-warning"; | ||
|
|
||
| /// Print a warning received from the Logfire backend to stderr. | ||
| /// | ||
| /// Repeats of the same message are suppressed, so that a chatty server only warns once per | ||
| /// 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}"); | ||
| } | ||
| } | ||
|
|
||
| /// Record `message` as seen, returning whether it had not been seen before. | ||
| fn is_first_occurrence(message: &str) -> bool { | ||
| static SEEN: OnceLock<Mutex<HashSet<String>>> = OnceLock::new(); | ||
| SEEN.get_or_init(Mutex::default) | ||
| .lock() | ||
| .unwrap_or_else(PoisonError::into_inner) | ||
| .insert(message.to_owned()) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 High
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed. Would you like me to prepare that bounded deduplication change? |
||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::is_first_occurrence; | ||
|
|
||
| #[test] | ||
| fn repeated_warnings_are_deduplicated() { | ||
| let message = "repeated_warnings_are_deduplicated"; // unique to this test | ||
| assert!(is_first_occurrence(message)); | ||
| assert!(!is_first_occurrence(message)); | ||
| assert!(is_first_occurrence("a different message")); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,7 +4,15 @@ | |
| feature = "export-http-protobuf", | ||
| feature = "export-http-json" | ||
| )), | ||
| expect(unreachable_code, unused_variables) | ||
| expect(unreachable_code) | ||
| )] | ||
| #![cfg_attr( | ||
| not(any(feature = "export-http-protobuf", feature = "export-http-json")), | ||
| allow( | ||
| unused_variables, | ||
| clippy::needless_pass_by_value, | ||
| reason = "the endpoint configuration and server response hook are only used for exports over HTTP" | ||
| ) | ||
| )] | ||
| //! Helper functions to configure exporters via OTLP. | ||
| use std::collections::HashMap; | ||
|
|
@@ -14,7 +22,7 @@ use opentelemetry_sdk::{ | |
| logs::LogExporter, metrics::exporter::PushMetricExporter, trace::SpanExporter, | ||
| }; | ||
|
|
||
| use crate::{ConfigureError, internal::env::get_optional_env}; | ||
| use crate::{ConfigureError, internal::env::get_optional_env, server_response::ServerResponseHook}; | ||
|
|
||
| /// The `User-Agent` sent with OTLP exports, e.g. `logfire-rust/0.13.0`. | ||
| /// | ||
|
|
@@ -85,6 +93,15 @@ macro_rules! feature_required { | |
| pub fn span_exporter( | ||
| endpoint: &str, | ||
| headers: Option<HashMap<String, String>>, | ||
| ) -> Result<impl SpanExporter + use<>, ConfigureError> { | ||
| span_exporter_with_hook(endpoint, headers, None) | ||
| } | ||
|
|
||
| /// As [`span_exporter`], with a hook called for each response received from the Logfire API. | ||
| pub(crate) fn span_exporter_with_hook( | ||
| endpoint: &str, | ||
| headers: Option<HashMap<String, String>>, | ||
| server_response_hook: Option<ServerResponseHook>, | ||
| ) -> Result<impl SpanExporter + use<>, ConfigureError> { | ||
| let protocol = protocol_from_env("OTEL_EXPORTER_OTLP_TRACES_PROTOCOL")?; | ||
|
|
||
|
|
@@ -113,6 +130,10 @@ pub fn span_exporter( | |
| .with_http() | ||
| .with_protocol(Protocol::HttpBinary) | ||
| .with_headers(headers_with_user_agent(headers)) | ||
| .with_http_client(http_client( | ||
| "OTEL_EXPORTER_OTLP_TRACES_TIMEOUT", | ||
| server_response_hook, | ||
| )?) | ||
| .with_endpoint(format!("{endpoint}/v1/traces")) | ||
| .build()? | ||
| } | ||
|
|
@@ -125,6 +146,10 @@ pub fn span_exporter( | |
| .with_http() | ||
| .with_protocol(Protocol::HttpJson) | ||
| .with_headers(headers_with_user_agent(headers)) | ||
| .with_http_client(http_client( | ||
| "OTEL_EXPORTER_OTLP_TRACES_TIMEOUT", | ||
| server_response_hook, | ||
| )?) | ||
| .with_endpoint(format!("{endpoint}/v1/traces")) | ||
| .build()? | ||
| } | ||
|
|
@@ -170,6 +195,15 @@ pub fn span_exporter( | |
| pub fn metric_exporter( | ||
| endpoint: &str, | ||
| headers: Option<HashMap<String, String>>, | ||
| ) -> Result<impl PushMetricExporter + use<>, ConfigureError> { | ||
| metric_exporter_with_hook(endpoint, headers, None) | ||
| } | ||
|
|
||
| /// As [`metric_exporter`], with a hook called for each response received from the Logfire API. | ||
| pub(crate) fn metric_exporter_with_hook( | ||
| endpoint: &str, | ||
| headers: Option<HashMap<String, String>>, | ||
| server_response_hook: Option<ServerResponseHook>, | ||
| ) -> Result<impl PushMetricExporter + use<>, ConfigureError> { | ||
| let protocol = protocol_from_env("OTEL_EXPORTER_OTLP_METRICS_PROTOCOL")?; | ||
|
|
||
|
|
@@ -200,6 +234,10 @@ pub fn metric_exporter( | |
| .with_http() | ||
| .with_protocol(Protocol::HttpBinary) | ||
| .with_headers(headers_with_user_agent(headers)) | ||
| .with_http_client(http_client( | ||
| "OTEL_EXPORTER_OTLP_METRICS_TIMEOUT", | ||
| server_response_hook, | ||
| )?) | ||
| .with_endpoint(format!("{endpoint}/v1/metrics")) | ||
| .build()?) | ||
| } | ||
|
|
@@ -213,6 +251,10 @@ pub fn metric_exporter( | |
| .with_http() | ||
| .with_protocol(Protocol::HttpJson) | ||
| .with_headers(headers_with_user_agent(headers)) | ||
| .with_http_client(http_client( | ||
| "OTEL_EXPORTER_OTLP_METRICS_TIMEOUT", | ||
| server_response_hook, | ||
| )?) | ||
| .with_endpoint(format!("{endpoint}/v1/metrics")) | ||
| .build()?) | ||
| } | ||
|
|
@@ -249,6 +291,15 @@ pub fn metric_exporter( | |
| pub fn log_exporter( | ||
| endpoint: &str, | ||
| headers: Option<HashMap<String, String>>, | ||
| ) -> Result<impl LogExporter + use<>, ConfigureError> { | ||
| log_exporter_with_hook(endpoint, headers, None) | ||
| } | ||
|
|
||
| /// As [`log_exporter`], with a hook called for each response received from the Logfire API. | ||
| pub(crate) fn log_exporter_with_hook( | ||
| endpoint: &str, | ||
| headers: Option<HashMap<String, String>>, | ||
| server_response_hook: Option<ServerResponseHook>, | ||
| ) -> Result<impl LogExporter + use<>, ConfigureError> { | ||
| let protocol = protocol_from_env("OTEL_EXPORTER_OTLP_LOGS_PROTOCOL")?; | ||
|
|
||
|
|
@@ -277,6 +328,10 @@ pub fn log_exporter( | |
| .with_http() | ||
| .with_protocol(Protocol::HttpBinary) | ||
| .with_headers(headers_with_user_agent(headers)) | ||
| .with_http_client(http_client( | ||
| "OTEL_EXPORTER_OTLP_LOGS_TIMEOUT", | ||
| server_response_hook, | ||
| )?) | ||
| .with_endpoint(format!("{endpoint}/v1/logs")) | ||
| .build()?) | ||
| } | ||
|
|
@@ -289,6 +344,10 @@ pub fn log_exporter( | |
| .with_http() | ||
| .with_protocol(Protocol::HttpJson) | ||
| .with_headers(headers_with_user_agent(headers)) | ||
| .with_http_client(http_client( | ||
| "OTEL_EXPORTER_OTLP_LOGS_TIMEOUT", | ||
| server_response_hook, | ||
| )?) | ||
| .with_endpoint(format!("{endpoint}/v1/logs")) | ||
| .build()?) | ||
| } | ||
|
|
@@ -309,6 +368,41 @@ pub fn log_exporter( | |
| } | ||
| } | ||
|
|
||
| /// The default OTLP export timeout, matching the default used by `opentelemetry-otlp`. | ||
| #[cfg(any(feature = "export-http-protobuf", feature = "export-http-json"))] | ||
| const DEFAULT_EXPORT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); | ||
|
|
||
| /// Build the `reqwest` client used for HTTP exports. | ||
| /// | ||
| /// `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"))] | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? |
||
| fn http_client( | ||
| signal_timeout_var: &str, | ||
| server_response_hook: Option<ServerResponseHook>, | ||
| ) -> 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)? | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 High An inherited non-UTF-8 🚀 Reply "fix it for me" or copy this AI Prompt for your agent: |
||
| && let Ok(millis) = value.parse() | ||
| { | ||
| timeout = std::time::Duration::from_millis(millis); | ||
| break; | ||
| } | ||
| } | ||
|
|
||
| let client = reqwest::Client::builder() | ||
| .timeout(timeout) | ||
| .build() | ||
| .map_err(|e| ConfigureError::Other(e.into()))?; | ||
|
|
||
| Ok(crate::server_response::LogfireHttpClient::new( | ||
| client, | ||
| server_response_hook, | ||
| )) | ||
| } | ||
|
|
||
| /// Build the `tonic` endpoint used for gRPC exports. | ||
| /// | ||
| /// TLS is configured here because `tonic` only configures it itself in `Endpoint::new`, which | ||
|
|
||
There was a problem hiding this comment.
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:21emit_warningpanics when writing the warning tostderrfails, so a valid warning header can crash the query/export path whenstderris closed, full, or non-blocking. Replaceeprintln!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:
There was a problem hiding this comment.
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?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
logfire-corehas no logging facade dependency, andwarn!would be a no-op without a configured logger, changing the promised stderr behavior. A falliblewriteln!(std::io::stderr(), ...)while ignoring the error preserves it without panicking.