Lesson 37 of 44
Tracing and Observability
Observability is the ability to tell what a running program is doing from the data it emits: logs, traces, and metrics. Plain println! logging breaks down in async code because many tasks interleave on the same threads, so a line of output does not say which request produced it. The tracing crate solves this with spans, which record that a stretch of work belongs to one request, and events, which are log records that carry structured fields and the span they happened in.
| Concept | API |
|---|---|
| Event | info!, warn!, error!, debug!, trace! |
| Span | info_span!, #[instrument], Span::current() |
| Attaching a span to a future | Instrument::instrument, in_current_span |
| Output and filtering | tracing-subscriber: fmt, EnvFilter, Layer |
| Runtime numbers | Handle::metrics (stable methods) |
The crates are tracing and tracing-subscriber (with its env-filter and json features). Add them with cargo add tracing tracing-subscriber --features tracing-subscriber/env-filter,tracing-subscriber/json.
Every example writes to standard output with the timestamp turned off (.without_time()) and colors turned off (.with_ansi(false)), so the output is reproducible. A real service keeps timestamps.
Example 1: An event
An event is one record at a point in time, with a level (TRACE, DEBUG, INFO, WARN, ERROR, from most to least verbose) and optional key-value fields. Events do nothing until a subscriber is installed. The subscriber decides what to do with them. tracing_subscriber::fmt() builds a subscriber that prints lines.
use tracing::{info, warn};
#[tokio::main]
async fn main() {
tracing_subscriber::fmt()
.with_writer(std::io::stdout)
.with_ansi(false)
.without_time()
.init();
info!("server starting");
warn!(port = 8080, "port is a default value");
} INFO master_tokio_runtime_production_tokio_tracing_and_observability_0: server starting
WARN master_tokio_runtime_production_tokio_tracing_and_observability_0: port is a default value port=8080The first argument style, port = 8080, records a field. The last argument is the message. .init() installs the subscriber as the process-wide default. Calling init twice panics, so a program calls it once, early in main.
Example 2: Field formatting
A field value is recorded by its own type when it is a number, a bool, or a string. The prefixes % and ? record the value through Display or Debug. with_target(false) removes the module path that the formatter prints by default.
use tracing::info;
#[derive(Debug)]
struct User {
id: u32,
name: &'static str,
}
#[tokio::main]
async fn main() {
tracing_subscriber::fmt()
.with_writer(std::io::stdout)
.with_ansi(false)
.without_time()
.with_target(false)
.init();
let user = User { id: 7, name: "asha" };
let path = std::path::Path::new("/tmp/report.txt");
info!(id = user.id, name = user.name, "plain values");
info!(user = ?user, "debug format");
info!(path = %path.display(), "display format");
let retries = 3;
info!(retries, ok = true, "shorthand: the variable name becomes the field name");
} INFO plain values id=7 name="asha"
INFO debug format user=User { id: 7, name: "asha" }
INFO display format path=/tmp/report.txt
INFO shorthand: the variable name becomes the field name retries=3 ok=trueFields stay separate from the message, so a log system can filter on id = 7 without parsing text.
Example 3: Levels and a maximum level
The subscriber drops events above a maximum verbosity. with_max_level sets it. A macro whose level is filtered out does not evaluate its field expressions.
use tracing::{Level, debug, error, info, trace, warn};
#[tokio::main]
async fn main() {
tracing_subscriber::fmt()
.with_writer(std::io::stdout)
.with_ansi(false)
.without_time()
.with_target(false)
.with_max_level(Level::INFO)
.init();
trace!("trace: hidden");
debug!("debug: hidden");
info!("info: shown");
warn!("warn: shown");
error!("error: shown");
} INFO info: shown
WARN warn: shown
ERROR error: shownRuntime filtering by a string such as RUST_LOG=warn uses EnvFilter (Example 14).
Example 4: A span and the enter guard
A span is a period of time with a name and fields. Events created while a span is entered are reported inside it. span.enter() returns a guard; the span stays entered until the guard is dropped.
use tracing::{info, info_span};
fn handle(request_id: u32) {
let span = info_span!("request", id = request_id);
let _guard = span.enter();
info!("started");
info!("finished");
}
#[tokio::main]
async fn main() {
tracing_subscriber::fmt()
.with_writer(std::io::stdout)
.with_ansi(false)
.without_time()
.with_target(false)
.init();
info!("before");
handle(1);
handle(2);
info!("after");
} INFO before
INFO request{id=1}: started
INFO request{id=1}: finished
INFO request{id=2}: started
INFO request{id=2}: finished
INFO afterBoth events inside handle print with the request{id=1}: prefix, so they can be tied to the request even when other requests log between them.
Example 5: Why enter() must not be held across .await
An entered span is a per-thread setting. If a future holds the guard while it is suspended at an .await, the span stays entered on that thread while the executor runs other futures. Events from those futures are then reported inside the wrong span. The example runs two futures in one task with tokio::join! (explained in join!, try_join!, and Running Work Concurrently) so the order is fixed.
use tracing::{info, info_span};
async fn slow_request() {
let span = info_span!("request", id = 1);
let _guard = span.enter(); // held across the await below: wrong
info!("request waiting");
tokio::task::yield_now().await;
info!("request done");
}
async fn unrelated_work() {
info!("unrelated work");
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
tracing_subscriber::fmt()
.with_writer(std::io::stdout)
.with_ansi(false)
.without_time()
.with_target(false)
.init();
tokio::join!(slow_request(), unrelated_work());
} INFO request{id=1}: request waiting
INFO request{id=1}: unrelated work
INFO request{id=1}: request doneThe line "unrelated work" is printed inside request{id=1} although it has nothing to do with that request. The fix is to attach the span to the future with instrument, which enters the span only while that future is being polled (Example 7).
Example 6: #[instrument]
The #[instrument] attribute wraps a function in a span named after the function, with the arguments recorded as fields. On an async fn it attaches the span to the returned future correctly, so no guard is held across an .await.
use std::time::Duration;
use tracing::{info, instrument};
#[instrument]
async fn fetch_user(id: u32, region: &str) -> String {
info!("looking up user");
tokio::time::sleep(Duration::from_millis(10)).await;
info!("lookup finished");
format!("user-{id}")
}
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
tracing_subscriber::fmt()
.with_writer(std::io::stdout)
.with_ansi(false)
.without_time()
.with_target(false)
.init();
let name = fetch_user(42, "eu").await;
info!(%name, "done");
} INFO fetch_user{id=42 region="eu"}: looking up user
INFO fetch_user{id=42 region="eu"}: lookup finished
INFO done name=user-42The arguments must implement Debug, because the attribute records them with ?. The span is created when the function is called, entered each time the future is polled, and closed when the future completes or is dropped.
Example 7: #[instrument] options
Attribute options control the span. skip(...) leaves an argument out (for large values or secrets), fields(...) adds fields, name = "..." renames the span, level = "..." sets its level, ret logs the return value, and err logs an Err return.
use tracing::instrument;
#[instrument(name = "login", skip(password), fields(attempt = 1), ret, err)]
async fn login(user: &str, password: &str) -> Result<u32, String> {
if password == "secret" { Ok(1001) } else { Err(format!("bad password for {user}")) }
}
#[tokio::main]
async fn main() {
tracing_subscriber::fmt()
.with_writer(std::io::stdout)
.with_ansi(false)
.without_time()
.with_target(false)
.init();
let _ = login("asha", "secret").await;
let _ = login("ben", "guess").await;
} INFO login{user="asha" attempt=1}: return=1001
ERROR login{user="ben" attempt=1}: error=bad password for benThe password never appears in the output because of skip. ret emits an event at the span's level with the returned value, and err emits an ERROR event with the error. When ret and err are combined on a Result, the Ok value is reported by ret and the Err value by err.
Example 8: Instrumenting a future with .instrument
Instrument::instrument(span) attaches a span to any future. The span is entered on every poll of the future and exited when the poll returns. This is the correct form of Example 5.
use tracing::{Instrument, info, info_span};
async fn slow_request() {
info!("request waiting");
tokio::task::yield_now().await;
info!("request done");
}
async fn unrelated_work() {
info!("unrelated work");
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
tracing_subscriber::fmt()
.with_writer(std::io::stdout)
.with_ansi(false)
.without_time()
.with_target(false)
.init();
tokio::join!(
slow_request().instrument(info_span!("request", id = 1)),
unrelated_work(),
);
} INFO request{id=1}: request waiting
INFO unrelated work
INFO request{id=1}: request doneNow "unrelated work" has no span, and both request events are inside request{id=1} even though the request future was suspended between them.
Example 9: Seeing polls with span events
fmt can print an event when a span is created, entered, exited, or closed. FmtSpan::ENTER | FmtSpan::EXIT shows every poll of an instrumented future, which makes the suspension points visible. (FmtSpan::CLOSE also prints busy and idle durations, which change on every run, so it is not used in these examples.)
use tracing::{Instrument, info_span};
use tracing_subscriber::fmt::format::FmtSpan;
#[tokio::main(flavor = "current_thread")]
async fn main() {
tracing_subscriber::fmt()
.with_writer(std::io::stdout)
.with_ansi(false)
.without_time()
.with_target(false)
.with_span_events(FmtSpan::NEW | FmtSpan::ENTER | FmtSpan::EXIT)
.init();
async {
tokio::task::yield_now().await;
tokio::task::yield_now().await;
}
.instrument(info_span!("job"))
.await;
} INFO job: new
INFO job: enter
INFO job: exit
INFO job: enter
INFO job: exit
INFO job: enter
INFO job: exit
INFO job: enter
INFO job: exitThe future is polled three times: once until the first yield_now, once until the second, and once to finish. Each poll produces an enter and an exit line.
Example 10: Spans and tokio::spawn
A spawned task is a separate future. It does not inherit the span of the code that called spawn. To keep the relationship, instrument the spawned future with a span, either the current one with in_current_span() or a new child span.
use tracing::{Instrument, info, info_span};
#[tokio::main(flavor = "current_thread")]
async fn main() {
tracing_subscriber::fmt()
.with_writer(std::io::stdout)
.with_ansi(false)
.without_time()
.with_target(false)
.init();
async {
let lost = tokio::spawn(async {
info!("spawned without a span");
});
lost.await.unwrap();
let kept = tokio::spawn(
async {
info!("spawned with in_current_span");
}
.in_current_span(),
);
kept.await.unwrap();
let child = tokio::spawn(
async {
info!("spawned with a child span");
}
.instrument(info_span!("worker", n = 1)),
);
child.await.unwrap();
}
.instrument(info_span!("request", id = 9))
.await;
} INFO spawned without a span
INFO request{id=9}: spawned with in_current_span
INFO request{id=9}:worker{n=1}: spawned with a child spanThe child span's line shows both spans, request{id=9}:worker{n=1}:, because a span created inside a span records the outer one as its parent. in_current_span() captures Span::current() at the moment it is called, which is inside request.
Example 11: Recording a field after the span exists
Some values are unknown when the span starts, such as a status code. Declare the field as tracing::field::Empty and fill it in later with Span::record.
use tracing::{Instrument, Span, field, info, info_span};
async fn handle(path: &str) -> u16 {
let status = if path == "/" { 200 } else { 404 };
Span::current().record("status", status);
info!("responding");
status
}
#[tokio::main]
async fn main() {
tracing_subscriber::fmt()
.with_writer(std::io::stdout)
.with_ansi(false)
.without_time()
.with_target(false)
.init();
for path in ["/", "/missing"] {
let span = info_span!("http", %path, status = field::Empty);
handle(path).instrument(span).await;
}
} INFO http{path=/ status=200}: responding
INFO http{path=/missing status=404}: respondingThe value is recorded before the responding event, so the event line shows it. A field that is never recorded is omitted from the output.
Example 12: Capturing output in a buffer
fmt writes through a MakeWriter, and any Fn() -> impl io::Write closure works as one. A shared buffer collects the log text so a program, or a test, can inspect it. tracing::subscriber::with_default installs a subscriber for the duration of a closure on the current thread, which suits a current_thread runtime.
use std::io;
use std::sync::{Arc, Mutex};
use tracing::{Instrument, info, info_span};
use tracing_subscriber::fmt::MakeWriter;
#[derive(Clone, Default)]
struct Buffer(Arc<Mutex<Vec<u8>>>);
impl io::Write for Buffer {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(bytes);
Ok(bytes.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
impl<'a> MakeWriter<'a> for Buffer {
type Writer = Buffer;
fn make_writer(&'a self) -> Buffer {
self.clone()
}
}
fn main() {
let buffer = Buffer::default();
let subscriber = tracing_subscriber::fmt()
.with_writer(buffer.clone())
.with_ansi(false)
.without_time()
.with_target(false)
.finish();
let runtime = tokio::runtime::Builder::new_current_thread().build().unwrap();
tracing::subscriber::with_default(subscriber, || {
runtime.block_on(async {
info!("checking out");
}
.instrument(info_span!("order", id = 5)));
});
let text = String::from_utf8(buffer.0.lock().unwrap().clone()).unwrap();
println!("captured {} line(s)", text.lines().count());
println!("contains the span: {}", text.contains("order{id=5}"));
print!("{text}");
}captured 1 line(s)
contains the span: true
INFO order{id=5}: checking outLesson Testing Async Code uses this pattern to assert on log output. The captured text is thread-local to the with_default closure only when the runtime runs on the same thread, which is why the example builds a current_thread runtime.
Example 13: A custom Layer
A layer is a composable piece of a subscriber. tracing_subscriber::Layer has hooks such as on_event and on_new_span. A layer can count, forward to a metrics system, or export spans. The example counts events per level.
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use tracing::{Event, Level, Subscriber, error, info, warn};
use tracing_subscriber::layer::{Context, SubscriberExt};
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::Layer;
#[derive(Default)]
struct Counts {
info: AtomicUsize,
warn: AtomicUsize,
error: AtomicUsize,
}
struct CountLayer(Arc<Counts>);
impl<S: Subscriber> Layer<S> for CountLayer {
fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
let counter = match *event.metadata().level() {
Level::ERROR => &self.0.error,
Level::WARN => &self.0.warn,
_ => &self.0.info,
};
counter.fetch_add(1, Ordering::Relaxed);
}
}
#[tokio::main]
async fn main() {
let counts = Arc::new(Counts::default());
tracing_subscriber::registry().with(CountLayer(counts.clone())).init();
info!("a");
info!("b");
warn!("c");
error!("d");
println!("info={} warn={} error={}",
counts.info.load(Ordering::Relaxed),
counts.warn.load(Ordering::Relaxed),
counts.error.load(Ordering::Relaxed));
}info=2 warn=1 error=1registry() is a subscriber that stores span data and prints nothing. .with(layer) adds behavior to it. A real service stacks an output layer, a filter, and an exporter the same way.
Example 14: Filtering by target with EnvFilter
EnvFilter reads directives such as warn,billing=debug: a default level, plus per-target overrides. A target is a string on each event, the module path by default, and can be set with target: "...". EnvFilter::from_default_env() reads the RUST_LOG variable; the example builds the filter from a string so its behavior is fixed.
use tracing::{debug, info, warn};
use tracing_subscriber::EnvFilter;
#[tokio::main]
async fn main() {
tracing_subscriber::fmt()
.with_writer(std::io::stdout)
.with_ansi(false)
.without_time()
.with_env_filter(EnvFilter::new("warn,billing=debug"))
.init();
debug!(target: "billing", "invoice computed");
debug!(target: "search", "query parsed");
info!(target: "search", "query finished");
warn!(target: "search", "index is stale");
}DEBUG billing: invoice computed
WARN search: index is staleOnly the billing target passes at debug. Everything else must be warn or above. In production the same string is supplied through RUST_LOG=warn,billing=debug, and changing verbosity needs no rebuild.
Example 15: JSON output
Log pipelines usually want one JSON object per line. .json() switches the formatter. Spans that were entered are included under span and spans.
use tracing::{Instrument, info, info_span};
#[tokio::main(flavor = "current_thread")]
async fn main() {
tracing_subscriber::fmt()
.json()
.with_writer(std::io::stdout)
.without_time()
.with_target(false)
.init();
async {
info!(items = 3, "batch accepted");
}
.instrument(info_span!("request", id = 12))
.await;
}{"level":"INFO","fields":{"message":"batch accepted","items":3},"span":{"id":12,"name":"request"},"spans":[{"id":12,"name":"request"}]}Each line is a complete JSON document, so tools can index fields.items and span.id without regular expressions.
Example 16: Runtime metrics
Handle::metrics() returns a RuntimeMetrics value with counters about the runtime. Three methods are stable and need no special build flag: num_workers (worker threads, always 1 on current_thread), num_alive_tasks (tasks spawned and not yet finished), and global_queue_depth (tasks waiting in the global queue). On 64-bit targets worker_total_busy_duration, worker_park_count, and worker_park_unpark_count are stable as well.
use tokio::runtime::Handle;
use tokio::sync::oneshot;
#[tokio::main(flavor = "current_thread")]
async fn main() {
let metrics = Handle::current().metrics();
println!("workers: {}", metrics.num_workers());
println!("alive before spawn: {}", metrics.num_alive_tasks());
let mut gates = Vec::new();
let mut handles = Vec::new();
for _ in 0..3 {
let (open, wait) = oneshot::channel::<()>();
gates.push(open);
handles.push(tokio::spawn(async move {
let _ = wait.await;
}));
}
tokio::task::yield_now().await;
println!("alive with 3 blocked tasks: {}", metrics.num_alive_tasks());
for gate in gates {
let _ = gate.send(());
}
for handle in handles {
handle.await.unwrap();
}
println!("alive after all finished: {}", metrics.num_alive_tasks());
}workers: 1
alive before spawn: 0
alive with 3 blocked tasks: 3
alive after all finished: 0A count of alive tasks that keeps rising while the load stays flat is a leak: tasks that never finish, often because they wait on a channel or lock that is never released. On the multi-thread runtime the count is not strongly consistent, so treat it as a trend and not as an exact value.
Many more metrics (poll counts, steal counts, queue depths per worker, poll-time histograms, blocking-pool sizes) exist behind the tokio_unstable compiler flag. That flag opts in to APIs that may change between minor versions, and it is set with RUSTFLAGS="--cfg tokio_unstable" or in .cargo/config.toml. These methods are named here without code because they are not part of the stable API.
Example 17: Finding stuck tasks
A task is stuck when it waits for something that never happens. Two things make it findable: a count of tasks in flight, and a record of what each task is waiting for. The example keeps a shared table of stages and a drop guard that removes an entry when its task ends. A watchdog task checks the table after a deadline and reports what is left. The clock is paused, so the deadline passes without real waiting.
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::sync::oneshot;
use tokio::time::sleep;
type Stages = Arc<Mutex<BTreeMap<u32, &'static str>>>;
struct InFlight {
id: u32,
stages: Stages,
}
impl InFlight {
fn start(id: u32, stages: &Stages) -> InFlight {
stages.lock().unwrap().insert(id, "started");
InFlight { id, stages: stages.clone() }
}
fn stage(&self, name: &'static str) {
self.stages.lock().unwrap().insert(self.id, name);
}
}
impl Drop for InFlight {
fn drop(&mut self) {
self.stages.lock().unwrap().remove(&self.id);
}
}
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
let stages: Stages = Arc::default();
let (never_sent, never_received) = oneshot::channel::<u32>();
for id in 1..=3 {
let stages = stages.clone();
tokio::spawn(async move {
let tracker = InFlight::start(id, &stages);
tracker.stage("waiting for the database");
sleep(Duration::from_millis(100)).await;
tracker.stage("done");
});
}
let stuck_stages = stages.clone();
tokio::spawn(async move {
let tracker = InFlight::start(99, &stuck_stages);
tracker.stage("waiting for a reply that is never sent");
let _ = never_received.await;
});
sleep(Duration::from_secs(5)).await; // the watchdog deadline
let remaining = stages.lock().unwrap().clone();
println!("tasks still in flight after 5s: {}", remaining.len());
for (id, stage) in remaining {
println!(" task {id}: {stage}");
}
drop(never_sent);
}tasks still in flight after 5s: 1
task 99: waiting for a reply that is never sentTasks 1 to 3 finished and removed their entries. Task 99 is still there, with the stage it recorded before it blocked. In a real service the same information comes from an instrumented span per task and from num_alive_tasks. With tokio_unstable, a task dump (an unstable feature that prints the stack of every task) and tokio-console show it without hand-written bookkeeping.
Example 18: A latency histogram
A single average hides slow requests. A histogram counts how many samples fall into each range, and a percentile such as p99 is the value below which 99 percent of samples fall. Metrics systems store histograms because bucket counts from many processes can be added together, and averages cannot.
The example times operations with the paused clock, where sleeps take exactly their nominal length, and prints only bucket counts and derived percentiles.
use std::time::Duration;
use tokio::time::{Instant, sleep};
struct Histogram {
bounds_ms: Vec<u64>, // upper bound of each bucket; the last bucket is unbounded
counts: Vec<u64>,
}
impl Histogram {
fn new(bounds_ms: &[u64]) -> Histogram {
Histogram { bounds_ms: bounds_ms.to_vec(), counts: vec![0; bounds_ms.len() + 1] }
}
fn record(&mut self, elapsed: Duration) {
let ms = elapsed.as_millis() as u64;
let index = self.bounds_ms.iter().position(|&bound| ms <= bound).unwrap_or(self.bounds_ms.len());
self.counts[index] += 1;
}
/// Upper bound of the bucket that contains the given percentile.
fn percentile(&self, p: f64) -> String {
let total: u64 = self.counts.iter().sum();
let target = (total as f64 * p).ceil() as u64;
let mut seen = 0;
for (index, count) in self.counts.iter().enumerate() {
seen += count;
if seen >= target {
return match self.bounds_ms.get(index) {
Some(bound) => format!("<= {bound}ms"),
None => "above the last bound".to_string(),
};
}
}
unreachable!()
}
}
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
let mut histogram = Histogram::new(&[10, 50, 100, 500]);
let work_ms = [3, 4, 4, 5, 6, 8, 9, 12, 15, 20, 30, 45, 70, 90, 400, 900];
for ms in work_ms {
let start = Instant::now();
sleep(Duration::from_millis(ms)).await;
histogram.record(start.elapsed());
}
let labels = ["<=10ms", "<=50ms", "<=100ms", "<=500ms", ">500ms"];
for (label, count) in labels.iter().zip(&histogram.counts) {
println!("{label:<8} {count}");
}
println!("p50: {}", histogram.percentile(0.50));
println!("p95: {}", histogram.percentile(0.95));
println!("p99: {}", histogram.percentile(0.99));
}<=10ms 7
<=50ms 5
<=100ms 2
<=500ms 1
>500ms 1
p50: <= 50ms
p95: above the last bound
p99: above the last boundSixteen samples give a median in the first bucket and a p99 above 500 ms. The mean of these samples is about 100 ms, which describes none of the requests. Percentiles expose the slow tail, and that tail is what users of a service notice.
Example 19: What to measure in a Tokio service
Signals for a service fall into a few groups. Each row names the signal and what a change in it indicates.
| Signal | Source | What a change means |
|---|---|---|
| Requests per second, errors per second | counters in handlers | load and failure rate |
| Latency p50, p95, p99 | histograms around handlers | slow tail, overloaded dependency |
num_alive_tasks |
Handle::metrics |
rising with flat load: leaked or stuck tasks |
Queued messages in an mpsc channel (max_capacity() - capacity() on the sender) |
gauge around a bounded channel | consumer slower than producer |
Permits available in a Semaphore |
available_permits |
saturation of a limited resource |
| Worker busy time, poll time | unstable metrics, tokio-console |
tasks that block or run long between awaits |
| Spans per request | tracing |
where a single request spent its time |
tokio-console is a terminal tool that connects to a running process through the console-subscriber crate. It lists tasks with their poll counts, busy and idle time, and wakers. It requires building with tokio_unstable and installing the console-subscriber layer, so it is normally used in development and staging.
Interview points
tracingseparates instrumentation (macros in library code) from collection (a subscriber installed by the binary). Libraries call macros and never install a subscriber.- An event is a point in time; a span is a period with a name and fields. Events record which span was active.
- A span guard from
enter()must not be held across.await: the span would stay entered on the thread while other tasks run. Use#[instrument]or.instrument(span), which enter the span per poll. - Spawned tasks do not inherit the caller's span. Use
.in_current_span()or.instrument(child_span)on the spawned future. #[instrument]records arguments withDebug;skipremoves large or secret arguments,retanderrlog results.EnvFiltersets verbosity per target at run time throughRUST_LOG.- Stable runtime metrics are few (
num_workers,num_alive_tasks,global_queue_depth, and busy and park counts on 64-bit). Detailed metrics needtokio_unstable. - Rising
num_alive_tasksat flat load points to leaked or stuck tasks.tokio-consoleshows which tasks and why. - Report percentiles from histograms. Means hide the tail, and averages cannot be combined across instances, while bucket counts can.
Summary
- Install a subscriber once in
main;fmt()prints text or JSON,registry()plus layers composes behavior. - Use
.without_time()and.with_ansi(false)for reproducible output in examples and tests. - Attach spans to futures with
#[instrument]or.instrument(span), and to spawned tasks with.in_current_span()or a child span. - Capture output into a shared buffer through a
MakeWriterto assert on logs. Handle::metrics()gives stable counters for workers, alive tasks, and the global queue.- Find stuck tasks with a per-task record of the current stage and a watchdog, or with
tokio-console. - Latency belongs in histograms with percentile reporting.
