How does tracing::dispatcher::set_global_default affect nested span contexts across async boundaries?
tracing::dispatcher::set_global_default establishes a global dispatcher that receives all span and event data for the entire process, and this dispatcher is automatically propagated across thread and async task boundaries through the tracing crate's context tracking. When a span is created in one async task and another task enters that span's context, the global dispatcher ensures both operations are correlated: the span ID and metadata are preserved, allowing the downstream subscriber to reconstruct the full span hierarchy regardless of where the execution context moves. This propagation works because tracing stores the current span context in thread-local storage, and async runtimes that support tracing automatically transfer this context when tasks migrate between threads.
Basic Global Dispatcher Setup
use tracing::{info, span, Level};
use tracing_subscriber::{layer::SubscriberExt, Registry};
fn main() {
// Create a subscriber with layers
let subscriber = Registry::default()
.with(tracing_subscriber::fmt::layer());
// Set as global default
tracing::dispatcher::set_global_default(tracing::Dispatch::new(subscriber))
.expect("Failed to set global default");
// Now all spans and events go through this dispatcher
let span = span!(Level::INFO, "main_operation");
let _enter = span.enter();
info!("This event is dispatched to the global subscriber");
// The dispatcher is now the process-wide default
// No need to pass it around explicitly
}The global dispatcher handles all tracing calls after set_global_default is invoked.
The Dispatch System
use tracing::{dispatcher, info, span, Level};
fn main() {
// Before setting global default: no dispatcher
// Events are simply dropped
// Create and set global default
let subscriber = tracing_subscriber::fmt()
.with_target(false)
.finish();
dispatcher::set_global_default(dispatcher::Dispatch::new(subscriber))
.unwrap();
// The Dispatch type wraps any Subscriber
// It's Send + Sync, so it can be shared across threads
// How it works:
// 1. set_global_default stores the Dispatch in a static
// 2. All macros (info!, span!, etc.) access this static
// 3. The dispatch forwards to the underlying subscriber
let span = span!(Level::INFO, "operation");
let _guard = span.enter();
info!("Dispatched through global");
// The span context is stored in thread-local storage
// Current span ID and metadata are tracked per-thread
}Dispatch wraps a Subscriber and makes it globally accessible.
Nested Span Contexts
use tracing::{info, span, Level};
fn main() {
tracing_subscriber::fmt()
.with_target(false)
.with_thread_ids(true)
.init();
// Create nested spans
let parent = span!(Level::INFO, "parent");
let _parent_guard = parent.enter();
info!("Inside parent span");
{
// Child span - nested within parent
let child = span!(Level::INFO, "child");
let _child_guard = child.enter();
info!("Inside child span");
// Context: parent -> child
// Another level of nesting
let grandchild = span!(Level::INFO, "grandchild");
let _gc_guard = grandchild.enter();
info!("Inside grandchild span");
// Context: parent -> child -> grandchild
}
info!("Back in parent span");
// Context: parent
}Nested spans form a hierarchy that the dispatcher tracks.
Thread-Local Context
use tracing::{info, span, Level};
use std::thread;
fn main() {
tracing_subscriber::fmt()
.with_thread_ids(true)
.with_target(false)
.init();
// Each thread has its own current span context
let parent = span!(Level::INFO, "parent");
thread::spawn(move || {
let _guard = parent.enter();
info!("In spawned thread, inside parent span");
// The span context was NOT automatically transferred
// We had to manually enter the span
});
// This thread has no current span
info!("Main thread, no span context");
thread::spawn(|| {
// No span context from main thread
info!("Another spawned thread, no parent context");
});
std::thread::sleep(std::time::Duration::from_millis(100));
}Each thread has independent span context; spans don't automatically cross threads.
Async Boundaries and Context Propagation
use tracing::{info, span, Level};
#[tokio::main]
async fn main() {
tracing_subscriber::fmt()
.with_thread_ids(true)
.with_target(false)
.init();
let parent = span!(Level::INFO, "parent");
let _guard = parent.enter();
// Spawn an async task
let handle = tokio::spawn(async {
// Tokio's tracing support propagates the current span context
info!("Inside spawned task");
// Context: parent (propagated from spawn site)
let child = span!(Level::INFO, "child_in_task");
let _child_guard = child.enter();
info!("Inside child span in task");
// Context: parent -> child_in_task
});
handle.await.unwrap();
info!("Back in main");
// Context: parent
}tokio propagates tracing context across async boundaries automatically.
Context Across Async Moves
use tracing::{info, span, Level};
#[tokio::main]
async fn main() {
tracing_subscriber::fmt()
.with_thread_ids(true)
.with_target(false)
.init();
let root = span!(Level::INFO, "root");
let _guard = root.enter();
// Context: root
info!("Starting root span");
// First async block
let task1 = async {
info!("In task1");
// Context: root (propagated)
let task1_child = span!(Level::INFO, "task1_child");
let _guard = task1_child.enter();
info!("In task1 child span");
// Context: root -> task1_child
};
// Second async block
let task2 = async {
info!("In task2");
// Context: root (propagated)
let task2_child = span!(Level::INFO, "task2_child");
let _guard = task2_child.enter();
info!("In task2 child span");
// Context: root -> task2_child
};
// Both tasks see the root span context
tokio::join!(task1, task2);
info!("After tasks");
// Context: root
}Both tasks inherit the root span; each can create its own nested spans.
The dispatcher::get_default Function
use tracing::{dispatcher, Dispatch, info, span, Level};
fn main() {
tracing_subscriber::fmt().init();
// Get the current dispatcher
dispatcher::get_default(|dispatch: &Dispatch| {
// Access the global dispatcher
println!("Got dispatcher: {:?}", dispatch);
});
// The dispatcher is accessible from anywhere
// No need to pass it around
let span = span!(Level::INFO, "operation");
let _guard = span.enter();
// Within a span context, can also access current span
dispatcher::get_default(|dispatch| {
// The dispatch has access to current span info
// through its subscriber
});
info!("Event uses global dispatcher");
}get_default provides access to the global dispatcher from anywhere.
WithGlobal and Scope Propagation
use tracing::{dispatcher, Dispatch, info, span, Level};
fn main() {
let subscriber = tracing_subscriber::fmt()
.with_target(false)
.finish();
let dispatch = Dispatch::new(subscriber);
// Use with_global for temporary dispatcher
// (would fail if global already set)
// Instead, use scoped dispatch for temporary override
let alt_subscriber = tracing_subscriber::fmt()
.with_target(true)
.with_thread_names(true)
.finish();
let alt_dispatch = Dispatch::new(alt_subscriber);
// Set global default first
dispatcher::set_global_default(
Dispatch::new(tracing_subscriber::fmt().finish())
).ok(); // May already be set
// For different dispatchers per scope, use tracing-futures
// or manually manage contexts
}set_global_default can only be called once; use alternative approaches for multiple dispatchers.
Multiple Subscribers with Layers
use tracing::{info, span, Level};
use tracing_subscriber::{layer::SubscriberExt, Registry, Layer};
fn main() {
// Instead of multiple dispatchers, use layered subscribers
// Layer 1: Console output
let console_layer = tracing_subscriber::fmt::layer()
.with_target(false);
// Layer 2: File output (hypothetical)
// let file_layer = ...;
// Combine layers
let subscriber = Registry::default()
.with(console_layer);
// .with(file_layer);
tracing::dispatcher::set_global_default(tracing::Dispatch::new(subscriber))
.unwrap();
// All spans/events go to both layers
let span = span!(Level::INFO, "operation");
let _guard = span.enter();
info!("Event goes to all layers");
}Use layers for multiple outputs instead of multiple dispatchers.
Async Runtime Integration
use tracing::{info, span, Level, Instrument};
#[tokio::main]
async fn main() {
tracing_subscriber::fmt()
.with_thread_ids(true)
.with_target(false)
.init();
// Create a span to instrument an async task
let span = span!(Level::INFO, "api_request",
endpoint = "/users",
request_id = "abc123"
);
// Instrument wraps the future to enter/exit span on poll
async fn handle_request() {
info!("Processing request");
// Simulate async work
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
info!("Request processed");
}
// Instrument attaches span context to the future
handle_request()
.instrument(span)
.await;
// This ensures:
// 1. Span is entered when future is polled
// 2. Context propagates across await points
// 3. Works even if task moves between threads
}Instrument attaches span context to async futures for proper propagation.
Nested Spans Across Await Points
use tracing::{info, span, Level, Instrument};
#[tokio::main]
async fn main() {
tracing_subscriber::fmt()
.with_thread_ids(true)
.with_target(false)
.init();
let parent = span!(Level::INFO, "parent", id = 1);
async {
info!("Inside parent, before await");
// Context: parent
// Nested child span
let child = span!(Level::INFO, "child", id = 2);
let _guard = child.enter();
info!("Inside child, before await");
// Context: parent -> child
tokio::time::sleep(std::time::Duration::from_millis(1)).await;
// After await, context is restored
info!("Inside child, after await");
// Context: parent -> child (still valid!)
// This works because:
// 1. Tokio's tracing support saves context across await
// 2. Global dispatcher is Send + Sync
// 3. Span IDs are globally unique and tracked
}
.instrument(parent)
.await;
}Span context persists across .await points within instrumented futures.
Task Migration Between Threads
use tracing::{info, span, Level, Instrument};
use tokio::runtime::Runtime;
fn main() {
tracing_subscriber::fmt()
.with_thread_ids(true)
.with_target(false)
.init();
let rt = Runtime::new().unwrap();
rt.block_on(async {
let span = span!(Level::INFO, "migrating_task");
async {
info!("Task started on thread");
// Yield to allow migration to another thread
tokio::task::yield_now().await;
// May now be on a different thread!
info!("Task continued after yield");
// Span context is maintained even if thread changed
// The tracing context is stored per-task, not per-thread
}
.instrument(span)
.await;
});
}Context propagates even when async tasks migrate between threads.
Span Context Storage
use tracing::{info, span, Level};
fn main() {
tracing_subscriber::fmt()
.with_thread_ids(true)
.with_target(false)
.init();
// How tracing stores context:
// 1. Global Dispatcher (static)
// - Set by set_global_default
// - Shared across all threads
// - Send + Sync
// 2. Thread-local Current Span (per-thread)
// - Each thread has its own "current span" stack
// - Stored as a stack of span IDs
// - span.enter() pushes, drop pops
// 3. Span Registry (in subscriber)
// - Stores span metadata
// - Spans are reference-counted
// - Allows looking up span by ID
let parent = span!(Level::INFO, "parent");
let _guard = parent.enter();
// Current thread now has parent in its context stack
// When we create child spans:
let child = span!(Level::INFO, "child");
// child knows its parent is parent (via context)
// The span ID links them in the hierarchy
let _child_guard = child.enter();
// Context stack: parent -> child
info!("Event");
// Event carries the full context: parent -> child
}Context is stored per-thread but spans are globally registered.
Cross-Thread Span Reference
use tracing::{info, span, Level, Instrument};
use std::sync::Arc;
#[tokio::main]
async fn main() {
tracing_subscriber::fmt()
.with_thread_ids(true)
.with_target(false)
.init();
// Create a span that will be shared across tasks
let shared_span = span!(Level::INFO, "shared_operation", id = "abc");
// Clone span for use in multiple tasks
let span1 = shared_span.clone();
let span2 = shared_span.clone();
// Task 1 enters the span
let task1 = async move {
let _guard = span1.enter();
info!("Task 1 in shared span");
};
// Task 2 enters the same span
let task2 = async move {
let _guard = span2.enter();
info!("Task 2 in shared span");
};
// Both tasks reference the same span
// But they run independently with their own guards
tokio::join!(task1, task2);
// The span is the same span ID, stored in global registry
// Each thread/task has its own entry in its context stack
}Spans can be cloned and shared; each context has its own entry on the stack.
Instrumenting Functions
use tracing::{info, span, Level, Instrument};
// Sync function - use span.enter()
fn sync_work() {
let span = span!(Level::INFO, "sync_work");
let _guard = span.enter();
info!("Doing sync work");
}
// Async function - use .instrument()
async fn async_work() {
info!("Doing async work (already instrumented)");
}
#[tokio::main]
async fn main() {
tracing_subscriber::fmt().init();
// For sync functions: manually enter span
sync_work();
// For async functions: use Instrument trait
let span = span!(Level::INFO, "async_operation");
async_work()
.instrument(span)
.await;
// Or use #[instrument] attribute
instrumented_function().await;
}
#[tracing::instrument]
async fn instrumented_function() {
info!("Automatically instrumented");
// Span name comes from function name
}Use #[instrument] for automatic instrumentation, or .instrument() for manual.
Span Relationships in Output
use tracing::{info, span, Level};
fn main() {
// JSON format shows span hierarchy
tracing_subscriber::fmt()
.json()
.with_target(false)
.with_thread_ids(false)
.init();
let request = span!(Level::INFO, "http_request",
method = "GET",
path = "/api/users"
);
let _req_guard = request.enter();
let db_query = span!(Level::INFO, "db_query",
table = "users",
operation = "select"
);
let _db_guard = db_query.enter();
info!("Executing query");
// JSON output includes:
// {
// "timestamp": "...",
// "level": "INFO",
// "message": "Executing query",
// "spans": [
// {"name": "http_request", "method": "GET", "path": "/api/users"},
// {"name": "db_query", "table": "users", "operation": "select"}
// ]
// }
}JSON output format shows the span hierarchy with all parent spans.
Synthesis
Global dispatcher role:
set_global_defaultestablishes a process-wide subscriber- All spans and events route through this dispatcher
- Thread-safe (
Send + Sync) for concurrent access
Context propagation:
- Thread-local storage tracks current span per thread
- Async runtimes transfer context across
.awaitpoints - Task migration between threads preserves context
- Spans are reference-counted in a global registry
Nested span mechanics:
span.enter()pushes span onto thread-local stackdrop(guard)pops span from stack- Child spans reference parent via context
- Full hierarchy reconstructed from span IDs
Async boundaries:
- Context stored per-task, not per-thread
.instrument(span)attaches context to futures- Tokio's tracing integration propagates context
- Works across thread boundaries in multi-threaded runtime
Key components:
- Global
DispatchwrapsSubscriber - Thread-local context stack
- Global span registry (reference-counted spans)
- Async runtime integration for propagation
Common patterns:
#[instrument]for automatic function tracing.instrument(span)for manual async tracing- Clone spans for sharing across tasks
- Layers for multiple outputs
Key insight: The global dispatcher is the foundation that enables context to flow across async boundaries. When you call set_global_default, you're establishing a shared sink for all tracing data. The tracing crate then manages the complexity of context propagation: each thread has its own current-span stack stored in thread-local storage, but spans themselves are globally registered with unique IDs. When an async task suspends at .await and later resumesâpotentially on a different threadâthe runtime's tracing integration transfers the context stack to the new thread's local storage. The span IDs remain valid because they reference the global registry. This design means you can trace operations that span multiple threads without manual context management, as long as you use .instrument() or #[instrument] to attach spans to your async code.
