fix(observability): combine fmt + OTLP layers in single subscriber

Previously init_observability() called init_json_logger() (which set the
global subscriber with a fmt layer) and then init_tracing() (which only
set the global tracer provider). Because no tracing_opentelemetry::layer()
was ever added to the subscriber, #[instrument] spans never reached Tempo.

Now init_observability() builds a single Registry subscriber that
composes an EnvFilter, a JSON fmt layer, and an optional OTLP layer
(via tracing_opentelemetry). The OTLP layer gracefully degrades to None
when Tempo is unavailable, so logging always works.

Also adds build_otel_tracer() to tracing_config.rs which returns
Option<Tracer> for the caller to wrap in an OpenTelemetryLayer inside
their subscriber stack, letting the S type parameter be inferred
correctly by the compiler.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-03-01 00:32:35 +01:00
parent 7de84bb723
commit 0c7f6362d9
2 changed files with 86 additions and 20 deletions

View File

@@ -74,7 +74,7 @@ mod tests;
// Re-export commonly used types
pub use correlation::{CorrelationId, CorrelationIdExt, CORRELATION_ID_HEADER};
pub use logger::{init_json_logger, JsonLoggerConfig, LogLevel};
pub use tracing_config::{init_tracing, TracingConfig};
pub use tracing_config::{build_otel_tracer, init_tracing, TracingConfig};
use crate::error::CommonResult;
@@ -110,32 +110,41 @@ pub async fn init_observability(
service_name: &str,
otlp_endpoint: &str,
) -> CommonResult<()> {
// Console-only logging: stdout is captured by containerd and shipped
// to Loki via Promtail. No file logging needed in K8s.
let logger_config = JsonLoggerConfig {
service_name: service_name.to_string(),
log_level: LogLevel::Info,
enable_console: true,
enable_file: false,
log_directory: String::new(),
rotation: logger::LogRotation::Daily,
max_files: 10,
max_file_size_mb: 100,
};
use tracing_subscriber::prelude::*;
init_json_logger(logger_config)?;
// Build env filter: honour RUST_LOG, default to info.
let env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
// Initialize OpenTelemetry tracing with OTLP export.
// Gracefully degrade if Tempo is unavailable — logging still works.
// JSON fmt layer — stdout captured by containerd / Promtail → Loki.
let fmt_layer = tracing_subscriber::fmt::layer()
.json()
.with_target(true)
.with_current_span(true)
.with_span_list(false);
// OTLP layer — gracefully degrade if Tempo is unavailable.
// build_otel_tracer returns Option<Tracer>; wrapping with
// tracing_opentelemetry::layer() inside the subscriber chain lets
// the `S` type parameter be inferred correctly by the compiler.
let tracing_config = TracingConfig {
service_name: service_name.to_string(),
otlp_endpoint: otlp_endpoint.to_string(),
enable_export: true,
};
if let Err(e) = init_tracing(tracing_config).await {
eprintln!("OTLP tracing unavailable (non-fatal): {}", e);
let otel_tracer = tracing_config::build_otel_tracer(&tracing_config);
let otel_layer = otel_tracer
.map(|tracer| tracing_opentelemetry::layer().with_tracer(tracer));
if otel_layer.is_none() {
eprintln!("OTLP tracing layer unavailable — logs-only mode");
}
// Single subscriber with all layers. `.with(Option<L>)` is a no-op when None.
tracing_subscriber::registry()
.with(env_filter)
.with(fmt_layer)
.with(otel_layer)
.init();
Ok(())
}

View File

@@ -21,7 +21,7 @@
//! using the W3C Trace Context specification.
use crate::error::{CommonError, CommonResult};
use opentelemetry::{global, KeyValue};
use opentelemetry::{global, trace::TracerProvider as _, KeyValue};
use opentelemetry_otlp::{SpanExporter, WithExportConfig};
use opentelemetry_sdk::{
runtime::Tokio as OtelTokio,
@@ -57,12 +57,69 @@ impl Default for TracingConfig {
}
}
/// Build an OpenTelemetry tracer backed by an OTLP exporter.
///
/// Returns `Some(tracer)` when OTLP export is enabled and the exporter can be
/// constructed, or `None` on failure (graceful degradation). The caller wraps
/// the tracer in a `tracing_opentelemetry::layer().with_tracer(tracer)` layer
/// inside their subscriber stack so the `S` type parameter is inferred
/// correctly.
///
/// This function also registers the tracer provider as the global provider
/// (required for propagation helpers like `inject_trace_context`).
///
/// # Arguments
///
/// * `config` - Tracing configuration
///
/// # Returns
///
/// Returns `Some(Tracer)` on success, or `None` if export is disabled or the
/// OTLP exporter fails to build.
pub fn build_otel_tracer(
config: &TracingConfig,
) -> Option<opentelemetry_sdk::trace::Tracer> {
if !config.enable_export {
return None;
}
let exporter = match SpanExporter::builder()
.with_tonic()
.with_endpoint(&config.otlp_endpoint)
.build()
{
Ok(e) => e,
Err(e) => {
eprintln!("OTLP exporter build failed (non-fatal): {e}");
return None;
}
};
let provider = TracerProvider::builder()
.with_sampler(Sampler::AlwaysOn)
.with_batch_exporter(exporter, OtelTokio)
.with_resource(Resource::new(vec![
KeyValue::new("service.name", config.service_name.clone()),
KeyValue::new("service.version", env!("CARGO_PKG_VERSION")),
]))
.build();
let tracer = provider.tracer(config.service_name.clone());
global::set_tracer_provider(provider);
Some(tracer)
}
/// Initialize OpenTelemetry tracing with OTLP export.
///
/// This function sets up the OpenTelemetry tracing pipeline with OTLP
/// (OpenTelemetry Protocol) as the trace exporter. OTLP is the vendor-neutral
/// standard supported by Jaeger (v1.35+), Grafana Tempo, Datadog, and others.
///
/// **Note:** Prefer [`build_otel_tracer`] for new code. This function is kept
/// for backward compatibility but only sets the global tracer provider without
/// adding a layer to the subscriber.
///
/// # Arguments
///
/// * `config` - Tracing configuration