Why OpenTelemetry for Rust
OpenTelemetry is the standard observability SDK across languages, and the Rust implementation reached production-grade in 2024–2025. The same OTLP wire format whether you instrument Rust, Go, Python, Node.js, or .NET means your Rust services, your Python workers, and your Node.js edge functions all show up in one Service Map in Telemtra with consistent service names and trace context.
Compared to Rust-specific solutions (vendor SDKs, ad-hoc tracing-subscriber setups, log-only pipelines), OpenTelemetry gives you one SDK that emits traces, metrics, and logs together, with auto-context propagation through tokio tasks, HTTP headers, and gRPC metadata. And because it routes through the tracing crate, your existing tracing::info! and #[instrument] calls become OTLP automatically — no rewrite.
Install
Add to Cargo.toml (latest stable versions as of mid-2026):
[dependencies]
opentelemetry = { version = "0.27", features = ["trace", "metrics", "logs"] }
opentelemetry_sdk = { version = "0.27", features = ["rt-tokio", "trace", "metrics", "logs"] }
opentelemetry-otlp = { version = "0.27", features = ["http-proto", "reqwest-client", "trace", "metrics", "logs"] }
opentelemetry-appender-tracing = "0.27"
opentelemetry-semantic-conventions = "0.27"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tracing-opentelemetry = "0.28"
dotenvy = "0.15"
# Pick the framework integration you need:
tower-http = { version = "0.6", features = ["trace"] } # for axum / tower stacks
actix-web-opentelemetry = "0.19" # for actix-web Where this goes in your project
Rust has no agent or launcher — telemetry setup is a module you call from main(). Typical layout:
my-service/
├── src/main.rs # entry point — call telemetry::init() here
├── src/telemetry.rs # NEW: the init/shutdown functions below
├── Cargo.toml
└── .env # TELEMTRA_API_KEY and OTEL_* vars 1. Environment variables (.env):
TELEMTRA_API_KEY=tk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
OTEL_EXPORTER_OTLP_ENDPOINT=https://ingest.telemtra.io
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
OTEL_EXPORTER_OTLP_HEADERS=x-api-key=${TELEMTRA_API_KEY}
OTEL_SERVICE_NAME=checkout-api
OTEL_RESOURCE_ATTRIBUTES=deployment.environment=development Every official OpenTelemetry SDK reads these automatically — no hardcoded URLs or headers in your Rust code.
2. Call init from main first, shutdown last:
mod telemetry;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
dotenvy::dotenv().ok();
let guard = telemetry::init()?;
// ... your existing server startup ...
guard.shutdown();
Ok(())
} The shutdown call is critical: BatchSpanProcessor holds spans in memory until the batch fills, so without an explicit flush you will lose the last few seconds of telemetry on every restart.
Bootstrap the SDK
// src/telemetry.rs
use opentelemetry::{global, KeyValue};
use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge;
use opentelemetry_otlp::WithExportConfig;
use opentelemetry_sdk::{
logs::LoggerProvider,
metrics::{PeriodicReader, SdkMeterProvider},
runtime,
trace::{BatchConfig, TracerProvider},
Resource,
};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
pub struct TelemetryGuard {
tracer_provider: TracerProvider,
meter_provider: SdkMeterProvider,
logger_provider: LoggerProvider,
}
impl TelemetryGuard {
pub fn shutdown(self) {
let _ = self.tracer_provider.shutdown();
let _ = self.meter_provider.shutdown();
let _ = self.logger_provider.shutdown();
}
}
pub fn init() -> Result<TelemetryGuard, Box<dyn std::error::Error>> {
let resource = Resource::new(vec![
KeyValue::new("service.name", std::env::var("OTEL_SERVICE_NAME").unwrap_or_else(|_| "rust-app".into())),
]);
// Traces
let trace_exporter = opentelemetry_otlp::new_exporter().http().with_protocol(opentelemetry_otlp::Protocol::HttpBinary);
let tracer_provider = opentelemetry_otlp::new_pipeline()
.tracing()
.with_exporter(trace_exporter)
.with_trace_config(opentelemetry_sdk::trace::Config::default().with_resource(resource.clone()))
.with_batch_config(BatchConfig::default())
.install_batch(runtime::Tokio)?;
global::set_tracer_provider(tracer_provider.clone());
// Metrics
let metric_exporter = opentelemetry_otlp::new_exporter().http().with_protocol(opentelemetry_otlp::Protocol::HttpBinary);
let meter_provider = opentelemetry_otlp::new_pipeline()
.metrics(runtime::Tokio)
.with_exporter(metric_exporter)
.with_resource(resource.clone())
.build()?;
global::set_meter_provider(meter_provider.clone());
// Logs (bridged from tracing)
let log_exporter = opentelemetry_otlp::new_exporter().http().with_protocol(opentelemetry_otlp::Protocol::HttpBinary);
let logger_provider = opentelemetry_otlp::new_pipeline()
.logging()
.with_exporter(log_exporter)
.with_resource(resource)
.install_batch(runtime::Tokio)?;
// Wire tracing-subscriber so tracing::info! and #[instrument] flow through
let tracer = global::tracer("rust-app");
let otel_trace_layer = tracing_opentelemetry::layer().with_tracer(tracer);
let otel_log_layer = OpenTelemetryTracingBridge::new(&logger_provider);
tracing_subscriber::registry()
.with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
.with(otel_trace_layer)
.with(otel_log_layer)
.with(tracing_subscriber::fmt::layer())
.init();
Ok(TelemetryGuard { tracer_provider, meter_provider, logger_provider })
} One init() wires all three signals plus the tracing bridge. After this, every tracing::info! or #[instrument] in your code emits OTLP automatically.
Instrument axum
Add tower-http's TraceLayer to your router. It produces a span for every request with method, route, and status.
use axum::{routing::get, Router};
use tower_http::trace::TraceLayer;
let app = Router::new()
.route("/checkout", get(checkout_handler))
.layer(TraceLayer::new_for_http());
let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await?;
axum::serve(listener, app).await?; For outbound HTTP calls, use reqwest-tracing or wrap calls in #[instrument] so the trace context is attached.
Instrument actix-web
use actix_web::{web, App, HttpServer};
use actix_web_opentelemetry::RequestTracing;
HttpServer::new(|| {
App::new()
.wrap(RequestTracing::new())
.route("/checkout", web::get().to(checkout_handler))
})
.bind("0.0.0.0:8080")?
.run()
.await?; Instrument tonic (gRPC)
Add an interceptor on both client and server that injects/extracts W3C tracecontext headers:
use opentelemetry::global;
use tonic::{service::Interceptor, Request, Status};
#[derive(Clone)]
struct TraceInterceptor;
impl Interceptor for TraceInterceptor {
fn call(&mut self, mut req: Request<()>) -> Result<Request<()>, Status> {
let cx = tracing::Span::current().context();
let mut headers = req.metadata().clone();
global::get_text_map_propagator(|prop| {
prop.inject_context(&cx, &mut MetadataInjector(&mut headers));
});
*req.metadata_mut() = headers;
Ok(req)
}
} For a working MetadataInjector, see the opentelemetry-otlp examples directory. Spans propagate across gRPC the same way they do across HTTP.
Instrument sqlx
sqlx emits tracing spans natively when the runtime-tokio-rustls and macros features are enabled. With the OTel bridge in place, those become OTLP spans automatically:
[dependencies]
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "macros"] } let row = sqlx::query!("SELECT id, name FROM users WHERE id = $1", user_id)
.fetch_one(&pool)
.await?; Every query becomes a child span on whatever request triggered it.
Manual spans with #[instrument]
use tracing::instrument;
#[instrument(skip(db), fields(order.id = %order_id, order.total_cents = total_cents))]
async fn submit_order(db: &Pool, order_id: String, total_cents: i64) -> Result<(), Error> {
charge_card(db, &order_id, total_cents).await?;
write_ledger(db, &order_id).await?;
Ok(())
} The #[instrument] attribute wraps the function in a tracing span — and because the OTel bridge is wired, that span becomes an OTLP span in Telemtra with the attributes you listed in fields(...).
Metrics
use opentelemetry::{global, KeyValue};
let meter = global::meter("checkout-api");
let orders_total = meter.u64_counter("orders_total").init();
let checkout_latency = meter.f64_histogram("checkout_latency_ms").init();
orders_total.add(1, &[KeyValue::new("plan", "team")]);
checkout_latency.record(348.0, &[KeyValue::new("region", "eu-west-1")]); Use enumerable attribute values (plan, region, http.status_code). Never put unbounded values (request IDs, user IDs) in metric attributes.
Logs via the tracing bridge
You do not need a separate log call — every tracing::info!, tracing::warn!, and tracing::error! already emits an OTLP log record through the OpenTelemetryTracingBridge layer wired in init(). The active trace_id and span_id attach automatically.
tracing::info!(order.id = %order_id, "order accepted");
tracing::warn!(retry = 2, "downstream slow");
tracing::error!(err = %e, "payment failed"); In Telemtra you can jump from a slow trace to the exact log lines it produced.
Context propagation across tokio tasks
Rust's tracing crate carries the active span via task-local storage. When you spawn a new task with tokio::spawn, attach the current span explicitly with .instrument():
use tracing::Instrument;
let span = tracing::info_span!("background.process");
tokio::spawn(async move {
// ... work happens inside this span ...
}.instrument(span)); For HTTP/gRPC propagation across services, the default propagator is W3C tracecontext + baggage — what Telemtra and every other OTel-compatible backend expects.
Troubleshooting
- Spans flush late or never. Call
guard.shutdown()on graceful exit.BatchSpanProcessorholds spans until the batch fills or the timer fires. - 403 from Telemtra. Check
OTEL_EXPORTER_OTLP_HEADERS=x-api-key=$${TELEMTRA_API_KEY}is set and the key is active. The header is read by the exporter — not by reqwest's default headers. - No logs in Telemtra. Confirm
OpenTelemetryTracingBridgeis added to the subscriber AND thelogsfeature is enabled onopentelemetry,opentelemetry_sdk, andopentelemetry-otlp. - tokio::spawn loses span context. Use
.instrument(Span::current())on the spawned future. - Disconnected spans across services. Confirm both sides use W3C tracecontext (the default propagator).
- High-cardinality metrics warning. Never put request IDs, user IDs, or full URLs in metric attribute values.
For protocol details see What is OTLP?. To run a sidecar Collector for batching and buffering, follow the Collector quickstart. New to Telemtra? Start with the quickstart or the AI agent quickstart if you want your coding agent to do this for you. To go live, create a Telemtra workspace.
OpenTelemetry Rust FAQ
Why is the Rust OpenTelemetry SDK so different from Python or Node.js?
Rust has no runtime monkey-patching, so there is no autoload equivalent. Instead, the ecosystem leans on the tracing crate as a common surface: you instrument with tracing::info! and #[instrument], and a single subscriber layer (tracing-opentelemetry) bridges those events into OTLP. It is more explicit upfront but more flexible long-term.
Do I have to use the tracing crate, or can I use OpenTelemetry directly?
Both work. The tracing-based approach is idiomatic for most Rust web frameworks (axum, actix-web, tonic) because those frameworks already emit tracing spans. If your codebase does not use tracing, you can call opentelemetry::global::tracer() directly — but you will write more wiring.
Which HTTP frameworks are supported?
axum and actix-web have first-class community integrations (tower-http TraceLayer, actix-web-opentelemetry). Rocket and warp work via manual tracing spans. tonic (gRPC) has an interceptor pattern that propagates W3C tracecontext across the wire.
How do I link tracing::info! logs to the active span?
Use opentelemetry-appender-tracing — its OpenTelemetryTracingBridge layer captures tracing events and emits them as OTLP log records with the active trace_id and span_id attached. In Telemtra you can jump from a slow trace straight to the log lines it produced.
What about sqlx, sea-orm, and other databases?
sqlx emits tracing spans natively when the runtime-tokio-rustls + macros features are enabled, so they appear in your traces automatically. For sea-orm enable the with-tracing feature. For raw tokio-postgres or rusqlite, wrap your queries in #[instrument] for manual instrumentation.
Further reading
- Telemtra quickstart — minimum Rust config and snippets for every other supported stack.
- AI agent quickstart — paste one prompt into Claude Code or Cursor and have your agent wire all of the above for you.
- opentelemetry.io — Rust documentation
- github.com/open-telemetry/opentelemetry-rust — SDK source
- docs.rs — opentelemetry-otlp — exporter API reference
- docs.rs — tracing-opentelemetry — tracing bridge reference
- OpenTelemetry semantic conventions