use std::{collections::HashMap, fmt, time::Duration}; use axum::http::{HeaderName, HeaderValue}; use opentelemetry::{ KeyValue, Value, trace::{Status, TracerProvider as _}, }; use opentelemetry_otlp::{Protocol, SpanExporter, WithExportConfig, WithHttpConfig}; use opentelemetry_sdk::{ Resource, error::OTelSdkResult, trace::{ BatchConfigBuilder, BatchSpanProcessor, SdkTracer, SdkTracerProvider, SpanData, SpanExporter as SpanExporterTrait, }, }; use percent_encoding::percent_decode_str; use thiserror::Error; use url::Url; use crate::ServiceIdentity; const DEFAULT_EXPORT_TIMEOUT: Duration = Duration::from_secs(10); const DEFAULT_MAX_QUEUE_SIZE: usize = 2_048; const DEFAULT_MAX_EXPORT_BATCH_SIZE: usize = 512; const DEFAULT_SCHEDULE_DELAY: Duration = Duration::from_secs(5); const DEFAULT_BATCH_EXPORT_TIMEOUT: Duration = Duration::from_secs(30); const MAX_QUEUE_SIZE: usize = 65_536; const MAX_DURATION: Duration = Duration::from_secs(300); #[derive(Clone, Debug, Eq, PartialEq)] pub struct OtlpBatchConfig { max_queue_size: usize, max_export_batch_size: usize, scheduled_delay: Duration, export_timeout: Duration, } impl OtlpBatchConfig { pub fn try_new( max_queue_size: usize, max_export_batch_size: usize, scheduled_delay: Duration, export_timeout: Duration, ) -> Result { let valid = max_queue_size > 0 && max_queue_size <= MAX_QUEUE_SIZE && max_export_batch_size > 0 && max_export_batch_size <= max_queue_size && duration_is_bounded(scheduled_delay) && duration_is_bounded(export_timeout); if !valid { return Err(OtlpTraceConfigError::InvalidBatchLimits); } Ok(Self { max_queue_size, max_export_batch_size, scheduled_delay, export_timeout, }) } pub fn max_queue_size(&self) -> usize { self.max_queue_size } pub fn max_export_batch_size(&self) -> usize { self.max_export_batch_size } pub fn scheduled_delay(&self) -> Duration { self.scheduled_delay } pub fn export_timeout(&self) -> Duration { self.export_timeout } fn sdk_config(&self) -> opentelemetry_sdk::trace::BatchConfig { BatchConfigBuilder::default() .with_max_queue_size(self.max_queue_size) .with_max_export_batch_size(self.max_export_batch_size) .with_scheduled_delay(self.scheduled_delay) .build() } } impl Default for OtlpBatchConfig { fn default() -> Self { Self { max_queue_size: DEFAULT_MAX_QUEUE_SIZE, max_export_batch_size: DEFAULT_MAX_EXPORT_BATCH_SIZE, scheduled_delay: DEFAULT_SCHEDULE_DELAY, export_timeout: DEFAULT_BATCH_EXPORT_TIMEOUT, } } } #[derive(Clone, Eq, PartialEq)] pub struct OtlpTraceConfig { endpoint: Option, export_timeout: Duration, batch: OtlpBatchConfig, headers: HashMap, } impl fmt::Debug for OtlpTraceConfig { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("OtlpTraceConfig") .field("enabled", &self.is_enabled()) .field("export_timeout", &self.export_timeout) .field("batch", &self.batch) .field("header_count", &self.headers.len()) .finish() } } impl OtlpTraceConfig { #[allow(clippy::too_many_arguments)] pub fn from_values( generic_endpoint: Option, traces_endpoint: Option, generic_protocol: Option, traces_protocol: Option, generic_timeout: Option, traces_timeout: Option, generic_headers: Option, traces_headers: Option, max_queue_size: usize, max_export_batch_size: usize, scheduled_delay: String, batch_export_timeout: String, ) -> Result { OtlpEnvSettings { traces_endpoint, generic_endpoint, traces_protocol, generic_protocol, traces_timeout, generic_timeout, traces_headers, generic_headers, max_queue_size: Some(max_queue_size.to_string()), max_export_batch_size: Some(max_export_batch_size.to_string()), scheduled_delay: Some(scheduled_delay), batch_export_timeout: Some(batch_export_timeout), } .into_config() } fn from_settings(settings: OtlpEnvSettings) -> Result { let endpoint = match settings.traces_endpoint { Some(endpoint) => Some(validate_endpoint(endpoint, EndpointKind::Trace)?), None => settings .generic_endpoint .map(|endpoint| validate_endpoint(endpoint, EndpointKind::Generic)) .transpose()?, }; if endpoint.is_none() { return Ok(Self { endpoint: None, export_timeout: DEFAULT_EXPORT_TIMEOUT, batch: OtlpBatchConfig::default(), headers: HashMap::new(), }); } let protocol = settings.traces_protocol.or(settings.generic_protocol); let export_timeout = match settings.traces_timeout { Some(timeout) => duration_env("OTEL_EXPORTER_OTLP_TRACES_TIMEOUT", Some(timeout))?, None => duration_env("OTEL_EXPORTER_OTLP_TIMEOUT", settings.generic_timeout)?, } .unwrap_or(DEFAULT_EXPORT_TIMEOUT); let batch = OtlpBatchConfig::try_new( usize_env("OTEL_BSP_MAX_QUEUE_SIZE", settings.max_queue_size)? .unwrap_or(DEFAULT_MAX_QUEUE_SIZE), usize_env( "OTEL_BSP_MAX_EXPORT_BATCH_SIZE", settings.max_export_batch_size, )? .unwrap_or(DEFAULT_MAX_EXPORT_BATCH_SIZE), duration_env("OTEL_BSP_SCHEDULE_DELAY", settings.scheduled_delay)? .unwrap_or(DEFAULT_SCHEDULE_DELAY), duration_env("OTEL_BSP_EXPORT_TIMEOUT", settings.batch_export_timeout)? .unwrap_or(DEFAULT_BATCH_EXPORT_TIMEOUT), )?; let headers = settings .traces_headers .filter(|value| !value.is_empty()) .or(settings.generic_headers.filter(|value| !value.is_empty())) .map(|value| parse_headers(&value)) .transpose()? .unwrap_or_default(); Self::try_new_with_headers(endpoint, protocol, export_timeout, batch, headers) } pub fn try_new( endpoint: Option, protocol: Option, export_timeout: Duration, batch: OtlpBatchConfig, ) -> Result { Self::try_new_with_headers(endpoint, protocol, export_timeout, batch, HashMap::new()) } fn try_new_with_headers( endpoint: Option, protocol: Option, export_timeout: Duration, batch: OtlpBatchConfig, headers: HashMap, ) -> Result { let endpoint = endpoint .map(|endpoint| validate_endpoint(endpoint, EndpointKind::Trace)) .transpose()?; if endpoint.is_some() && protocol.as_deref().unwrap_or("http/protobuf") != "http/protobuf" { return Err(OtlpTraceConfigError::UnsupportedProtocol); } if !duration_is_bounded(export_timeout) { return Err(OtlpTraceConfigError::InvalidDuration { field: "OTEL_EXPORTER_OTLP_TIMEOUT", }); } Ok(Self { endpoint, export_timeout, batch, headers, }) } pub fn is_enabled(&self) -> bool { self.endpoint.is_some() } pub fn export_timeout(&self) -> Duration { self.export_timeout } pub fn batch(&self) -> &OtlpBatchConfig { &self.batch } fn effective_export_timeout(&self) -> Duration { self.export_timeout.min(self.batch.export_timeout) } #[cfg(test)] fn endpoint(&self) -> Option<&str> { self.endpoint.as_deref() } #[cfg(test)] fn header(&self, name: &str) -> Option<&str> { self.headers.get(name).map(String::as_str) } } impl Default for OtlpTraceConfig { fn default() -> Self { Self { endpoint: None, export_timeout: DEFAULT_EXPORT_TIMEOUT, batch: OtlpBatchConfig::default(), headers: HashMap::new(), } } } #[derive(Default)] struct OtlpEnvSettings { traces_endpoint: Option, generic_endpoint: Option, traces_protocol: Option, generic_protocol: Option, traces_timeout: Option, generic_timeout: Option, traces_headers: Option, generic_headers: Option, max_queue_size: Option, max_export_batch_size: Option, scheduled_delay: Option, batch_export_timeout: Option, } impl OtlpEnvSettings { fn into_config(self) -> Result { OtlpTraceConfig::from_settings(self) } } #[derive(Debug, Error, Eq, PartialEq)] pub enum OtlpTraceConfigError { #[error("OTLP environment variable is not valid UTF-8: {field}")] InvalidEnvironmentEncoding { field: &'static str }, #[error("OTLP trace endpoint is invalid: {reason}")] InvalidEndpoint { reason: &'static str }, #[error("OTLP trace protocol must be http/protobuf")] UnsupportedProtocol, #[error("OTLP numeric setting is invalid: {field}")] InvalidNumber { field: &'static str }, #[error("OTLP duration setting is invalid: {field}")] InvalidDuration { field: &'static str }, #[error("OTLP batch limits are invalid")] InvalidBatchLimits, #[error("OTLP trace headers are invalid")] InvalidHeaders, } #[derive(Debug, Error)] pub enum OtlpTraceError { #[error("failed to configure OTLP trace exporter")] ExporterConfiguration, } pub fn build_tracer_provider( identity: &ServiceIdentity, config: &OtlpTraceConfig, ) -> Result, OtlpTraceError> { let Some(endpoint) = config.endpoint.as_deref() else { return Ok(None); }; let exporter = SpanExporter::builder() .with_http() .with_protocol(Protocol::HttpBinary) .with_endpoint(endpoint) .with_timeout(config.effective_export_timeout()) .with_headers(config.headers.clone()) .build() .map_err(|_| OtlpTraceError::ExporterConfiguration)?; let processor = BatchSpanProcessor::builder(ObservedSpanExporter(exporter)) .with_batch_config(config.batch.sdk_config()) .build(); let resource = trace_resource(identity); let provider = SdkTracerProvider::builder() .with_span_processor(processor) .with_resource(resource) .build(); let tracer = provider.tracer("crank"); Ok(Some((provider, tracer))) } pub(crate) fn build_local_tracer_provider( identity: &ServiceIdentity, ) -> (SdkTracerProvider, SdkTracer) { let provider = SdkTracerProvider::builder() .with_resource(trace_resource(identity)) .build(); let tracer = provider.tracer("crank"); (provider, tracer) } fn trace_resource(identity: &ServiceIdentity) -> Resource { Resource::builder_empty() .with_attributes([ KeyValue::new("service.name", identity.service().to_owned()), KeyValue::new("service.version", identity.version().to_owned()), KeyValue::new( "deployment.environment.name", identity.environment().to_owned(), ), ]) .build() } #[derive(Debug)] struct ObservedSpanExporter(SpanExporter); impl SpanExporterTrait for ObservedSpanExporter { async fn export(&self, mut batch: Vec) -> OTelSdkResult { sanitize_trace_batch(&mut batch); let result = self.0.export(batch).await; if result.is_err() { crank_metrics::record_export_failure( crank_metrics::SignalType::Trace, crank_metrics::Exporter::Otlp, ); } result } fn shutdown_with_timeout(&self, timeout: Duration) -> OTelSdkResult { self.0.shutdown_with_timeout(timeout) } fn force_flush(&self) -> OTelSdkResult { self.0.force_flush() } fn set_resource(&mut self, resource: &Resource) { self.0.set_resource(resource); } } fn sanitize_trace_batch(batch: &mut Vec) { batch.retain(|span| is_allowed_span_name(span.name.as_ref())); for span in batch { let original_attribute_count = span.attributes.len(); span.attributes.retain(is_allowed_span_attribute); span.dropped_attributes_count = span .dropped_attributes_count .saturating_add((original_attribute_count - span.attributes.len()) as u32); span.events = Default::default(); span.links = Default::default(); if matches!(span.status, Status::Error { .. }) { span.status = Status::error(""); } } } fn is_allowed_span_name(name: &str) -> bool { matches!( name, "http.request" | "mcp.request" | "mcp.rate_limit" | "mcp.access.check" | "mcp.catalog.load" | "mcp.tools.resolve" | "approval.check" | "runtime.execute" | "runtime.arguments.map" | "runtime.idempotency" | "upstream.http" | "runtime.response.transform" | "auth.resolve" | "approval.recovery" | "history.write" | "db.query" ) } fn is_allowed_span_attribute(attribute: &KeyValue) -> bool { let Value::String(value) = &attribute.value else { return false; }; let value = value.as_str(); match attribute.key.as_str() { "request_id" => is_valid_request_id(value), "stage" => is_allowed_span_name(value), "outcome" => matches!( value, "success" | "error" | "allowed" | "denied" | "required" | "replay" | "execute" | "skipped" | "cache_hit" ), "error.category" => matches!( value, "access" | "rate_limit" | "catalog" | "approval" | "idempotency" | "schema" | "mapping" | "upstream" | "transformation" | "history" | "database" | "concurrency" | "configuration" | "internal" ), "db.system" => value == "postgresql", "db.operation" => matches!( value, "machine_access.read" | "machine_access.touch" | "catalog.load" | "approval.read" | "approval.write" | "auth_profile.read" | "secret.read" | "secret.touch" | "invocation_history.write" ), _ => false, } } fn is_valid_request_id(value: &str) -> bool { !value.is_empty() && value.len() <= 128 && value .bytes() .all(|byte| byte.is_ascii_graphic() && byte != b',' && byte != b';') } #[derive(Clone, Copy)] enum EndpointKind { Trace, Generic, } fn validate_endpoint(endpoint: String, kind: EndpointKind) -> Result { let mut url = Url::parse(&endpoint).map_err(|_| OtlpTraceConfigError::InvalidEndpoint { reason: "invalid URL", })?; if !matches!(url.scheme(), "http" | "https") { return Err(OtlpTraceConfigError::InvalidEndpoint { reason: "unsupported scheme", }); } if url.host_str().is_none() { return Err(OtlpTraceConfigError::InvalidEndpoint { reason: "host is required", }); } if !url.username().is_empty() || url.password().is_some() { return Err(OtlpTraceConfigError::InvalidEndpoint { reason: "credentials are forbidden", }); } if url.query().is_some() || url.fragment().is_some() { return Err(OtlpTraceConfigError::InvalidEndpoint { reason: "query and fragment are forbidden", }); } if matches!(kind, EndpointKind::Generic) { let path = url.path().trim_end_matches('/'); url.set_path(&format!("{path}/v1/traces")); } Ok(url.into()) } fn parse_headers(value: &str) -> Result, OtlpTraceConfigError> { value .split_terminator(',') .map(str::trim) .filter(|item| !item.is_empty()) .try_fold(HashMap::new(), |mut headers, item| { let (name, encoded_value) = item .split_once('=') .ok_or(OtlpTraceConfigError::InvalidHeaders)?; let name = HeaderName::from_bytes(name.trim().as_bytes()) .map_err(|_| OtlpTraceConfigError::InvalidHeaders)?; let value = percent_decode_str(encoded_value.trim()) .decode_utf8() .map_err(|_| OtlpTraceConfigError::InvalidHeaders)? .into_owned(); if value.is_empty() || HeaderValue::from_str(&value).is_err() { return Err(OtlpTraceConfigError::InvalidHeaders); } headers.insert(name.as_str().to_owned(), value); Ok(headers) }) } fn usize_env( field: &'static str, value: Option, ) -> Result, OtlpTraceConfigError> { value .map(|value| { value .parse() .map_err(|_| OtlpTraceConfigError::InvalidNumber { field }) }) .transpose() } fn duration_env( field: &'static str, value: Option, ) -> Result, OtlpTraceConfigError> { value .map(|value| { value .parse::() .map(Duration::from_millis) .map_err(|_| OtlpTraceConfigError::InvalidDuration { field }) }) .transpose() } fn duration_is_bounded(duration: Duration) -> bool { !duration.is_zero() && duration <= MAX_DURATION } #[cfg(test)] mod tests { use std::{ io::{Read, Write}, net::TcpListener, sync::mpsc, thread, time::{Duration, Instant}, }; use axum::{ Router, body::{Body, to_bytes}, extract::Request, middleware::Next, response::Response, routing::get, }; use opentelemetry::{ KeyValue, trace::{Span as _, Status, Tracer as _}, }; use opentelemetry_proto::tonic::{ collector::trace::v1::ExportTraceServiceRequest, common::v1::any_value, }; use prost::Message; use tower::ServiceExt; use tracing::{Instrument, info_span}; use tracing_subscriber::layer::SubscriberExt; use super::{ OtlpBatchConfig, OtlpEnvSettings, OtlpTraceConfig, build_local_tracer_provider, build_tracer_provider, }; use crate::ServiceIdentity; #[test] fn signal_specific_settings_override_generic_settings() { let config = OtlpEnvSettings { traces_endpoint: Some("https://traces.example.test/custom".to_owned()), generic_endpoint: Some("https://generic.example.test/otel".to_owned()), traces_protocol: Some("http/protobuf".to_owned()), generic_protocol: Some("grpc".to_owned()), // community-scope: allow=grpc traces_timeout: Some("2500".to_owned()), generic_timeout: Some("invalid-unused-fallback".to_owned()), ..OtlpEnvSettings::default() } .into_config() .unwrap(); assert_eq!( config.endpoint(), Some("https://traces.example.test/custom") ); assert_eq!(config.export_timeout(), Duration::from_millis(2500)); } #[test] fn disabled_export_ignores_inactive_settings() { let config = OtlpEnvSettings { traces_protocol: Some("grpc".to_owned()), // community-scope: allow=grpc generic_protocol: Some("grpc".to_owned()), // community-scope: allow=grpc traces_timeout: Some("invalid".to_owned()), generic_timeout: Some("invalid".to_owned()), max_queue_size: Some("invalid".to_owned()), max_export_batch_size: Some("invalid".to_owned()), scheduled_delay: Some("invalid".to_owned()), batch_export_timeout: Some("invalid".to_owned()), ..OtlpEnvSettings::default() } .into_config() .unwrap(); assert!(!config.is_enabled()); } #[test] fn empty_signal_headers_use_generic_headers() { let config = OtlpEnvSettings { traces_endpoint: Some("https://traces.example.test/v1/traces".to_owned()), traces_headers: Some(String::new()), generic_headers: Some( "authorization=Bearer%20canary-token,x-scope=community".to_owned(), ), ..OtlpEnvSettings::default() } .into_config() .unwrap(); assert_eq!(config.header("authorization"), Some("Bearer canary-token")); assert_eq!(config.header("x-scope"), Some("community")); assert!(!format!("{config:?}").contains("canary-token")); } #[test] fn invalid_headers_return_a_safe_error() { let config = OtlpEnvSettings { traces_endpoint: Some("https://traces.example.test/v1/traces".to_owned()), traces_headers: Some("authorization=canary-secret%0Ainjected".to_owned()), ..OtlpEnvSettings::default() }; let error = config.into_config().unwrap_err(); assert!(matches!(error, super::OtlpTraceConfigError::InvalidHeaders)); assert!(!error.to_string().contains("canary-secret")); } #[test] fn stricter_batch_timeout_bounds_http_export() { let config = OtlpEnvSettings { traces_endpoint: Some("https://traces.example.test/v1/traces".to_owned()), traces_protocol: Some("http/protobuf".to_owned()), traces_timeout: Some("9000".to_owned()), batch_export_timeout: Some("2500".to_owned()), ..OtlpEnvSettings::default() } .into_config() .unwrap(); assert_eq!( config.effective_export_timeout(), Duration::from_millis(2500) ); } #[test] fn generic_endpoint_receives_standard_trace_path() { let config = OtlpEnvSettings { generic_endpoint: Some("https://generic.example.test/otel/".to_owned()), generic_protocol: Some("http/protobuf".to_owned()), ..OtlpEnvSettings::default() } .into_config() .unwrap(); assert_eq!( config.endpoint(), Some("https://generic.example.test/otel/v1/traces") ); } #[test] fn disabled_export_does_not_build_a_provider() { let config = OtlpTraceConfig::try_new( None, None, Duration::from_secs(1), OtlpBatchConfig::default(), ) .unwrap(); let identity = ServiceIdentity::try_new("admin-api", "0.3.1", "test").unwrap(); assert!(build_tracer_provider(&identity, &config).unwrap().is_none()); } #[test] fn local_provider_creates_valid_context_without_an_exporter() { let identity = ServiceIdentity::try_new("admin-api", "0.3.1", "test").unwrap(); let (provider, tracer) = build_local_tracer_provider(&identity); let span = tracer.start("http.request"); assert!(span.span_context().is_valid()); provider.shutdown().unwrap(); } #[test] fn real_http_protobuf_export_contains_resource_and_trace() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let address = listener.local_addr().unwrap(); let (request_tx, request_rx) = mpsc::sync_channel(1); let server = thread::spawn(move || { let (mut stream, _) = listener.accept().unwrap(); let request = read_http_request(&mut stream); stream .write_all( b"HTTP/1.1 200 OK\r\ncontent-type: application/x-protobuf\r\ncontent-length: 0\r\nconnection: close\r\n\r\n", ) .unwrap(); request_tx.send(request).unwrap(); }); let config = OtlpEnvSettings { traces_endpoint: Some(format!("http://{address}/v1/traces")), traces_protocol: Some("http/protobuf".to_owned()), traces_timeout: Some("2000".to_owned()), traces_headers: Some(String::new()), generic_headers: Some("authorization=Bearer%20canary-token".to_owned()), max_queue_size: Some("16".to_owned()), max_export_batch_size: Some("8".to_owned()), scheduled_delay: Some("10".to_owned()), batch_export_timeout: Some("2000".to_owned()), ..OtlpEnvSettings::default() } .into_config() .unwrap(); let identity = ServiceIdentity::try_new("admin-api", "0.3.1", "integration-test").unwrap(); let (provider, tracer) = build_tracer_provider(&identity, &config).unwrap().unwrap(); let mut span = tracer.start("http.request"); let trace_id = span.span_context().trace_id().to_bytes(); span.set_attribute(KeyValue::new("request_id", "req_otlp_contract")); span.set_attribute(KeyValue::new("authorization", "Bearer canary-span-secret")); span.add_event( "canary-span-event", vec![KeyValue::new("payload", "canary-span-secret")], ); span.set_status(Status::error("canary-span-secret")); span.end(); provider.force_flush().unwrap(); provider.shutdown().unwrap(); let request = request_rx.recv_timeout(Duration::from_secs(2)).unwrap(); server.join().unwrap(); let (headers, body) = split_http_request(&request); assert!(headers.contains("POST /v1/traces HTTP/1.1")); assert!( headers .to_ascii_lowercase() .contains("content-type: application/x-protobuf") ); assert!( headers .to_ascii_lowercase() .contains("authorization: bearer canary-token") ); let export = ExportTraceServiceRequest::decode(body).unwrap(); let resource_spans = export.resource_spans.first().unwrap(); let attributes = &resource_spans.resource.as_ref().unwrap().attributes; assert_eq!( string_attribute(attributes, "service.name"), Some("admin-api") ); assert_eq!( string_attribute(attributes, "service.version"), Some("0.3.1") ); assert_eq!( string_attribute(attributes, "deployment.environment.name"), Some("integration-test") ); assert_eq!( resource_spans.scope_spans[0].spans[0].trace_id.as_slice(), trace_id ); let exported_span = &resource_spans.scope_spans[0].spans[0]; assert_eq!(exported_span.name, "http.request"); assert_eq!( string_attribute(&exported_span.attributes, "request_id"), Some("req_otlp_contract") ); assert!( exported_span .attributes .iter() .all(|attribute| attribute.key != "authorization") ); assert!(exported_span.events.is_empty()); assert_eq!( exported_span .status .as_ref() .map(|status| status.message.as_str()), Some("") ); assert!( !body .windows(b"canary-span-secret".len()) .any(|window| { window == b"canary-span-secret" }) ); } #[tokio::test(flavor = "current_thread")] async fn unavailable_receiver_does_not_change_product_result() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let address = listener.local_addr().unwrap(); let server = thread::spawn(move || { let (stream, _) = listener.accept().unwrap(); drop(stream); }); let config = OtlpTraceConfig::try_new( Some(format!("http://{address}/v1/traces")), Some("http/protobuf".to_owned()), Duration::from_millis(250), OtlpBatchConfig::try_new(8, 4, Duration::from_millis(10), Duration::from_millis(250)) .unwrap(), ) .unwrap(); let identity = ServiceIdentity::try_new("mcp-server", "0.3.1", "fault-test").unwrap(); let (provider, tracer) = build_tracer_provider(&identity, &config).unwrap().unwrap(); let subscriber = tracing_subscriber::registry().with(tracing_opentelemetry::layer().with_tracer(tracer)); let dispatch = tracing::Dispatch::new(subscriber); let _dispatch_guard = tracing::dispatcher::set_default(&dispatch); let app = Router::new() .route("/product", get(|| async { "product-success" })) .layer(axum::middleware::from_fn(trace_product_request)); let response = app .oneshot( Request::builder() .uri("/product") .body(Body::empty()) .unwrap(), ) .await .unwrap(); let status = response.status(); let body = to_bytes(response.into_body(), 64).await.unwrap(); assert_eq!(status, axum::http::StatusCode::OK); assert_eq!(body.as_ref(), b"product-success"); assert!(provider.force_flush().is_err()); let _ = provider.shutdown(); server.join().unwrap(); } async fn trace_product_request(request: Request, next: Next) -> Response { next.run(request) .instrument(info_span!(target: "crank::trace", "http.request")) .await } #[test] fn hanging_receiver_respects_the_stricter_export_timeout() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let address = listener.local_addr().unwrap(); let server = thread::spawn(move || { let (stream, _) = listener.accept().unwrap(); thread::sleep(Duration::from_millis(750)); drop(stream); }); let config = OtlpTraceConfig::try_new( Some(format!("http://{address}/v1/traces")), Some("http/protobuf".to_owned()), Duration::from_secs(2), OtlpBatchConfig::try_new(8, 4, Duration::from_millis(10), Duration::from_millis(100)) .unwrap(), ) .unwrap(); let identity = ServiceIdentity::try_new("admin-api", "0.3.1", "timeout-test").unwrap(); let (provider, tracer) = build_tracer_provider(&identity, &config).unwrap().unwrap(); let mut span = tracer.start("http.request"); span.end(); let started_at = Instant::now(); assert!(provider.force_flush().is_err()); assert!(started_at.elapsed() < Duration::from_millis(500)); let _ = provider.shutdown(); server.join().unwrap(); } fn read_http_request(stream: &mut std::net::TcpStream) -> Vec { stream .set_read_timeout(Some(Duration::from_secs(2))) .unwrap(); let mut request = Vec::new(); let mut buffer = [0_u8; 4096]; loop { let read = stream.read(&mut buffer).unwrap(); request.extend_from_slice(&buffer[..read]); let Some(header_end) = find_bytes(&request, b"\r\n\r\n") else { continue; }; let headers = String::from_utf8_lossy(&request[..header_end]); let content_length = headers .lines() .find_map(|line| { let (name, value) = line.split_once(':')?; name.eq_ignore_ascii_case("content-length") .then(|| value.trim().parse::().ok()) .flatten() }) .unwrap_or(0); if request.len() >= header_end + 4 + content_length { return request; } } } fn split_http_request(request: &[u8]) -> (&str, &[u8]) { let header_end = find_bytes(request, b"\r\n\r\n").unwrap(); ( std::str::from_utf8(&request[..header_end]).unwrap(), &request[header_end + 4..], ) } fn string_attribute<'a>( attributes: &'a [opentelemetry_proto::tonic::common::v1::KeyValue], key: &str, ) -> Option<&'a str> { attributes.iter().find_map(|attribute| { let value = attribute.value.as_ref()?.value.as_ref()?; (attribute.key == key) .then_some(value) .and_then(|value| match value { any_value::Value::StringValue(value) => Some(value.as_str()), _ => None, }) }) } fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option { haystack .windows(needle.len()) .position(|candidate| candidate == needle) } }