Production Debugging
Production bugs differ from local failures: traffic shape, data volume, and environment config hide issues your laptop never sees. Structured logs, distributed traces, gdb/lldb, and tokio-console close that gap without risky restarts.
Recipe
Quick-reference recipe card - copy-paste ready.
use tracing::{info, instrument};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
pub fn init_tracing() {
tracing_subscriber::registry()
.with(EnvFilter::from_default_env())
.with(tracing_subscriber::fmt::layer().json())
.init();
}
#[instrument(skip(pool), fields(trace_id, tenant_id))]
async fn handle_order(
trace_id: String,
tenant_id: String,
pool: &sqlx::PgPool,
) -> Result<(), sqlx::Error> {
tracing::Span::current().record("trace_id", &trace_id);
tracing::Span::current().record("tenant_id", &tenant_id);
info!("order_created", order_id = "ord_123");
sqlx::query("SELECT 1").execute(pool).await?;
Ok(())
}# Live async task inspection (enable tokio_unstable + console subscriber in staging)
RUSTFLAGS="--cfg tokio_unstable" cargo run --features console
# Attach debugger to running binary (needs debug symbols in image)
lldb -p $(pgrep orders-api)
(gdb) thread apply all btWhen to reach for this:
- Error rate spikes with no obvious deploy correlation
- Latency grows but CPU looks idle (blocked tasks or I/O wait)
- Issue appears only for one tenant or region
- Local reproduction fails after matching Rust version and env vars
Working Example
//! prod_debug_helpers.rs - minimal toolkit for correlating prod evidence.
use std::time::Instant;
use serde::Serialize;
use tracing::{info, instrument};
#[derive(Debug, Serialize)]
pub struct DeployContext {
pub git_sha: String,
pub rust_version: String,
pub service: String,
}
pub fn deploy_context() -> DeployContext {
DeployContext {
git_sha: std::env::var("GIT_SHA").unwrap_or_else(|_| "unknown".into()),
rust_version: env!("RUSTC_VERSION").to_string(),
service: std::env::var("SERVICE_NAME").unwrap_or_else(|_| "api".into()),
}
}
#[instrument(skip(f), fields(request_id, trace_id))]
pub async fn with_request_scope<F, T>(
request_id: &str,
trace_id: &str,
f: F,
) -> T
where
F: std::future::Future<Output = T>,
{
let span = tracing::Span::current();
span.record("request_id", request_id);
span.record("trace_id", trace_id);
let ctx = deploy_context();
info!(?ctx, "request_started");
let start = Instant::now();
let result = f.await;
info!(duration_ms = start.elapsed().as_millis() as u64, "request_finished");
result
}# Filter JSON logs by trace_id in prod
kubectl logs deploy/orders-api | jq 'select(.fields.trace_id=="abc-123")'
# Sample stacks without restart (Linux, needs perf permissions)
perf record -p $(pgrep orders-api) -g -- sleep 30
perf scriptWhat this demonstrates:
- Every request carries
trace_id,tenant_id, and deploy SHA in structured logs - Spans wrap async handlers so
sqlxand Axum layers nest correctly - Debugger and profiler attach to the same binary you ship (with symbols)
- Evidence gathering precedes code changes
Deep Dive
How It Works
- Correlate first - Match
trace_idacross API, worker, and database slow-query logs before hypothesizing. - Sandbox repro - Copy anonymized prod payload, feature flags, and
RUST_LOGlevel into a staging pod. - Async-specific tools -
tokio-consoleshows long-polling tasks;tracingshows which span consumed time. - Native debugging -
gdb/lldbfor segfaults, SIGABRT fromabort(), or stuck threads in mixed sync/async code. - Stabilize - Roll back, scale, or rate-limit before deep RCA during active incidents.
Rust-Specific Signals
| Symptom | Likely cause | First check |
|---|---|---|
| Sudden 500s, no deploy | Panic in unwrap() | RUST_BACKTRACE=1 in staging replay |
| Flat CPU, high latency | Blocked runtime thread | tokio-console, tracing span durations |
| OOMKilled pod | Allocator spike or leak | dhat/heaptrack in load test |
| Intermittent wrong data | Data race in unsafe | miri in CI, audit unsafe blocks |
Observability Stack
// Cargo.toml dependencies (typical Axum service)
// tracing, tracing-subscriber, tracing-opentelemetry
// tower-http with TraceLayer for HTTP request IDsWire TraceLayer in Axum so every HTTP request gets a trace_id propagated to downstream spans and log lines.
Gotchas
- Debug symbols stripped in prod image -
lldbshows???. Fix: Ship separate debug symbol package or split-gbuild for on-call. - Logging at
debugin prod - Disk and cost explosion. Fix: DynamicRUST_LOGvia env, defaultinfo. - Blocking in async handler -
std::fs::readon Tokio worker thread stalls all tasks on that thread. Fix:tokio::task::spawn_blocking. - Missing span on
sqlxpool - DB time invisible. Fix: Enablesqlxtracing feature and pool instrumentation. - Hotfix without deploy SHA tag - Cannot correlate regression. Fix: Log
GIT_SHAat startup and in every error report.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
rr record-and-replay | Heisenbugs, rare races | Production host (dev only) |
flamegraph + perf | CPU hot paths | I/O-bound latency (misleading) |
| Core dump analysis | Segfault after panic hook | Tokio logical deadlocks |
| Shadow traffic replay | Repro without user impact | Writes or side effects not idempotent |
FAQs
Can I attach lldb to a release build?
Yes, if debug symbols are available. Many teams ship -C force-frame-pointers=yes and store symbols separately.
When use tokio-console vs tracing?
tracing answers what happened per request; tokio-console answers what tasks are alive and how long polls take.
How reproduce prod-only panic?
Match RUSTFLAGS, feature flags, CPU count (TOKIO_WORKER_THREADS), and input size. Use sanitized prod fixture.
Is RUST_BACKTRACE=full safe in prod?
Briefly in staging or a single canary pod. Full backtraces are verbose and may leak paths; use =1 for incidents.
How debug sqlx connection pool exhaustion?
Enable pool metrics, log acquire span duration, compare with DB max_connections and replica lag.
What about WASM or embedded targets?
Use defmt/probe-rs for embedded; browser WASM relies on console_error_panic_hook and source maps.
Should on-call change code live?
Stabilize with rollback or flags first. Live edits bypass CI and lose audit trail.
How long to gather logs before fix?
15-30 minutes of correlated evidence usually beats guessing. Extend if intermittent (>1h cycle).
Distributed traces across services?
Export tracing spans via OpenTelemetry OTLP; propagate traceparent header in Axum middleware.
When escalate to vendor/cloud support?
When evidence points to hypervisor, managed DB failover, or LB misconfiguration outside your binary.
Related
- Tracing & Instrumentation - span setup
- Common Production Failure Modes - symptom catalog
- Incident Response Playbooks - process
- Debugging Tools - local tooling
- Graceful Shutdown - safe deploy debugging
Stack versions: This page was written for Rust 1.97.0 (edition 2024), Tokio 1.x, Axum 0.8, serde 1.0, sqlx 0.8, clap 4, and Polars 0.46+.