otel-bootstrap 2.14.1

One-call OpenTelemetry bootstrap — traces + metrics with OTLP export
Documentation

otel-bootstrap

CI crates.io docs.rs License: MIT Rust 1.85+

One-call OpenTelemetry bootstrap for Rust services — traces, metrics, and logs over OTLP with sensible defaults.

The standard opentelemetry + opentelemetry-otlp + tracing-subscriber wiring is the same in every service. This crate does it once: call init_telemetry("my-service"), keep the handle alive, and drop it to flush.

Features

  • One call to wire it all up — traces, metrics, and logs over OTLP, tracing-subscriber configured, W3C TraceContext + Baggage propagators registered.
  • gRPC or HTTP/protobuf — select transport via cargo feature.
  • Env-var configuration — follows the OpenTelemetry spec (OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_SERVICE_NAME, OTEL_TRACES_SAMPLER, …).
  • Builder API — version, environment, sampler, custom meter setup, extra subscriber layers.
  • Optional axum middlewareOtelTraceLayer for inbound HTTP trace propagation.
  • Generic enduser.* span enrichment — implement EnrichSpan on your context type and plug it into SpanEnricherLayer<T> with no brefwiz dependencies required.
  • Graceful shutdown — drop TelemetryHandles to flush and shut down both providers.

Quick start

[dependencies]
otel-bootstrap = "2"
tokio = { version = "1", features = ["full"] }
use otel_bootstrap::init_telemetry;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let _telemetry = init_telemetry("my-service")?;

    tracing::info!("service started");
    // ... run your server ...
    Ok(())
    // _telemetry dropped here → SDK flushes and shuts down
}

For more control, use the builder:

use otel_bootstrap::{LogFormat, Telemetry};

let _telemetry = Telemetry::builder("my-service")
    .with_version(env!("CARGO_PKG_VERSION"))
    .with_environment("production")
    .with_log_filter("info,opentelemetry_sdk=warn")
    .with_log_format(LogFormat::Json)
    .init()?;

with_log_filter and with_log_format take precedence over environment-backed logging defaults, so typed configuration sources can configure logging without mutating process-global environment.

Cargo features

Feature Default Description
grpc OTLP/gRPC transport via tonic
http OTLP/HTTP-protobuf transport via reqwest
axum OtelTraceLayer + SpanEnricherLayer<T> for axum servers
tonic-tracing grpc_client_layer() + grpc_server_layer() for raw tonic gRPC clients/servers
testing In-memory exporters for unit tests

At least one of grpc or http must be enabled (enforced at compile time).

Configuration

Variable Default
OTEL_EXPORTER_OTLP_ENDPOINT http://localhost:4317 (gRPC) / http://localhost:4318 (HTTP)
OTEL_EXPORTER_OTLP_PROTOCOL grpc
OTEL_EXPORTER_OTLP_TIMEOUT 10000 ms
OTEL_SERVICE_NAME overridden by the service_name argument
OTEL_TRACES_SAMPLER parentbased_always_on
OTEL_TRACES_SAMPLER_ARG sampler-specific (e.g. ratio for traceidratio)

Full reference: OTEL_TRACES_SAMPLER values.

Axum middleware

otel-bootstrap = { version = "2", features = ["axum"] }
use otel_bootstrap::axum_layer;

let app = Router::new()
    .route("/", get(handler))
    .layer(axum_layer());

This layer reads the traceparent / tracestate headers, starts a server span, and propagates context to all child spans.

enduser.* span enrichment

otel-bootstrap = { version = "2", features = ["axum"] }

Implement EnrichSpan on your context type, then plug it into SpanEnricherLayer:

use otel_bootstrap::span_enrichment::{EnrichSpan, span_enricher_layer};
use tracing::Span;

#[derive(Clone)]
struct MyContext { user_id: String }

impl EnrichSpan for MyContext {
    fn enrich(&self, span: &Span) {
        span.record("enduser.id", &self.user_id.as_str());
    }
}

let app = Router::new()
    .route("/", get(handler))
    .layer(span_enricher_layer::<MyContext>());

Routes with no MyContext extension are silently skipped.

Span-field log propagation

When with_logs(true) is set, otel-bootstrap replaces the upstream log bridge with SpanAwareLogBridge. It propagates span-level attributes into every OTLP log record so that request.id, enduser.*, and http.* fields appear as Loki labels alongside the trace context.

Two propagation paths are supported:

  • Tracing fields declared at span creation (info_span!("req", "request.id" = id)) — captured automatically for names in [PROPAGATED_SPAN_FIELDS].
  • Post-creation attributes set via record_span_log_attr_on — use this from middleware or EnrichSpan::enrich_span for fields that arrive after span creation.
use otel_bootstrap::{span_enrichment::emit_request_id, record_span_log_attr_on};
use opentelemetry::{Key, logs::AnyValue};

// In your request handler / middleware:
emit_request_id(&request_id);  // → appears in traces + logs

// In EnrichSpan::enrich_span for enduser fields:
span.set_attribute("enduser.id", user_id.clone());
record_span_log_attr_on(span, Key::new("enduser.id"), AnyValue::String(user_id.into()));

Override the captured field set per service:

const FIELDS: &[&str] = &["request.id", "enduser.id", "tenant.id"];
let _telemetry = Telemetry::builder("my-service")
    .with_logs(true)
    .with_propagated_span_fields(FIELDS)
    .init()?;

Continuous profiling

Profiling is the fourth OTLP signal alongside traces, metrics, and logs (ADR platform/0201). It ships as an explicit, off-by-default feature:

Feature Default Description
profiling Enables Telemetry::builder(...).with_profiling(endpoint)
profiling-bridge-pyroscope-rs Implies profiling; wires the pyroscope-rs exporter
let _telemetry = otel_bootstrap::Telemetry::builder("my-service")
    .with_profiling("http://localhost:4040")
    .init()?;

Single export plane. There is no direct-to-backend pyroscope client path. pyroscope-rs hardcodes its own HTTP client with no TLS/identity hook, so profiles are pushed over plain HTTP to a localhost-only SPIFFE-terminating sidecar that carries the workload identity upstream (ADR platform/0203). The with_profiling endpoint is validated at init time to be loopback-only (127.0.0.1, ::1, localhost, or a unix socket) — anything else is rejected. This bridge is a temporary, sunset-bound exception (ADR platform/0202, Tracks #40) pending a native Rust OTLP profiles exporter; every profile carries trace_id/span_id tags for correlation with the trace pillar.

SVID rotation requires a pod restart. Like the rest of the telemetry channel, mTLS material for the profiling sidecar path is read once at init() time and held for the process lifetime. SPIFFE SVID rotation is not hot-reloaded into an already-running telemetry channel — a rotated SVID only takes effect on the next process start (pod restart). This is a standing platform constraint, not a profiling-specific bug.

Examples

License

MIT — see LICENSE.