//! OpenTelemetry initialization and shutdown. //! //! When `OTEL_EXPORTER_OTLP_ENDPOINT` is set, sets up a full tracing pipeline //! with OTLP export. Otherwise, sets up tracing with logfmt output only. //! //! In both cases, the same logfmt log format is used, with carefully chosen //! fields for human readability and machine parseability. use std::env; use std::sync::{Once, OnceLock}; use anyhow::Context as _; use opentelemetry_sdk::metrics::SdkMeterProvider; use opentelemetry_sdk::trace::SdkTracerProvider; static TRACER_PROVIDER: OnceLock = OnceLock::new(); static METER_PROVIDER: OnceLock = OnceLock::new(); static TEST_LOGGING_INIT: Once = Once::new(); static OTLP_HTTP_WORKER_SENDER: OnceLock< Result, String>, > = OnceLock::new(); const DEFAULT_ENV_FILTER_DIRECTIVES: &str = "sqlpage=info,actix_web=info,tracing_actix_web=info,opentelemetry=warn,opentelemetry_sdk=warn,opentelemetry_otlp=warn"; pub(crate) const ACCESS_LOG_TARGET: &str = "sqlpage::access"; type OtlpHttpResponse = Result, String>; struct OtlpHttpJob { request: opentelemetry_http::Request, response_sender: tokio::sync::oneshot::Sender, } #[derive(Clone)] struct AwcOtlpHttpClient { sender: tokio::sync::mpsc::UnboundedSender, } impl AwcOtlpHttpClient { fn new() -> anyhow::Result { Ok(Self { sender: otlp_http_worker_sender()?, }) } } impl std::fmt::Debug for AwcOtlpHttpClient { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("AwcOtlpHttpClient").finish_non_exhaustive() } } fn otlp_http_worker_sender() -> anyhow::Result> { let sender_result = OTLP_HTTP_WORKER_SENDER .get_or_init(|| init_otlp_http_worker_sender().map_err(|error| error.to_string())); match sender_result { Ok(sender) => Ok(sender.clone()), Err(error) => { anyhow::bail!("Failed to initialize OTLP AWC worker thread: {error}"); } } } fn init_otlp_http_worker_sender() -> anyhow::Result> { let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel::(); let (init_result_sender, init_result_receiver) = std::sync::mpsc::sync_channel(1); let use_system_root_ca_certificates = crate::webserver::http_client::default_system_root_ca_certificates_from_env(); std::thread::Builder::new() .name("sqlpage-otlp-http".to_owned()) .spawn(move || { actix_web::rt::System::new().block_on(async move { let awc_client = match crate::webserver::http_client::make_http_client_with_system_roots( use_system_root_ca_certificates, ) { Ok(client) => { let _ = init_result_sender.send(Ok(())); client } Err(error) => { let error = format!("Failed to initialize OTLP AWC client: {error:#}"); let _ = init_result_sender.send(Err(error.clone())); while let Some(job) = receiver.recv().await { let _ = job.response_sender.send(Err(error.clone())); } return; } }; while let Some(job) = receiver.recv().await { let response = execute_otlp_http_request_with_awc(&awc_client, job.request) .await .map_err(|error| error.to_string()); let _ = job.response_sender.send(response); } }); }) .context("Failed to spawn OTLP AWC worker thread")?; match init_result_receiver.recv() { Ok(Ok(())) => {} Ok(Err(error)) => anyhow::bail!("{error}"), Err(error) => anyhow::bail!("OTLP AWC worker initialization channel closed: {error}"), } Ok(sender) } async fn execute_otlp_http_request_with_awc( awc_client: &awc::Client, request: opentelemetry_http::Request, ) -> anyhow::Result> { let (request_parts, request_body) = request.into_parts(); let awc_method = awc::http::Method::from_bytes(request_parts.method.as_str().as_bytes()) .with_context(|| format!("Invalid OTLP HTTP method: {}", request_parts.method))?; let awc_uri: awc::http::Uri = request_parts .uri .to_string() .parse() .with_context(|| format!("Invalid OTLP collector URI: {}", request_parts.uri))?; let mut awc_request = awc_client.request(awc_method, awc_uri.clone()); for (header_name, header_value) in &request_parts.headers { let header_name_str = header_name.as_str(); let awc_header_name = awc::http::header::HeaderName::from_bytes(header_name_str.as_bytes()) .with_context(|| format!("Invalid OTLP header name: {header_name_str}"))?; let awc_header_value = awc::http::header::HeaderValue::from_bytes(header_value.as_bytes()) .with_context(|| format!("Invalid OTLP header value for {header_name_str}"))?; awc_request = awc_request.insert_header((awc_header_name, awc_header_value)); } let mut awc_response = awc_request.send_body(request_body).await.map_err(|error| { anyhow::anyhow!("Failed to send OTLP HTTP request to {awc_uri}: {error}") })?; let mut response_builder = opentelemetry_http::Response::builder().status(awc_response.status().as_u16()); for (header_name, header_value) in awc_response.headers() { let header_value = header_value.to_str().map_err(|error| { anyhow::anyhow!( "Invalid OTLP response header value for {}: {error}", header_name.as_str() ) })?; response_builder = response_builder.header(header_name.as_str(), header_value); } let response_body = awc_response.body().await.map_err(|error| { anyhow::anyhow!("Failed to read OTLP HTTP response body from {awc_uri}: {error}") })?; response_builder .body(response_body) .context("Failed to build OTLP HTTP response") } #[async_trait::async_trait] impl opentelemetry_http::HttpClient for AwcOtlpHttpClient { async fn send_bytes( &self, request: opentelemetry_http::Request, ) -> Result< opentelemetry_http::Response, opentelemetry_http::HttpError, > { let (response_sender, response_receiver) = tokio::sync::oneshot::channel(); self.sender .send(OtlpHttpJob { request, response_sender, }) .map_err(|_| anyhow::anyhow!("OTLP AWC worker thread is unavailable"))?; response_receiver .await .map_err(|_| anyhow::anyhow!("OTLP AWC worker dropped response channel"))? .map_err(Into::into) } } /// Initializes logging / tracing. Returns whether `OTel` was activated. pub fn init_telemetry() -> anyhow::Result { init_telemetry_with_log_layer(logfmt::LogfmtLayer::new()) } fn init_telemetry_with_log_layer(logfmt_layer: logfmt::LogfmtLayer) -> anyhow::Result { let otel_endpoint = env::var("OTEL_EXPORTER_OTLP_ENDPOINT").ok(); let otel_active = otel_endpoint.as_deref().is_some_and(|v| !v.is_empty()); if otel_active { init_otel_tracing(logfmt_layer)?; } else { init_tracing(logfmt_layer)?; } Ok(otel_active) } /// Initializes logging once for tests using the same formatter as production. /// /// Unlike `init_telemetry`, this does not initialize OTEL exporters and does /// not panic on invalid `LOG_LEVEL` / `RUST_LOG` values. pub fn init_test_logging() { TEST_LOGGING_INIT.call_once(|| { init_test_tracing(); }); } /// Shuts down the `OTel` tracer provider, flushing pending spans. pub fn shutdown_telemetry() { if let Some(provider) = TRACER_PROVIDER.get() && let Err(e) = provider.shutdown() { eprintln!("Error shutting down tracer provider: {e}"); } if let Some(provider) = METER_PROVIDER.get() && let Err(e) = provider.shutdown() { eprintln!("Error shutting down meter provider: {e}"); } } /// Tracing subscriber without `OTel` export — logfmt output only. fn init_tracing(logfmt_layer: logfmt::LogfmtLayer) -> anyhow::Result<()> { use tracing_subscriber::layer::SubscriberExt; let subscriber = tracing_subscriber::registry() .with(default_env_filter()) .with(logfmt_layer); set_global_subscriber(subscriber) } fn init_test_tracing() { use tracing_subscriber::layer::SubscriberExt; let subscriber = tracing_subscriber::registry() .with(test_env_filter()) .with(logfmt::LogfmtLayer::test_writer()); set_global_subscriber(subscriber).expect("Failed to initialize test tracing subscriber"); } fn init_otel_tracing(logfmt_layer: logfmt::LogfmtLayer) -> anyhow::Result<()> { use opentelemetry::global; use opentelemetry::trace::TracerProvider as _; use opentelemetry_otlp::WithExportConfig as _; use opentelemetry_otlp::WithHttpConfig as _; use opentelemetry_sdk::propagation::TraceContextPropagator; use tracing_subscriber::layer::SubscriberExt; let http_client = AwcOtlpHttpClient::new().context("Failed to initialize OTLP AWC client")?; // W3C TraceContext propagation (traceparent header) global::set_text_map_propagator(TraceContextPropagator::new()); // OTLP exporter — reads OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_SERVICE_NAME, etc. let exporter = opentelemetry_otlp::SpanExporter::builder() .with_http() .with_http_client(http_client.clone()) .with_protocol(opentelemetry_otlp::Protocol::HttpBinary) .build() .context("Failed to build OTLP span exporter")?; let span_processor = opentelemetry_sdk::trace::span_processor_with_async_runtime::BatchSpanProcessor::builder( exporter, opentelemetry_sdk::runtime::Tokio, ) .build(); let provider = SdkTracerProvider::builder() .with_span_processor(span_processor) .build(); let tracer = provider.tracer("sqlpage"); global::set_tracer_provider(provider.clone()); let _ = TRACER_PROVIDER.set(provider); // OTLP Metric exporter let metric_exporter = opentelemetry_otlp::MetricExporter::builder() .with_http() .with_http_client(http_client) .with_protocol(opentelemetry_otlp::Protocol::HttpBinary) .build() .context("Failed to build OTLP metric exporter")?; let reader = opentelemetry_sdk::metrics::periodic_reader_with_async_runtime::PeriodicReader::builder( metric_exporter, opentelemetry_sdk::runtime::Tokio, ) .build(); let meter_provider = SdkMeterProvider::builder() .with_reader(reader) .with_view(|instrument: &opentelemetry_sdk::metrics::Instrument| { if instrument.kind() == opentelemetry_sdk::metrics::InstrumentKind::Histogram { opentelemetry_sdk::metrics::Stream::builder() .with_aggregation( opentelemetry_sdk::metrics::Aggregation::ExplicitBucketHistogram { boundaries: vec![ 0.001, 0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1.0, 2.5, 5.0, 7.5, 10.0, ], record_min_max: true, }, ) .build() .ok() } else { None } }) .build(); global::set_meter_provider(meter_provider.clone()); let _ = METER_PROVIDER.set(meter_provider.clone()); let otel_layer = tracing_opentelemetry::layer() .with_tracer(tracer) .with_location(false); let subscriber = tracing_subscriber::registry() .with(default_env_filter()) .with(logfmt_layer) .with(otel_layer); set_global_subscriber(subscriber) } fn default_env_filter() -> tracing_subscriber::EnvFilter { env_filter_directives( env::var("LOG_LEVEL").ok().as_deref(), env::var("RUST_LOG").ok().as_deref(), ) .parse() .expect("Invalid log filter value in LOG_LEVEL or RUST_LOG") } fn test_env_filter() -> tracing_subscriber::EnvFilter { env_filter_directives( env::var("LOG_LEVEL").ok().as_deref(), env::var("RUST_LOG").ok().as_deref(), ) .parse() .unwrap_or_else(|_| { DEFAULT_ENV_FILTER_DIRECTIVES .parse() .expect("Default filter directives should always be valid") }) } fn env_filter_directives(log_level: Option<&str>, rust_log: Option<&str>) -> String { match ( log_level.filter(|value| !value.is_empty()), rust_log.filter(|value| !value.is_empty()), ) { (Some(value), _) | (None, Some(value)) => value.to_owned(), (None, None) => DEFAULT_ENV_FILTER_DIRECTIVES.to_owned(), } } fn set_global_subscriber(subscriber: impl tracing::Subscriber + Send + Sync) -> anyhow::Result<()> { tracing::subscriber::set_global_default(subscriber) .context("Failed to set global tracing subscriber")?; tracing_log::LogTracer::init().context("Failed to set log→tracing bridge") } /// Custom logfmt logging layer. /// /// Outputs one line per event in logfmt format with carefully chosen fields: /// ```text /// ts=2026-03-08T20:56:15Z level=error target=sqlpage::webserver::error msg="..." http.request.method=GET url.path=/foo client.address=1.2.3.4 trace_id=abc123 /// ``` /// /// With terminal colors when stderr is a TTY. mod logfmt { use std::collections::BTreeMap; use std::collections::HashMap; use std::fmt::Write; use std::io::{self, IsTerminal}; use tracing::Subscriber; use tracing::field::{Field, Visit}; use tracing_subscriber::Layer; use tracing_subscriber::layer::Context; use tracing_subscriber::registry::LookupSpan; /// Stores span fields so we can access them when formatting events. #[derive(Default)] struct SpanFields(HashMap<&'static str, String>); /// Visitor that collects fields into a `HashMap`. struct FieldCollector<'a>(&'a mut HashMap<&'static str, String>); impl Visit for FieldCollector<'_> { fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { self.0.insert(field.name(), format!("{value:?}")); } fn record_str(&mut self, field: &Field, value: &str) { self.0.insert(field.name(), value.to_owned()); } fn record_i64(&mut self, field: &Field, value: i64) { self.0.insert(field.name(), value.to_string()); } fn record_u64(&mut self, field: &Field, value: u64) { self.0.insert(field.name(), value.to_string()); } fn record_bool(&mut self, field: &Field, value: bool) { self.0.insert(field.name(), value.to_string()); } } use opentelemetry_semantic_conventions::attribute as otel; /// Fields we pick from spans, in display order. /// (`span_field_name`, `logfmt_key`) const SPAN_FIELDS: &[(&str, &str)] = &[ (otel::HTTP_REQUEST_METHOD, otel::HTTP_REQUEST_METHOD), (otel::URL_PATH, otel::URL_PATH), ( otel::HTTP_RESPONSE_STATUS_CODE, otel::HTTP_RESPONSE_STATUS_CODE, ), (otel::CODE_FILE_PATH, otel::CODE_FILE_PATH), (otel::CLIENT_ADDRESS, otel::CLIENT_ADDRESS), ]; /// All-zeros trace ID means no real trace context. const INVALID_TRACE_ID: &str = "00000000000000000000000000000000"; // ANSI color codes const RED: &str = "\x1b[31m"; const YELLOW: &str = "\x1b[33m"; const GREEN: &str = "\x1b[32m"; const BLUE: &str = "\x1b[34m"; const DIM: &str = "\x1b[2m"; const BOLD: &str = "\x1b[1m"; const RESET: &str = "\x1b[0m"; #[derive(Copy, Clone)] enum OutputMode { StdoutAndStderr, TestWriter, } pub(super) struct LogfmtLayer { stdout_colors: bool, stderr_colors: bool, output_mode: OutputMode, } impl LogfmtLayer { pub fn new() -> Self { Self { stdout_colors: io::stdout().is_terminal(), stderr_colors: io::stderr().is_terminal(), output_mode: OutputMode::StdoutAndStderr, } } pub fn test_writer() -> Self { Self { stdout_colors: false, stderr_colors: false, output_mode: OutputMode::TestWriter, } } } impl Layer for LogfmtLayer where S: Subscriber + for<'a> LookupSpan<'a>, { fn on_new_span( &self, attrs: &tracing::span::Attributes<'_>, id: &tracing::span::Id, ctx: Context<'_, S>, ) { let mut fields = SpanFields::default(); attrs.record(&mut FieldCollector(&mut fields.0)); if let Some(span) = ctx.span(id) { span.extensions_mut().insert(fields); } } fn on_record( &self, id: &tracing::span::Id, values: &tracing::span::Record<'_>, ctx: Context<'_, S>, ) { if let Some(span) = ctx.span(id) { let mut ext = span.extensions_mut(); if let Some(fields) = ext.get_mut::() { values.record(&mut FieldCollector(&mut fields.0)); } } } fn on_event(&self, event: &tracing::Event<'_>, ctx: Context<'_, S>) { let mut buf = String::with_capacity(256); let level = *event.metadata().level(); let include_all_span_fields = includes_all_span_fields(); let mut event_fields = HashMap::new(); event.record(&mut FieldCollector(&mut event_fields)); let target = event_target(event, &event_fields); let output_stream = output_stream_for_target(target); let colors = self.use_colors(output_stream); let msg = event_fields.get("message"); let multiline_msg = is_multiline_terminal_message(colors, msg); let include_all_event_fields = include_all_span_fields || msg.is_none_or(String::is_empty); write_timestamp(&mut buf, colors); write_level(&mut buf, level, colors); write_message(&mut buf, msg, multiline_msg); write_dimmed_field(&mut buf, "target", target, colors); write_event_fields(&mut buf, &event_fields, include_all_event_fields); write_span_fields(&mut buf, ctx.event_scope(event), include_all_span_fields); write_trace_id(&mut buf, ctx.event_scope(event), colors); buf.push('\n'); write_multiline_message(&mut buf, msg, multiline_msg); match self.output_mode { OutputMode::StdoutAndStderr => write_to_output(output_stream, &buf), OutputMode::TestWriter => { eprint!("{buf}"); } } } } impl LogfmtLayer { fn use_colors(&self, output_stream: OutputStream) -> bool { match output_stream { OutputStream::Stdout => self.stdout_colors, OutputStream::Stderr => self.stderr_colors, } } } #[derive(Copy, Clone, Debug, Eq, PartialEq)] enum OutputStream { Stdout, Stderr, } fn output_stream_for_target(target: &str) -> OutputStream { if target == super::ACCESS_LOG_TARGET { OutputStream::Stdout } else { OutputStream::Stderr } } fn write_to_output(output_stream: OutputStream, buf: &str) { use std::io::Write as _; match output_stream { OutputStream::Stdout => { let _ = io::stdout().write_all(buf.as_bytes()); } OutputStream::Stderr => { let _ = io::stderr().write_all(buf.as_bytes()); } } } fn event_target<'a>( event: &'a tracing::Event<'_>, event_fields: &'a HashMap<&'static str, String>, ) -> &'a str { event_fields .get("log.target") .map_or_else(|| event.metadata().target(), String::as_str) } fn is_multiline_terminal_message(colors: bool, msg: Option<&String>) -> bool { colors && msg.is_some_and(|message| message.contains('\n')) } fn write_timestamp(buf: &mut String, colors: bool) { let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ"); if colors { let _ = write!(buf, "{DIM}ts={now}{RESET}"); } else { let _ = write!(buf, "ts={now}"); } } fn write_level(buf: &mut String, level: tracing::Level, colors: bool) { if colors { let (color, label) = level_style(level); let _ = write!(buf, " {DIM}level={RESET}{BOLD}{color}{label}{RESET}"); } else { let _ = write!(buf, " level={}", level.as_str().to_ascii_lowercase()); } } fn level_style(level: tracing::Level) -> (&'static str, &'static str) { match level { tracing::Level::ERROR => (RED, "error"), tracing::Level::WARN => (YELLOW, "warn"), tracing::Level::INFO => (GREEN, "info"), tracing::Level::DEBUG => (BLUE, "debug"), tracing::Level::TRACE => (DIM, "trace"), } } fn write_dimmed_field(buf: &mut String, key: &str, value: &str, colors: bool) { if colors { let _ = write!(buf, " {DIM}{key}={value}{RESET}"); } else { let _ = write!(buf, " {key}={value}"); } } fn write_message(buf: &mut String, msg: Option<&String>, multiline_msg: bool) { if !multiline_msg && let Some(msg) = msg.filter(|msg| !msg.is_empty()) { write_logfmt_value(buf, "msg", msg); } } fn write_event_fields( buf: &mut String, event_fields: &HashMap<&'static str, String>, include_all_event_fields: bool, ) { if !include_all_event_fields { return; } let mut extra_fields = BTreeMap::new(); for (&key, value) in event_fields { if key == "message" || key == "log.target" { continue; } extra_fields.insert(key, value); } for (key, value) in extra_fields { write_logfmt_value(buf, key, value); } } fn write_span_fields( buf: &mut String, scope: Option>, include_all_span_fields: bool, ) where S: Subscriber + for<'a> LookupSpan<'a>, { if let Some(scope) = scope { let mut seen_mapped_fields = [false; SPAN_FIELDS.len()]; let mut extra_fields = BTreeMap::new(); for span in scope { let ext = span.extensions(); if let Some(fields) = ext.get::() { for (i, &(span_key, logfmt_key)) in SPAN_FIELDS.iter().enumerate() { if seen_mapped_fields[i] { continue; } if let Some(val) = fields.0.get(span_key) { write_logfmt_value(buf, logfmt_key, val); seen_mapped_fields[i] = true; } } if include_all_span_fields { for (&key, val) in &fields.0 { if SPAN_FIELDS.iter().any(|(span_key, _)| key == *span_key) { continue; } extra_fields.entry(key).or_insert_with(|| val.clone()); } } } } if include_all_span_fields { for (key, val) in extra_fields { write_logfmt_value(buf, key, &val); } } } } fn includes_all_span_fields() -> bool { tracing::level_filters::LevelFilter::current() >= tracing::level_filters::LevelFilter::DEBUG } #[cfg(test)] fn write_span_field_maps<'a>( buf: &mut String, span_fields: impl IntoIterator>, include_all_span_fields: bool, ) { let mut seen_mapped_fields = [false; SPAN_FIELDS.len()]; let mut extra_fields = BTreeMap::new(); for fields in span_fields { for (i, &(span_key, logfmt_key)) in SPAN_FIELDS.iter().enumerate() { if seen_mapped_fields[i] { continue; } if let Some(val) = fields.get(span_key) { write_logfmt_value(buf, logfmt_key, val); seen_mapped_fields[i] = true; } } if include_all_span_fields { for (&key, val) in fields { if SPAN_FIELDS.iter().any(|(span_key, _)| key == *span_key) { continue; } extra_fields.entry(key).or_insert_with(|| val.clone()); } } } if include_all_span_fields { for (key, val) in extra_fields { write_logfmt_value(buf, key, &val); } } } fn write_trace_id( buf: &mut String, scope: Option>, colors: bool, ) where S: Subscriber + for<'a> LookupSpan<'a>, { if let Some(trace_id) = first_valid_trace_id(scope) { write_dimmed_field(buf, "trace_id", &trace_id, colors); } } fn first_valid_trace_id( scope: Option>, ) -> Option where S: Subscriber + for<'a> LookupSpan<'a>, { use opentelemetry::trace::TraceContextExt as _; let span_ids: Vec<_> = scope?.map(|span| span.id()).collect(); tracing::dispatcher::get_default(|dispatch| { for span_id in &span_ids { if let Some(otel_context) = tracing_opentelemetry::get_otel_context(span_id, dispatch) { let trace_id = otel_context.span().span_context().trace_id().to_string(); if trace_id != INVALID_TRACE_ID { return Some(trace_id); } } } None }) } fn write_multiline_message(buf: &mut String, msg: Option<&String>, multiline_msg: bool) { if multiline_msg && let Some(msg) = msg { buf.push_str(msg); buf.push('\n'); } } /// Write a logfmt key=value pair, quoting the value if it contains spaces or special chars. fn write_logfmt_value(buf: &mut String, key: &str, value: &str) { let needs_quoting = value.contains([' ', '"', '=', '\n', '\t']) || value.is_empty(); if needs_quoting { let escaped = value.replace('\n', " ").replace('"', "\\\""); let _ = write!(buf, " {key}=\"{escaped}\""); } else { let _ = write!(buf, " {key}={value}"); } } #[cfg(test)] mod tests { use super::*; use crate::telemetry::env_filter_directives; #[test] fn log_level_takes_precedence_over_rust_log() { assert_eq!( env_filter_directives(Some("sqlpage=debug"), Some("sqlpage=trace")), "sqlpage=debug" ); } #[test] fn rust_log_is_used_as_alias_when_log_level_is_missing() { assert_eq!( env_filter_directives(None, Some("sqlpage=trace")), "sqlpage=trace" ); } #[test] fn empty_values_fall_back_to_default_filter() { assert_eq!( env_filter_directives(Some(""), Some("")), "sqlpage=info,actix_web=info,tracing_actix_web=info,opentelemetry=warn,opentelemetry_sdk=warn,opentelemetry_otlp=warn" ); } #[test] fn debug_logs_include_unmapped_span_fields() { let mut buf = String::new(); let span_fields = HashMap::from([ (otel::HTTP_REQUEST_METHOD, "GET".to_string()), (otel::HTTP_ROUTE, "/users/:id".to_string()), ("otel.kind", "server".to_string()), ]); write_span_field_maps(&mut buf, [&span_fields], true); assert_eq!( buf, " http.request.method=GET http.route=/users/:id otel.kind=server" ); } #[test] fn info_logs_keep_only_mapped_span_fields_when_not_in_debug_mode() { let mut buf = String::new(); let span_fields = HashMap::from([ (otel::HTTP_REQUEST_METHOD, "GET".to_string()), (otel::HTTP_ROUTE, "/users/:id".to_string()), ("otel.kind", "server".to_string()), ]); write_span_field_maps(&mut buf, [&span_fields], false); assert_eq!(buf, " http.request.method=GET"); } #[test] fn event_fields_are_rendered_when_message_is_missing() { let mut buf = String::new(); let event_fields = HashMap::from([ ("message", String::new()), ("log.target", "opentelemetry_sdk".to_string()), ("name", "BatchSpanProcessor.ExportFailed".to_string()), ("reason", "connection error".to_string()), ]); write_event_fields(&mut buf, &event_fields, true); assert_eq!( buf, " name=BatchSpanProcessor.ExportFailed reason=\"connection error\"" ); } #[test] fn access_logs_use_stdout_and_other_logs_use_stderr() { assert_eq!( output_stream_for_target(super::super::ACCESS_LOG_TARGET), OutputStream::Stdout ); assert_eq!( output_stream_for_target("sqlpage::webserver::http"), OutputStream::Stderr ); } } }