diff --git a/logfire-client/src/client.rs b/logfire-client/src/client.rs index 6c3e1db..249e457 100644 --- a/logfire-client/src/client.rs +++ b/logfire-client/src/client.rs @@ -3,6 +3,8 @@ use reqwest::StatusCode; use serde::de::DeserializeOwned; +use logfire_core::server_response::{WARNING_HEADER_NAME, emit_warning}; + use crate::types::{ReadTokenInfo, RowQueryResults, SchemasResponse}; /// Errors that can occur when executing queries. @@ -30,6 +32,13 @@ pub enum ClientError { RowDeserialize(#[source] serde_json::Error), } +/// The warning which the Logfire backend sent with a response, if any. +fn warning_header(headers: &reqwest::header::HeaderMap) -> Option<&str> { + headers + .get(WARNING_HEADER_NAME) + .and_then(|value| value.to_str().ok()) +} + /// HTTP client for querying Logfire. #[derive(Clone, Debug)] pub struct LogfireClient { @@ -58,6 +67,10 @@ impl LogfireClient { let response = request.send().await.map_err(ClientError::Request)?; + if let Some(warning) = warning_header(response.headers()) { + emit_warning(warning); + } + let status = response.status(); if !status.is_success() { let body = response.text().await.unwrap_or_default(); @@ -102,9 +115,21 @@ impl LogfireClient { #[cfg(test)] mod tests { - use super::{ClientError, LogfireClient, StatusCode}; + use super::{ClientError, LogfireClient, StatusCode, warning_header}; use crate::builder::LogfireClientBuilder; + #[test] + fn warning_header_is_read() { + let mut headers = reqwest::header::HeaderMap::new(); + assert_eq!(warning_header(&headers), None); + + headers.insert( + "X-Logfire-Warning", + reqwest::header::HeaderValue::from_static("endpoint is deprecated"), + ); + assert_eq!(warning_header(&headers), Some("endpoint is deprecated")); + } + #[test] fn query_failed_error_format() { let err = ClientError::QueryFailed { diff --git a/logfire-core/src/lib.rs b/logfire-core/src/lib.rs index caec092..ebf032a 100644 --- a/logfire-core/src/lib.rs +++ b/logfire-core/src/lib.rs @@ -3,6 +3,7 @@ //! This crate provides common functionality used by both the instrumentation SDK (`logfire`) //! and the query client (`logfire-client`). +pub mod server_response; mod token; pub use token::{Region, parse_region}; diff --git a/logfire-core/src/server_response.rs b/logfire-core/src/server_response.rs new file mode 100644 index 0000000..af07131 --- /dev/null +++ b/logfire-core/src/server_response.rs @@ -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>> = OnceLock::new(); + SEEN.get_or_init(Mutex::default) + .lock() + .unwrap_or_else(PoisonError::into_inner) + .insert(message.to_owned()) +} + +#[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")); + } +} diff --git a/logfire/Cargo.toml b/logfire/Cargo.toml index 525113b..1d80f6e 100644 --- a/logfire/Cargo.toml +++ b/logfire/Cargo.toml @@ -15,10 +15,15 @@ logfire-core.workspace = true log = "0.4" env_filter = "2" +http = "1.2" + # deps for grpc export -http = { version = "1.2", optional = true } tonic = { version = "0.14", optional = true } +# deps for http export +async-trait = { version = "0.1", optional = true } +opentelemetry-http = { version = "0.32", optional = true, default-features = false } + # TLS for OTLP exports. `reqwest` must stay on the same major version as `opentelemetry-http`'s, # so that the TLS features enabled here apply to the client it builds. reqwest = { version = "0.13", optional = true, default-features = false } @@ -108,7 +113,6 @@ data-dir = ["dep:serde", "dep:serde_json"] export-grpc = [ "opentelemetry-otlp/grpc-tonic", "tonic/tls-native-roots", - "dep:http", "dep:rustls", "dep:tonic", ] @@ -116,12 +120,16 @@ export-http-protobuf = [ "opentelemetry-otlp/http-proto", "opentelemetry-otlp/reqwest-client", "reqwest/rustls-no-provider", + "dep:async-trait", + "dep:opentelemetry-http", "dep:rustls", ] export-http-json = [ "opentelemetry-otlp/http-json", "opentelemetry-otlp/reqwest-client", "reqwest/rustls-no-provider", + "dep:async-trait", + "dep:opentelemetry-http", "dep:rustls", ] # Use aws-lc-rs for TLS when the application has not installed a rustls `CryptoProvider`. diff --git a/logfire/src/config.rs b/logfire/src/config.rs index ec3db7f..0565bb8 100644 --- a/logfire/src/config.rs +++ b/logfire/src/config.rs @@ -19,7 +19,12 @@ use opentelemetry_sdk::{ }; use tracing::{Level, level_filters::LevelFilter}; -use crate::{ConfigureError, internal::env::get_optional_env, logfire::Logfire}; +use crate::{ + ConfigureError, + internal::env::get_optional_env, + logfire::Logfire, + server_response::{ServerResponse, ServerResponseHook}, +}; /// Builder for logfire configuration, returned from [`logfire::configure()`][crate::configure]. #[must_use = "call `.finish()` to complete logfire configuration."] @@ -393,6 +398,7 @@ pub struct AdvancedOptions { pub(crate) resources: Vec, pub(crate) log_record_processors: Vec, pub(crate) enable_tracing_metrics: bool, + pub(crate) server_response_hook: Option, // // // TODO: arguments below supported by Python @@ -444,6 +450,38 @@ impl AdvancedOptions { self.enable_tracing_metrics = enable; self } + + /// Set a hook which is called for every response received from the Logfire API. + /// + /// This is experimental and may change in a future release. + /// + /// The hook replaces the default behaviour, which prints any warning sent by the Logfire + /// backend (the `x-logfire-warning` response header) to stderr. Call + /// [`ServerResponse::default_hook`] from the hook to keep that behaviour, or set a hook which + /// does nothing to opt out of it entirely. + /// + /// The hook is called on the SDK's export thread, so it should not block. + /// + /// Responses are only inspected for exports over HTTP (the default protocol); the + /// `export-grpc` feature has no equivalent support. + /// + /// ```rust + /// use logfire::config::AdvancedOptions; + /// + /// let advanced = AdvancedOptions::default().with_server_response_hook(|response| { + /// if let Some(warning) = response.warning() { + /// tracing::warn!("logfire: {warning}"); + /// } + /// }); + /// ``` + #[must_use] + pub fn with_server_response_hook(mut self, hook: F) -> Self + where + F: Fn(&ServerResponse<'_>) + Send + Sync + 'static, + { + self.server_response_hook = Some(Arc::new(hook)); + self + } } /// Configuration of metrics. diff --git a/logfire/src/exporters.rs b/logfire/src/exporters.rs index 1af829d..33b6dd8 100644 --- a/logfire/src/exporters.rs +++ b/logfire/src/exporters.rs @@ -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>, +) -> Result, 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>, + server_response_hook: Option, ) -> Result, 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>, +) -> Result, 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>, + server_response_hook: Option, ) -> Result, 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>, +) -> Result, 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>, + server_response_hook: Option, ) -> Result, 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"))] +fn http_client( + signal_timeout_var: &str, + server_response_hook: Option, +) -> Result { + 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)? + && 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 diff --git a/logfire/src/lib.rs b/logfire/src/lib.rs index 2de2ead..90aa35d 100644 --- a/logfire/src/lib.rs +++ b/logfire/src/lib.rs @@ -125,6 +125,7 @@ pub mod config; pub mod exporters; mod logfire; mod metrics; +pub mod server_response; mod ulid_id_generator; pub use macros::*; diff --git a/logfire/src/logfire.rs b/logfire/src/logfire.rs index 0853dc2..8b2b757 100644 --- a/logfire/src/logfire.rs +++ b/logfire/src/logfire.rs @@ -51,6 +51,7 @@ use crate::{ pending_span_processor::PendingSpanProcessor, }, metrics, + server_response::ServerResponseHook, ulid_id_generator::UlidIdGenerator, }; @@ -416,6 +417,7 @@ impl Logfire { logfire_base_url, http_headers, config.metrics.is_some(), + advanced_options.server_response_hook, )?; tracer_provider_builder = tracer_provider_builder.with_span_processor( @@ -743,6 +745,7 @@ fn spawn_runtime_and_exporters( logfire_base_url: &str, http_headers: Option>, enable_metrics: bool, + server_response_hook: Option, ) -> Result< ( tokio::sync::oneshot::Sender<()>, @@ -801,7 +804,11 @@ fn spawn_runtime_and_exporters( let _rt_guard = handle.enter(); let span_processor = BatchSpanProcessor::builder( - crate::exporters::span_exporter(logfire_base_url, http_headers.clone())?, + crate::exporters::span_exporter_with_hook( + logfire_base_url, + http_headers.clone(), + server_response_hook.clone(), + )?, runtime::Tokio, ) .with_batch_config( @@ -813,7 +820,11 @@ fn spawn_runtime_and_exporters( let log_processor = LogProcessorShutdownHack::new( BatchLogProcessor::builder( - crate::exporters::log_exporter(logfire_base_url, http_headers.clone())?, + crate::exporters::log_exporter_with_hook( + logfire_base_url, + http_headers.clone(), + server_response_hook.clone(), + )?, runtime::Tokio, ) .build(), @@ -822,7 +833,11 @@ fn spawn_runtime_and_exporters( let metrics_processor = if enable_metrics { Some( PeriodicReader::builder( - crate::exporters::metric_exporter(logfire_base_url, http_headers)?, + crate::exporters::metric_exporter_with_hook( + logfire_base_url, + http_headers, + server_response_hook, + )?, runtime::Tokio, ) .build(), diff --git a/logfire/src/server_response.rs b/logfire/src/server_response.rs new file mode 100644 index 0000000..e2bcfe9 --- /dev/null +++ b/logfire/src/server_response.rs @@ -0,0 +1,198 @@ +//! Handling of responses received from the Logfire API. +//! +//! The Logfire backend can attach out-of-band information to the responses of API calls, which +//! this SDK surfaces to the user. Currently the only such signal is a warning header, which is +//! printed to stderr by default (deduplicated, so a chatty server only warns once per process). +//! +//! Use [`AdvancedOptions::with_server_response_hook`][crate::config::AdvancedOptions::with_server_response_hook] +//! to customize or disable this behaviour. +//! +//! # Note +//! +//! Responses are only inspected for exports over HTTP (the default protocol); the `export-grpc` +//! feature has no equivalent support. + +use std::sync::Arc; + +use logfire_core::server_response::WARNING_HEADER_NAME; + +/// A response received from the Logfire API, passed to the +/// [server response hook][crate::config::AdvancedOptions::with_server_response_hook]. +/// +/// This is experimental and may change in a future release. +#[derive(Debug)] +pub struct ServerResponse<'a> { + status: http::StatusCode, + headers: &'a http::HeaderMap, +} + +impl<'a> ServerResponse<'a> { + #[cfg_attr( + not(any(feature = "export-http-protobuf", feature = "export-http-json")), + allow(dead_code, reason = "only used by the HTTP exporters") + )] + pub(crate) fn new(status: http::StatusCode, headers: &'a http::HeaderMap) -> Self { + Self { status, headers } + } + + /// The status code of the response. + #[must_use] + pub fn status(&self) -> http::StatusCode { + self.status + } + + /// The headers of the response. + #[must_use] + pub fn headers(&self) -> &'a http::HeaderMap { + self.headers + } + + /// The warning which the Logfire backend sent with this response, if any. + /// + /// Returns `None` if the header is absent or is not valid ASCII. + #[must_use] + pub fn warning(&self) -> Option<&'a str> { + self.headers + .get(WARNING_HEADER_NAME) + .and_then(|value| value.to_str().ok()) + } + + /// The default handling of a response: print any [`warning`][Self::warning] to stderr. + /// + /// Call this from a custom hook to keep the default behaviour alongside your own. + pub fn default_hook(&self) { + if let Some(warning) = self.warning() { + logfire_core::server_response::emit_warning(warning); + } + } +} + +/// A hook called for every response which the SDK receives from the Logfire API. +/// +/// See [`AdvancedOptions::with_server_response_hook`][crate::config::AdvancedOptions::with_server_response_hook]. +pub type ServerResponseHook = Arc) + Send + Sync>; + +/// Run the configured hook for a response, or the default behaviour if no hook is configured. +#[cfg_attr( + not(any(feature = "export-http-protobuf", feature = "export-http-json")), + allow(dead_code, reason = "only used by the HTTP exporters") +)] +pub(crate) fn handle_response(hook: Option<&ServerResponseHook>, response: &ServerResponse<'_>) { + match hook { + Some(hook) => hook(response), + None => response.default_hook(), + } +} + +/// An [`HttpClient`][opentelemetry_http::HttpClient] which passes every response from the Logfire +/// API to the configured hook before handing it back to the OTLP exporter. +#[cfg(any(feature = "export-http-protobuf", feature = "export-http-json"))] +pub(crate) struct LogfireHttpClient { + client: reqwest::Client, + hook: Option, +} + +#[cfg(any(feature = "export-http-protobuf", feature = "export-http-json"))] +impl LogfireHttpClient { + pub(crate) fn new(client: reqwest::Client, hook: Option) -> Self { + Self { client, hook } + } +} + +#[cfg(any(feature = "export-http-protobuf", feature = "export-http-json"))] +impl std::fmt::Debug for LogfireHttpClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("LogfireHttpClient") + .field("client", &self.client) + .field("hook", &self.hook.as_ref().map(|_| "")) + .finish() + } +} + +#[cfg(any(feature = "export-http-protobuf", feature = "export-http-json"))] +#[async_trait::async_trait] +impl opentelemetry_http::HttpClient for LogfireHttpClient { + async fn send_bytes( + &self, + request: opentelemetry_http::Request, + ) -> Result< + opentelemetry_http::Response, + opentelemetry_http::HttpError, + > { + // this mirrors the `HttpClient` implementation for `reqwest::Client` in + // `opentelemetry-http`, with the response passed to the hook before the status is checked + // (so that warnings sent alongside an error response are not lost). + let request = request.try_into()?; + let response = self.client.execute(request).await?; + + handle_response( + self.hook.as_ref(), + &ServerResponse::new(response.status(), response.headers()), + ); + + let mut response = response.error_for_status()?; + let headers = std::mem::take(response.headers_mut()); + let mut http_response = opentelemetry_http::Response::builder() + .status(response.status()) + .body(response.bytes().await?)?; + *http_response.headers_mut() = headers; + + Ok(http_response) + } +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + + use super::{ServerResponse, ServerResponseHook, handle_response}; + + fn header_map(pairs: &[(&str, &str)]) -> http::HeaderMap { + let mut headers = http::HeaderMap::new(); + for (name, value) in pairs { + headers.insert( + http::HeaderName::try_from(*name).expect("invalid header name"), + http::HeaderValue::try_from(*value).expect("invalid header value"), + ); + } + headers + } + + #[test] + fn warning_header_is_read() { + let headers = header_map(&[("x-logfire-warning", "endpoint is deprecated")]); + let response = ServerResponse::new(http::StatusCode::OK, &headers); + assert_eq!(response.warning(), Some("endpoint is deprecated")); + assert_eq!(response.status(), http::StatusCode::OK); + + let headers = header_map(&[("x-other-header", "not a warning")]); + assert_eq!( + ServerResponse::new(http::StatusCode::OK, &headers).warning(), + None + ); + } + + #[test] + fn hook_replaces_default_behaviour() { + let seen = Arc::new(Mutex::new(Vec::new())); + let hook: ServerResponseHook = { + let seen = seen.clone(); + Arc::new(move |response: &ServerResponse<'_>| { + seen.lock() + .expect("poisoned") + .push(response.warning().map(ToOwned::to_owned)); + }) + }; + + let headers = header_map(&[("x-logfire-warning", "hook_replaces_default_behaviour")]); + handle_response( + Some(&hook), + &ServerResponse::new(http::StatusCode::OK, &headers), + ); + + assert_eq!( + *seen.lock().expect("poisoned"), + vec![Some("hook_replaces_default_behaviour".to_string())] + ); + } +} diff --git a/logfire/tests/test_http_sink.rs b/logfire/tests/test_http_sink.rs index 03e6726..9de08b1 100644 --- a/logfire/tests/test_http_sink.rs +++ b/logfire/tests/test_http_sink.rs @@ -717,6 +717,72 @@ async fn test_http_metrics_export() { } } +/// The server response hook is called with the warning sent by the Logfire backend, including +/// when it accompanies an error response. +/// +/// Runs for both HTTP protocols, as they are configured by separate code paths. +#[tokio::test] +async fn test_server_response_hook() { + for (protocol, status) in [("http/protobuf", 200), ("http/json", 500)] { + let mock_server = MockServer::start().await; + mock_server + .register( + Mock::given(method("POST")).respond_with( + ResponseTemplate::new(status) + .insert_header("x-logfire-warning", "endpoint is deprecated"), + ), + ) + .await; + + let responses = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + + let env_guard = ENV_MUTEX.lock().unwrap(); + // SAFETY: Holding mutex to prevent other thread interacting with env + unsafe { + std::env::set_var("OTEL_EXPORTER_OTLP_PROTOCOL", protocol); + } + + let logfire = configure() + .local() + .send_to_logfire(true) + .with_token("test-token") + .with_advanced_options( + AdvancedOptions::default() + .with_base_url(mock_server.uri()) + .with_server_response_hook({ + let responses = responses.clone(); + move |response| { + responses.lock().unwrap().push(( + response.status().as_u16(), + response.warning().map(ToOwned::to_owned), + )); + } + }), + ) + .finish() + .unwrap(); + + // SAFETY: As above + unsafe { + std::env::remove_var("OTEL_EXPORTER_OTLP_PROTOCOL"); + } + drop(env_guard); + + let guard = set_local_logfire(logfire); + span!("test_span").in_scope(|| {}); + let shutdown = guard.shutdown(); + if status == 200 { + shutdown.expect("shutdown failed"); + } + + assert_eq!( + *responses.lock().unwrap(), + vec![(status, Some("endpoint is deprecated".to_string()))], + "unexpected responses for {protocol}" + ); + } +} + /// An unsupported protocol in the environment is an error. #[test] fn test_unsupported_protocol() {