Otel: unify tracing subscriber across dev+release; env-gated OTLP export (#138)

Install ONE `tracing` subscriber in both build profiles via
`otel::init_tracing()` (previously release-only, so spans were a no-op in
dev). Behaviour is now environment-driven, not build-profile-driven:

- OTLP span export happens only when `OTLP_OTLS_ENDPOINT` is set
  (release / staging). Unset -> spans are still created with valid
  contexts (so outgoing `traceparent` injection keeps working) but export
  nowhere; the console `fmt` layer is what shows them.
- `tracing_subscriber::registry()` + `EnvFilter` (RUST_LOG) +
  `OpenTelemetryLayer` + console `fmt` layer, installed in both builds.
- Set the global W3C `TraceContextPropagator`: required by the
  `tracing-actix-web` request middleware (#139) and by the outgoing
  traceparent helpers, both of which read the *global* propagator.
- `tracing-log` bridge stays OFF (`default-features = false`) so the console
  fmt layer prints tracing spans only; `log` records keep their existing
  path (env_logger in debug, OTel logs in release) without double-printing.

Deps: + tracing, tracing-subscriber (env-filter/fmt/ansi), tracing-opentelemetry
0.32 (pins opentelemetry 0.31 + otel_sdk 0.31, no version bump); opentelemetry_sdk
gains the `testing` feature.

Add `tests/otel_propagation.rs`: the regression matrix from spike #133, pinning
the invariant the whole migration rests on -- an OTel context does NOT cross a
bare `tokio::spawn` on a multi-thread runtime (S2/S4 orphan onto a fresh trace)
but DOES with an explicit parent carry across the spawn (S6 `.instrument()`,
S7 `span.enter()`). This is why every spawn in the codebase must carry the parent.

clippy --all-targets clean; 690 lib + 775 integration + regression + 4 doc-tests pass.
This commit is contained in:
Cameron Cordes committed 2026-09-29 17:02:40 -04:00
1 parent 8284509c90
commit e93f6b6d69
5 files changed
+447 -14

No files matched your search

+4 -2
View File
@@ -111,7 +111,10 @@ fn main() -> std::io::Result<()> {
let system = actix::System::new();
system.block_on(async {
// Just use basic logger when running a non-release build
// One `tracing` subscriber in every build: env-driven, so dev prints
// spans to the console and release exports them over OTLP. The `log`
// pipeline stays separate — env_logger in debug, OTel logs in release.
otel::init_tracing();
#[cfg(debug_assertions)]
{
env_logger::init();
@@ -119,7 +122,6 @@ fn main() -> std::io::Result<()> {
#[cfg(not(debug_assertions))]
{
otel::init_logs();
otel::init_tracing();
}
// AppState construction loads (and seeds if needed) the libraries
+47 -11
View File
@@ -3,9 +3,11 @@ use actix_web::http::header::HeaderMap;
use opentelemetry::global::{BoxedSpan, BoxedTracer};
use opentelemetry::propagation::TextMapPropagator;
use opentelemetry::trace::{
Span, SpanBuilder, SpanContext, SpanKind, Status, TraceContextExt, Tracer,
Span, SpanBuilder, SpanContext, SpanKind, Status, TraceContextExt, Tracer, TracerProvider,
};
use opentelemetry::{Context, KeyValue, global};
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use opentelemetry_appender_log::OpenTelemetryLogBridge;
use opentelemetry_otlp::WithExportConfig;
use opentelemetry_sdk::Resource;
@@ -16,8 +18,26 @@ pub fn global_tracer() -> BoxedTracer {
global::tracer("image-server")
}
#[allow(dead_code)]
/// Install the process-wide `tracing` subscriber plus the OTel span pipeline.
///
/// Called in BOTH debug and release builds (the old split made spans a no-op
/// in dev). Behaviour is driven by the environment, not the build profile:
/// - `OTLP_OTLS_ENDPOINT` set -> spans are batch-exported over OTLP (the
/// release / staging path).
/// - unset -> spans are created with valid contexts (so
/// outgoing `traceparent` injection still works) but exported nowhere; the
/// console `fmt` layer is what shows them.
///
/// The console `fmt` layer is always present, so a release build run locally
/// also prints spans to stdout. `log` records are NOT routed through this
/// subscriber (the `tracing-log` bridge is off) — they keep their existing
/// path: `env_logger` in debug, OTel logs in release.
pub fn init_tracing() {
// W3C propagator for the whole process: the request middleware
// (tracing-actix-web) and the outgoing `traceparent` helpers both read
// the *global* propagator, so it must be set before any request lands.
global::set_text_map_propagator(TraceContextPropagator::new());
let resources = Resource::builder()
.with_attributes([
KeyValue::new("service.name", "image-server"),
@@ -25,18 +45,34 @@ pub fn init_tracing() {
])
.build();
let span_exporter = opentelemetry_otlp::SpanExporter::builder()
.with_tonic()
.with_endpoint(std::env::var("OTLP_OTLS_ENDPOINT").unwrap())
.build()
.unwrap();
let mut provider_builder =
opentelemetry_sdk::trace::SdkTracerProvider::builder().with_resource(resources);
let tracer_provider = opentelemetry_sdk::trace::SdkTracerProvider::builder()
.with_batch_exporter(span_exporter)
.with_resource(resources)
.build();
if let Ok(endpoint) = std::env::var("OTLP_OTLS_ENDPOINT") {
let span_exporter = opentelemetry_otlp::SpanExporter::builder()
.with_tonic()
.with_endpoint(endpoint)
.build()
.expect("OTLP span exporter");
provider_builder = provider_builder.with_batch_exporter(span_exporter);
}
let tracer_provider = provider_builder.build();
// The rest of the codebase resolves spans through the global provider
// (`global_tracer()`, `global::tracer("db")`), so install it there; the
// subscriber's OTel layer uses the same provider's tracer.
let tracer = tracer_provider.tracer("image-server");
global::set_tracer_provider(tracer_provider);
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.with(tracing_opentelemetry::OpenTelemetryLayer::new(tracer))
.with(tracing_subscriber::fmt::layer().with_target(false))
.try_init()
.expect("failed to install tracing subscriber");
}
#[allow(dead_code)]