tracing setup, request spans, OpenTelemetry, tokio-console, configuration loading, container images and performance habits for Tokio services.
Contents
- tracing basics
- Subscriber setup
- #[instrument] and spans across await
- HTTP request spans with TraceLayer
- Metrics
- OpenTelemetry
- tokio-console
- Configuration
- Dockerfile: cargo-chef + distroless
- Kubernetes probes and shutdown
- Performance checklist
tracing basics
tracingis the instrumentation API (spans, events, fields);tracing-subscriberdecides what is recorded and where it goes. Libraries (tokio, hyper, tower-http, sqlx, reqwest) emittracingevents.- Events:
tracing::info!(order_id = id, sku = %sku, "order created").%usesDisplay,?usesDebug. Use fields, not formatted strings, so logs stay queryable. - Spans give context to every event inside them (request id, user, order). In async code, attach spans to
futures (
.instrument(span)/#[instrument]), never holdspan.enter()across.await.
Subscriber setup
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }use tracing_subscriber::{EnvFilter, fmt, layer::SubscriberExt, util::SubscriberInitExt};
pub fn init_tracing() {
let filter = EnvFilter::try_from_default_env() // RUST_LOG=info,my_service=debug,tower_http=debug,sqlx=warn
.unwrap_or_else(|_| EnvFilter::new("info,tower_http=info,sqlx=warn"));
let json = std::env::var("LOG_FORMAT").is_ok_and(|v| v == "json");
let registry = tracing_subscriber::registry().with(filter);
if json {
registry
.with(fmt::layer().json().with_current_span(true).with_span_list(false))
.init();
} else {
registry.with(fmt::layer().with_target(false)).init();
}
}EnvFilterdirective syntax:level,target=level,target[span{field=value}]=level. Targets are module paths with::(crate names use_, not-).- JSON in production (one object per line for the log pipeline), pretty/compact locally.
tracing-logbridging is enabled by default intracing-subscriber, so crates usinglogshow up too.- Non-blocking file/stdout writer:
tracing-appender::non_blocking(std::io::stdout())returns a writer and a guard; keep the guard alive inmainor buffered logs are lost at exit.
#[instrument] and spans across await
use tracing::instrument;
#[instrument(skip(state, body), fields(tenant = %tenant, order_id))]
async fn create_order(state: &AppState, tenant: String, body: NewOrder) -> Result<Order, AppError> {
let order = state.repo.insert(&tenant, &body).await?;
tracing::Span::current().record("order_id", order.id); // fill a declared-empty field later
Ok(order)
}
#[instrument(level = "debug", skip_all, err)] // err: record Err(e) as an error event (needs Display)
async fn charge(gateway: &Gateway, amount_cents: i64) -> Result<(), PaymentError> {
gateway.charge(amount_cents).await
}skip(..)/skip_allfor large or secret arguments; every non-skipped argument must implementDebug.retrecords the return value,errrecords errors;fields(..)adds computed fields.- Spawned tasks do not inherit the current span automatically:
tokio::spawn(fut.in_current_span())(tracing::Instrument) or create a span explicitly.
HTTP request spans with TraceLayer
use std::time::Duration;
use axum::{extract::{MatchedPath, Request}, response::Response};
use tower_http::trace::TraceLayer;
use tracing::Span;
pub fn trace_layer() -> TraceLayer<
tower_http::classify::SharedClassifier<tower_http::classify::ServerErrorsAsFailures>,
impl Fn(&Request) -> Span + Clone,
tower_http::trace::DefaultOnRequest,
impl Fn(&Response, Duration, &Span) + Clone,
> {
TraceLayer::new_for_http()
.make_span_with(|req: &Request| {
let route = req.extensions().get::<MatchedPath>().map(|p| p.as_str().to_owned());
let request_id = req.headers().get("x-request-id").and_then(|v| v.to_str().ok()).unwrap_or("-");
tracing::info_span!(
"http",
method = %req.method(),
route = route.as_deref().unwrap_or("unmatched"), // low-cardinality: "/orders/{id}"
request_id,
status = tracing::field::Empty,
)
})
.on_response(|res: &Response, latency: Duration, span: &Span| {
span.record("status", res.status().as_u16());
tracing::info!(latency_ms = latency.as_millis() as u64, "response");
})
}The explicit return type is verbose; in practice build the layer inline in your router function. Use the
MatchedPath route template (not the raw URI) as the span/metric label to avoid unbounded cardinality.
MatchedPath is only available when the layer is applied with Router::layer (inside routing), not around
the whole service.
Metrics
metrics+metrics-exporter-prometheus(a/metricsendpoint) or OpenTelemetry metrics.- RED per route: request count, error count (5xx), latency histogram, labelled by matched route and method.
- Saturation gauges: in-flight requests,
Semaphore::available_permits(), channelcapacity(), sqlxpool.size()andpool.num_idle(), blocking-pool queue (tokio runtime metrics). - Tokio runtime metrics:
tokio::runtime::Handle::current().metrics()exposes worker count, alive tasks, global queue depth on stable; more detailed per-worker metrics (busy duration, steal counts, poll counts) need--cfg tokio_unstable.tokio-metricscrate adds task-level instrumentation (TaskMonitor).
OpenTelemetry
Export spans to an OTLP collector (Jaeger, Tempo, Honeycomb, Datadog agent) through tracing-opentelemetry.
The crates are opentelemetry, opentelemetry_sdk, opentelemetry-otlp and tracing-opentelemetry; they
release in lockstep and their builder APIs change between minor versions, so pin a matching set (the
tracing-opentelemetry README lists compatible versions) and copy the init code from that version's docs.
The shape is always:
1. Build an OTLP span exporter (tonic/gRPC to :4317 or HTTP to :4318).
2. Build an SdkTracerProvider with a batch processor and a Resource { service.name, service.version, env }.
3. tracer = provider.tracer("my-service")
4. registry().with(EnvFilter).with(fmt::layer()).with(tracing_opentelemetry::layer().with_tracer(tracer)).init()
5. Propagate context: extract W3C `traceparent` from incoming headers into the request span; inject it into
outgoing reqwest calls (a reqwest middleware or manual header injection).
6. On shutdown: provider.shutdown() so buffered spans are flushed.Configure with the standard env vars where supported (OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_SERVICE_NAME,
OTEL_TRACES_SAMPLER). Sample in production (parent-based ratio) and keep high-cardinality data in span
attributes, not span names.
tokio-console
A top-like view of every task: busy time, idle time, wakeups, polls, and warnings for tasks that are never
woken or poll for too long (blocking).
[dependencies]
console-subscriber = "0.4"
tokio = { version = "1", features = ["full", "tracing"] }fn main() {
console_subscriber::init(); // instead of your normal subscriber, or add console_subscriber::spawn() as a layer
// build the runtime / #[tokio::main] as usual
}RUSTFLAGS="--cfg tokio_unstable" cargo run # instrumentation requires tokio_unstable
cargo install --locked tokio-console
tokio-console # connects to 127.0.0.1:6669Reading it:
- A task with high busy time and few polls is blocking the worker (a sync call or CPU loop): move it to
spawn_blocking. - Many tasks idle forever, count growing: leaked/detached tasks waiting on something that never happens.
- "lost waker" warnings: a hand-written future returned
Pendingwithout arranging a wakeup.
Check the console-subscriber version against your tokio version; build the instrumented binary only for
debugging or staging (it has overhead).
Configuration
Order of precedence: defaults < config file < environment < CLI flags. Parse once at startup into a typed
struct, validate, fail fast with a clear message, and put it in AppState.
Plain environment variables (no extra crate):
use std::{net::SocketAddr, time::Duration};
#[derive(Clone, Debug)]
pub struct Config {
pub addr: SocketAddr,
pub database_url: String,
pub request_timeout: Duration,
pub max_db_connections: u32,
}
impl Config {
pub fn from_env() -> anyhow::Result<Self> {
use anyhow::Context;
let var = |k: &str| std::env::var(k).with_context(|| format!("missing env var {k}"));
Ok(Self {
addr: std::env::var("ADDR").unwrap_or_else(|_| "0.0.0.0:3000".into()).parse()?,
database_url: var("DATABASE_URL")?,
request_timeout: Duration::from_secs(
std::env::var("REQUEST_TIMEOUT_SECS").ok().and_then(|v| v.parse().ok()).unwrap_or(10),
),
max_db_connections: std::env::var("MAX_DB_CONNECTIONS").ok().and_then(|v| v.parse().ok()).unwrap_or(20),
})
}
}figment (figment = { version = "0.10", features = ["toml", "env"] }):
use figment::{Figment, providers::{Env, Format, Serialized, Toml}};
#[derive(Debug, serde::Deserialize, serde::Serialize)]
pub struct Settings {
pub port: u16,
pub database_url: String,
pub log_format: String,
}
impl Default for Settings {
fn default() -> Self {
Self { port: 3000, database_url: String::new(), log_format: "text".into() }
}
}
pub fn load() -> Result<Settings, figment::Error> {
Figment::from(Serialized::defaults(Settings::default()))
.merge(Toml::file("App.toml")) // optional: missing file is ignored
.merge(Env::prefixed("APP_")) // APP_PORT=8080, APP_DATABASE_URL=...
.extract()
}config crate (config = "0.15"):
#[derive(Debug, serde::Deserialize)]
pub struct Settings {
pub port: u16,
pub database: Database,
}
#[derive(Debug, serde::Deserialize)]
pub struct Database {
pub url: String,
pub max_connections: u32,
}
pub fn load() -> Result<Settings, config::ConfigError> {
config::Config::builder()
.set_default("port", 3000)?
.add_source(config::File::with_name("config/default").required(false))
.add_source(config::Environment::with_prefix("APP").separator("__")) // APP_DATABASE__URL
.build()?
.try_deserialize()
}- Secrets come from the environment or a mounted file, never from the image; wrap them (
secrecy::SecretString) soDebugdoes not print them. - Hot reload: publish new values through
tokio::sync::watch; most settings (pool sizes, bind address) need a restart anyway, keep reload for flags and limits.
Dockerfile: cargo-chef + distroless
# syntax=docker/dockerfile:1
FROM lukemathwalker/cargo-chef:latest-rust-1 AS chef
WORKDIR /app
FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json
FROM chef AS builder
COPY --from=planner /app/recipe.json recipe.json
# Dependencies only: this layer is cached until Cargo.toml/Cargo.lock change
RUN cargo chef cook --release --recipe-path recipe.json
COPY . .
ENV SQLX_OFFLINE=true
RUN cargo build --release --locked --bin orders-api
FROM gcr.io/distroless/cc-debian12:nonroot AS runtime
COPY --from=builder /app/target/release/orders-api /usr/local/bin/orders-api
EXPOSE 3000
ENTRYPOINT ["/usr/local/bin/orders-api"]cc-debian12contains glibc and libgcc (needed by a normal Rust binary) but no shell and no OpenSSL; use rustls in reqwest/sqlx, or switch to a debian-slim runtime withlibssl3andca-certificates.- rustls with
webpki-rootsembeds CA roots; with native roots the image needs/etc/ssl/certs(distroless cc includes ca-certificates). - Fully static alternative: build for
x86_64-unknown-linux-musland usegcr.io/distroless/staticorscratch; if you do, consider a different global allocator (musl's malloc is slow under contention). SQLX_OFFLINE=true+ committed.sqlx/letsquery!macros compile without a database.- The process is PID 1: it receives SIGTERM directly, which is what the shutdown handler expects. No shell
wrapper (
sh -c) that would swallow signals.
Kubernetes probes and shutdown
readinessProbe:
httpGet: { path: /readyz, port: 3000 }
periodSeconds: 5
failureThreshold: 2
livenessProbe:
httpGet: { path: /healthz, port: 3000 }
periodSeconds: 10
failureThreshold: 3
lifecycle:
preStop:
exec: { command: ["/usr/local/bin/orders-api", "--sleep", "5"] } # distroless has no `sleep`; or use the in-app delay
terminationGracePeriodSeconds: 30Either implement the readiness delay inside the app (flip readiness, sleep 5 s, then start draining, as in
runtime-tasks-and-shutdown.md) or give the binary a tiny --sleep N mode
for preStop; recent Kubernetes versions also support a native sleep preStop action.
Performance checklist
- One
reqwest::Client, onePgPool, built at startup; no per-request TLS handshakes - No blocking calls on workers (tokio-console busy times,
blocking_in_async); CPU work on rayon/spawn_blocking - Bodies as
Bytes/ streams, notVec<u8>copies;Bytes::cloneis a refcount bump - Serialize with
serde_json::to_writerinto a pre-sizedVec, or returnJson(value)directly; avoidto_string+ copy - Buffered I/O:
BufReader/BufWriterwith 8-64 KiB buffers on raw sockets and files; flushBufWriter -
TCP_NODELAYfor request/response protocols with small writes - Release profile:
lto = "thin",codegen-units = 1; considermimalloc/jemallocfor allocation-heavy loads - Bounded everything: channels, semaphores, body limits, timeouts; load shed instead of queueing forever
- Avoid
Arc<Mutex<..>>hot spots: shard, use atomics, or an actor; measure contention before changing - Logging: no
info!per item in hot loops; usedebug!/trace!and the filter; JSON formatting costs CPU - Benchmark with realistic concurrency (
oha,wrk,k6) and watch p99, not just throughput
rust-borrow-checker-error-explainer
Explain and fix Rust borrow-checker, ownership, move and lifetime compiler errors: E0382 use of moved value, E0499 cannot borrow as mutable more than once, E0502 also borrowed as immutable, E0505, E0506, E0503, E0507 cannot move out of index/Rc/reference, E0515 return reference to local, E0597 does not live long enough, E0716 temporary value dropped while borrowed, E0373 closure may outlive (spawn needs move), E0521 borrowed data escapes, E0384, E0596, E0594, E0381, E0713, 'lifetime may not live long enough', RefCell 'already borrowed: BorrowMutError' panics. Ranks fixes from restructuring (split borrows, entry API, split_at_mut, get_disjoint_mut, mem::take, Option::take, indices, thread::scope) down to clone and Rc<RefCell>/Arc<Mutex>; never unsafe. Covers NLL limits (get-or-insert), iterator invalidation, closure captures, self-referential structs. Ships std-only Rust triage tools.
rust-cargo-workspace-designer
Design, review and fix Cargo workspaces: when to split a crate (compile time, boundaries, publishing, proc-macros), crates/* vs apps/ + crates/ layouts, virtual manifest vs root package, members/exclude/default-members, resolver 2 vs 3 (MSRV-aware, edition 2024), [workspace.package] and [workspace.dependencies] inheritance, [workspace.lints], root-only profiles (lto, codegen-units), additive features with dep: and weak ?/ syntax, feature unification pitfalls, internal path + version deps, publish = false, Cargo.lock and MSRV policy, xtask, CI with --locked, nextest, rust-cache, sccache, cargo-hack/deny/machete/udeps/semver-checks, and cargo build --timings. Ships a workspace auditor and crate-graph tool.
rust-error-handling-thiserror-anyhow-designer
Design and review Rust error handling with thiserror 2 and anyhow 1: library vs application split, typed error enums with #[from], #[source], #[error(transparent)] and #[non_exhaustive], ErrorKind patterns, context via .context()/.with_context(), bail!/ensure!, source() chains, {:#} and {:?} reports, RUST_BACKTRACE/RUST_LIB_BACKTRACE, Box<dyn Error + Send + Sync>, main -> anyhow::Result or ExitCode, unwrap/expect/panic policy, Option to Result, collecting iterators of Results, axum IntoResponse mapping, retryable errors, tracing, async Send bounds, testing with matches! and downcast_ref, no_std core::error::Error, and snafu, eyre/color-eyre, miette, error-stack.
rust-lifetime-annotation-advisor
Decide when Rust lifetime annotations are needed and which ones: the three elision rules, structs holding references (struct Parser<'a>), owned vs borrowed (String, &str, Cow<'a, str>, Arc<str>), one vs several lifetimes, 'a: 'b bounds, T: 'static misconceptions, Box<dyn Trait + 'a> default object lifetimes, impl Trait + '_ and edition 2024 capture rules with use<..>, variance and PhantomData, HRTB for<'a> Fn(&'a T), GATs and lending iterators, async fn and tokio::spawn 'static, self-referential structs (ouroboros, self_cell, indices, arenas), Rc/Arc to escape lifetime soup. Ships std-only Rust checkers for redundant, missing, unused and over-restrictive lifetimes.
rust-trait-design-reviewer
Review and design Rust traits and generic APIs: generics vs impl Trait vs dyn Trait (static vs dynamic dispatch), dyn compatibility / object safety (generic methods, Self returns, where Self: Sized, error[E0038] not dyn compatible / cannot be made into an object), associated types vs generic parameters, blanket impls, coherence and orphan rule (E0117, E0119, E0210), newtype, extension, sealed and marker traits, supertraits and trait upcasting (1.86), async fn in traits (1.75) and the Send bound problem (trait_variant, async-trait), RPITIT, GATs, From/Into/TryFrom/AsRef/Borrow, std trait checklist (Debug, Clone, PartialEq, Hash, Default, Display, Error, Send/Sync, serde feature), #[must_use], #[non_exhaustive], semver hazards, Deref abuse, builders, typestate, PhantomData, Box<dyn Error + Send + Sync>, Fn traits, mockall.
rust-unsafe-block-auditor
Audit unsafe Rust for soundness: inventory every unsafe block, unsafe fn, unsafe impl/trait, extern block and unsafe attribute, check // SAFETY: comments and # Safety docs, and classify each as sound, needs-justification or likely UB, proposing safe alternatives first. Covers the five unsafe superpowers, aliasing (Stacked/Tree Borrows), invalid values, MaybeUninit vs mem::zeroed/uninitialized, transmute, slice::from_raw_parts, Vec::set_len/from_raw_parts, get_unchecked, CString::as_ptr dangling, unsafe impl Send/Sync, Pin projection, FFI (extern "C", repr(C), C-unwind, Box::into_raw/from_raw), strict provenance, static mut and &raw, edition 2024 (unsafe extern, #[unsafe(no_mangle)], unsafe_op_in_unsafe_fn, env::set_var), Miri, sanitizers, cargo-fuzz, loom, cargo-geiger and clippy safety lints.