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_default establishes 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 .await points
  • Task migration between threads preserves context
  • Spans are reference-counted in a global registry

Nested span mechanics:

  • span.enter() pushes span onto thread-local stack
  • drop(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 Dispatch wraps Subscriber
  • 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.