d718c5a372
CI / compile_and_lint (push) Failing after 0s
CI / docker_build (linux/amd64, -linux-amd64-duckdb, duckdb) (push) Failing after 0s
CI / docker_build (linux/arm64, -linux-arm64, minimal) (push) Failing after 2s
CI / docker_build (linux/arm64, -linux-arm64-duckdb, duckdb) (push) Failing after 1s
CI / docker_build (linux/amd64, minimal) (push) Failing after 1s
CI / test (, sqlite, sqlite::memory:) (push) Has been skipped
CI / test (mssql, mssql, mssql://root:Password123!@127.0.0.1/sqlpage) (push) Has been skipped
CI / test (mysql, mysql, mysql://root:Password123!@127.0.0.1/sqlpage) (push) Has been skipped
CI / test (oracle, oracle, Driver=Oracle 21 ODBC driver;Dbq=//127.0.0.1:1521/FREEPDB1;Uid=root;Pwd=Password123!) (push) Has been skipped
CI / test (postgres, odbc, Driver=PostgreSQL Unicode;Server=127.0.0.1;Port=5432;Database=sqlpage;UID=root;PWD=Password123!, true) (push) Has been skipped
CI / test (postgres, postgres, postgres://root:Password123!@127.0.0.1/sqlpage) (push) Has been skipped
CI / playwright (push) Has been skipped
CI / docker_build (linux/arm/v7, -linux-arm-v7, minimal) (push) Failing after 0s
CI / hurl_examples (push) Failing after 8s
deploy website / deploy_official_site (push) Failing after 1s
CI / hurl (${{ matrix.example }}) (push) Has been skipped
CI / docker_push (duckdb) (push) Has been cancelled
CI / docker_push (minimal) (push) Has been cancelled
CI / windows_test (push) Has been cancelled
875 lines
31 KiB
Rust
875 lines
31 KiB
Rust
//! 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<SdkTracerProvider> = OnceLock::new();
|
|
static METER_PROVIDER: OnceLock<SdkMeterProvider> = OnceLock::new();
|
|
static TEST_LOGGING_INIT: Once = Once::new();
|
|
static OTLP_HTTP_WORKER_SENDER: OnceLock<
|
|
Result<tokio::sync::mpsc::UnboundedSender<OtlpHttpJob>, 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<opentelemetry_http::Response<opentelemetry_http::Bytes>, String>;
|
|
|
|
struct OtlpHttpJob {
|
|
request: opentelemetry_http::Request<opentelemetry_http::Bytes>,
|
|
response_sender: tokio::sync::oneshot::Sender<OtlpHttpResponse>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct AwcOtlpHttpClient {
|
|
sender: tokio::sync::mpsc::UnboundedSender<OtlpHttpJob>,
|
|
}
|
|
|
|
impl AwcOtlpHttpClient {
|
|
fn new() -> anyhow::Result<Self> {
|
|
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<tokio::sync::mpsc::UnboundedSender<OtlpHttpJob>> {
|
|
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<tokio::sync::mpsc::UnboundedSender<OtlpHttpJob>>
|
|
{
|
|
let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel::<OtlpHttpJob>();
|
|
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<opentelemetry_http::Bytes>,
|
|
) -> anyhow::Result<opentelemetry_http::Response<opentelemetry_http::Bytes>> {
|
|
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<opentelemetry_http::Bytes>,
|
|
) -> Result<
|
|
opentelemetry_http::Response<opentelemetry_http::Bytes>,
|
|
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<bool> {
|
|
init_telemetry_with_log_layer(logfmt::LogfmtLayer::new())
|
|
}
|
|
|
|
fn init_telemetry_with_log_layer(logfmt_layer: logfmt::LogfmtLayer) -> anyhow::Result<bool> {
|
|
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<S> Layer<S> 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::<SpanFields>() {
|
|
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<S>(
|
|
buf: &mut String,
|
|
scope: Option<tracing_subscriber::registry::Scope<'_, S>>,
|
|
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::<SpanFields>() {
|
|
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<Item = &'a HashMap<&'static str, String>>,
|
|
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<S>(
|
|
buf: &mut String,
|
|
scope: Option<tracing_subscriber::registry::Scope<'_, S>>,
|
|
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<S>(
|
|
scope: Option<tracing_subscriber::registry::Scope<'_, S>>,
|
|
) -> Option<String>
|
|
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
|
|
);
|
|
}
|
|
}
|
|
}
|