What is the purpose of tracing::Instrument::instrument for attaching spans to future executions?

tracing::Instrument::instrument attaches a span to a future so that the span is entered when the future is polled, providing automatic context propagation across async boundaries without manual span management. This ensures that any events logged during the future's execution are properly associated with the span, even when the future is suspended and resumed multiple times by the async runtime.

The Problem: Context Loss Across Async Boundaries

use tracing::{info_span, info};
 
// Without instrument, span context is lost across await points
async fn process_request_without_instrument(id: u64) {
    // This span is entered synchronously
    let span = info_span!("request", id = id);
    let _enter = span.enter();  // Span entered here
    
    info!("Processing request");
    
    // Problem: When we hit an await point, the runtime may suspend this task
    let data = fetch_data().await;  // Suspension point
    
    // After resuming, the _enter guard is still in scope, but...
    // The span context might not be properly propagated
    // Events here may lose the span context
    
    info!("Data fetched: {:?}", data);
    
    // The span is exited when _enter goes out of scope
}
 
async fn fetch_data() -> Vec<u8> {
    // Which span are we in? Unclear!
    vec![1, 2, 3]
}

Manual span management with .enter() doesn't properly handle async suspension and resumption.

The Solution: Instrument Trait

use tracing::{info_span, info, Instrument};
 
// With instrument, span is entered on every poll
async fn process_request_with_instrument(id: u64) {
    let span = info_span!("request", id = id);
    
    // instrument wraps the future and enters the span on each poll
    async {
        info!("Processing request");
        
        let data = fetch_data().await;  // Suspended here
        
        // When resumed, span is automatically re-entered
        info!("Data fetched: {:?}", data);
        
        data
    }
    .instrument(span)
    .await
}
 
async fn fetch_data() -> Vec<u8> {
    // Now we're properly inside the request span
    vec![1, 2, 3]
}

.instrument(span) wraps the future so the span is entered every time the future is polled.

How Instrument Works

use tracing::{Span, Instrument};
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
 
// The Instrument trait:
pub trait Instrument: Sized {
    fn instrument(self, span: Span) -> Instrumented<Self> {
        Instrumented::new(self, span)
    }
    
    // Also available: instrument_noop
    // (attaches span that doesn't do anything, for conditional instrumentation)
}
 
// Instrumented wraps the inner future
pub struct Instrumented<T> {
    inner: T,
    span: Span,
}
 
// Key insight: When Instrumented is polled, it enters the span
impl<T: Future> Future for Instrumented<T> {
    type Output = T::Output;
    
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        let _enter = self.span.enter();  // Enter span on every poll
        self.inner.poll(cx)  // Poll the inner future
        // Span exited when _enter goes out of scope
    }
}
 
// This means:
// 1. First poll: span is entered
// 2. If future returns Pending, task is suspended
// 3. Runtime wakes the task later
// 4. Second poll: span is entered AGAIN
// 5. This continues until the future completes

Every poll of the instrumented future enters the span, maintaining context across suspensions.

Basic Usage Patterns

use tracing::{info_span, debug_span, error_span, info, Instrument};
 
async fn basic_patterns() {
    // Pattern 1: Inline with async block
    async {
        info!("Inside span");
    }
    .instrument(info_span!("my_span"))
    .await;
    
    // Pattern 2: With async function
    async fn my_async_function() {
        info!("Inside function");
    }
    
    my_async_function()
        .instrument(info_span!("function_call"))
        .await;
    
    // Pattern 3: With span fields
    async {
        info!("Processing");
    }
    .instrument(info_span!("request", user_id = 123, method = "GET"))
    .await;
    
    // Pattern 4: Different span levels
    async {
        info!("Debug operation");
    }
    .instrument(debug_span!("debug_ops"))
    .await;
    
    async {
        info!("Error context");
    }
    .instrument(error_span!("error_context"))
    .await;
}

The .instrument() method is called on the future before .await.

Propagating Context to Nested Futures

use tracing::{info_span, info, Instrument, Span};
 
async fn nested_futures() {
    // Outer span
    let outer_span = info_span!("outer", request_id = %12345);
    
    async {
        info!("In outer span");  // Has outer span context
        
        // Nested future can use outer span or create its own
        async {
            info!("In inner span");  // Has both spans in context
        }
        .instrument(info_span!("inner"))
        .await;
        
        info!("Back to outer span only");
    }
    .instrument(outer_span)
    .await;
}
 
async fn child_spans() {
    let parent = info_span!("parent", id = 1);
    
    async {
        // Create child span that inherits from parent
        let child = Span::current();  // Gets current span (parent)
        
        async {
            info!("Inside child");
        }
        .instrument(info_span!(parent: child, "nested"))
        .await;
    }
    .instrument(parent)
    .await;
}

Spans nest properly—child spans can be created with the current span as parent.

Integration with Async Runtimes

use tracing::{info_span, info, Instrument};
 
// With tokio::spawn
async fn spawn_with_instrumentation() {
    let span = info_span!("background_task", task_id = 42);
    
    // The span follows the spawned task
    tokio::spawn(
        async {
            info!("Task started");
            // ... work ...
            info!("Task completed");
        }
        .instrument(span)
    );
}
 
// Without instrument, spawned task has no parent span context
async fn spawn_without_instrumentation() {
    let span = info_span!("parent");
    let _enter = span.enter();
    
    // This task will NOT have the parent span context
    tokio::spawn(async {
        info!("Which span am I in?");  // Root span or no span
    });
    
    // This task WILL have the span context
    tokio::spawn(
        async {
            info!("I'm in the background_task span");
        }
        .instrument(info_span!("background_task"))
    );
}

When spawning tasks, .instrument() ensures the span context crosses task boundaries.

Real-World HTTP Handler Example

use tracing::{info_span, debug, info, error, Instrument};
use axum::{
    extract::Path,
    response::Json,
};
 
async fn handle_user_request(
    Path(user_id): Path<u64>,
) -> Json<serde_json::Value> {
    let span = info_span!(
        "http_request",
        user_id = %user_id,
        method = "GET",
        path = "/users/:id"
    );
    
    async {
        debug!("Received request");
        
        match fetch_user(user_id).await {
            Ok(user) => {
                info!("User found");
                Json(serde_json::to_value(user).unwrap())
            }
            Err(e) => {
                error!(error = %e, "Failed to fetch user");
                Json(serde_json::json!({"error": "not found"}))
            }
        }
    }
    .instrument(span)
    .await
}
 
async fn fetch_user(id: u64) -> Result<User, Error> {
    // Any spans created here will be children of http_request
    async {
        info_span!("db_query", user_id = %id).in_scope(|| {
            debug!("Executing database query");
        });
        
        // Or with instrument:
        async {
            debug!("Database operation");
        }
        .instrument(info_span!("db_operation"))
        .await;
        
        Ok(User { id, name: "Alice".to_string() })
    }
    .instrument(info_span!("fetch_user", user_id = %id))
    .await
}

Every log event in the request flow has proper hierarchical context.

Instrument vs InScope

use tracing::{info_span, info, Instrument, Span};
 
async fn instrument_vs_inscope() {
    let span = info_span!("my_span");
    
    // instrument: Attached to future, enters on every poll
    async {
        info!("This is inside the span");
        
        // Even across await points
        some_async_operation().await;
        
        info!("Still inside the span");
    }
    .instrument(span)
    .await;
    
    // in_scope: Enters span for synchronous block only
    let span2 = info_span!("sync_span");
    span2.in_scope(|| {
        info!("This is inside the span");
        // Only works for synchronous code
    });
    info!("This is outside the span");
    
    // in_scope does NOT work across await points:
    async {
        let span3 = info_span!("broken_span");
        span3.in_scope(|| {
            info!("In span");
        });
        
        some_async_operation().await;
        
        // Problem: span context lost after await
        info!("NOT in span anymore");
    }.await;
}
 
async fn some_async_operation() {
    // Simulated async work
}

.instrument() is for async; .in_scope() is for synchronous code only.

Conditional Instrumentation

use tracing::{Level, info_span, Instrument};
 
async fn conditional_instrumentation(enabled: bool) {
    // Option 1: Use if/else
    let future = async {
        info!("Work done");
    };
    
    if enabled {
        future.instrument(info_span!("traced")).await;
    } else {
        future.await;
    }
    
    // Option 2: Use instrument_noop
    // (The span is attached but does nothing)
    async {
        info!("Work done");
    }
    .instrument(info_span!(Level::TRACE, "conditional"))  // Low level = off in most configs
    .await;
    
    // Option 3: Create span conditionally
    let span = if enabled {
        info_span!("traced")
    } else {
        tracing::Span::none()  // A no-op span
    };
    
    async {
        info!("Work done");
    }
    .instrument(span)
    .await;
}

Use conditional logic or no-op spans when instrumentation should be optional.

Tracing Across Thread Boundaries

use tracing::{info_span, info, Instrument};
 
async fn across_threads() {
    let span = info_span!("cross_thread", thread = ?std::thread::current().id());
    
    // With tokio, the span follows the task across threads
    tokio::spawn(
        async {
            info!("Running on thread: {:?}", std::thread::current().id());
            
            // Even if runtime moves this task between threads,
            // the span context is preserved
        }
        .instrument(span)
    );
    
    // The span is stored in the task's context
    // When the task is polled on any thread, the span is entered
}
 
async fn thread_pool_example() {
    let span = info_span!("pool_work");
    
    // Blocking operation runs on blocking thread pool
    tokio::task::spawn_blocking(
        move || {
            // The span context is NOT automatically propagated to spawn_blocking
            // because it's a blocking closure, not a future
            
            // To propagate, use in_scope inside:
            span.in_scope(|| {
                info!("Inside blocking code");
            });
        }
    );
    
    // For spawn_blocking, you need to manually enter the span
    // Or capture it and use in_scope:
    let span_clone = span.clone();
    tokio::task::spawn_blocking(move || {
        span_clone.in_scope(|| {
            info!("Blocking operation with proper context");
        });
    });
}

Spans propagate automatically with async tasks but require manual handling for blocking operations.

Performance Considerations

use tracing::{info_span, Instrument, Level};
 
async fn performance_considerations() {
    // Creating spans has overhead
    // Use appropriate log levels
    
    // Expensive: INFO span always created
    async {
        // work
    }
    .instrument(info_span!("operation"))
    .await;
    
    // Cheaper: TRACE span often disabled
    async {
        // work
    }
    .instrument(tracing::trace_span!("operation"))
    .await;
    
    // Even cheaper: span not created if level is disabled
    if tracing::enabled!(Level::DEBUG) {
        async {
            // work
        }
        .instrument(info_span!("debug_operation"))
        .await;
    } else {
        // work without span
    }
    
    // Span fields are evaluated at span creation time
    let expensive_field = compute_expensive_value();
    async {
        // work
    }
    .instrument(info_span!("op", value = %expensive_field))
    .await;
}
 
fn compute_expensive_value() -> String {
    // Expensive computation
    "value".to_string()
}

Consider span creation overhead and use appropriate log levels for performance-sensitive code.

Common Patterns and Idioms

use tracing::{info_span, debug_span, trace_span, info, Instrument, Span};
 
// Pattern 1: Function-level instrumentation
async fn fetch_item(id: u64) -> Item {
    async {
        // Implementation
        Item { id }
    }
    .instrument(info_span!("fetch_item", id = %id))
    .await
}
 
// Pattern 2: Handler-level with request context
struct RequestContext {
    request_id: String,
    user_id: u64,
}
 
async fn handle_request(ctx: RequestContext) -> Response {
    async {
        // All events have request context
        process_request().await
    }
    .instrument(info_span!(
        "request",
        request_id = %ctx.request_id,
        user_id = %ctx.user_id
    ))
    .await
}
 
// Pattern 3: Nested instrumentation with semantic names
async fn process_order(order_id: u64) {
    async {
        validate_order(order_id).await;
        charge_payment(order_id).await;
        ship_order(order_id).await;
    }
    .instrument(info_span!("process_order", order_id = %order_id))
    .await;
}
 
async fn validate_order(id: u64) {
    async {
        // Validation logic
    }
    .instrument(debug_span!("validate"))
    .await;
}
 
async fn charge_payment(id: u64) {
    async {
        // Payment logic
    }
    .instrument(debug_span!("payment"))
    .await;
}
 
async fn ship_order(id: u64) {
    async {
        // Shipping logic
    }
    .instrument(debug_span!("shipping"))
    .await;
}
 
// Pattern 4: Extracting current span for correlation
async fn operation_with_correlation() {
    let span = info_span!("operation");
    
    async {
        let current = Span::current();
        let correlation_id = current.context().span().metadata().unwrap().name();
        
        info!(correlation_id = %correlation_id, "Processing");
    }
    .instrument(span)
    .await;
}

Establish consistent instrumentation patterns across your codebase.

Error Handling with Instrument

use tracing::{info_span, error, Instrument};
use anyhow::Result;
 
async fn error_handling() -> Result<()> {
    async {
        // Errors are still propagated correctly
        fallible_operation().await?;
        another_fallible().await?;
        Ok(())
    }
    .instrument(info_span!("error_example"))
    .await
    // Error propagates, but span context is captured
}
 
async fn fallible_operation() -> Result<()> {
    Err(anyhow::anyhow!("Something went wrong"))
}
 
async fn another_fallible() -> Result<()> {
    Ok(())
}
 
// With error instrumentation
async fn with_error_instrumentation() -> Result<()> {
    let span = info_span!("operation");
    
    async {
        match fallible_operation().await {
            Ok(_) => Ok(()),
            Err(e) => {
                // Log error within span context
                error!(error = %e, "Operation failed");
                Err(e)
            }
        }
    }
    .instrument(span)
    .await
}

Errors propagate normally; spans capture error context.

Summary Table

fn summary() {
    // | Method          | Scope               | Use Case                |
    // |-----------------|---------------------|-------------------------|
    // | instrument     | Entire future       | Async code, await points|
    // | in_scope        | Synchronous block   | Sync code only          |
    // | enter           | RAII guard          | Manual sync management  |
    
    // | Pattern                           | Example                              |
    // |-----------------------------------|--------------------------------------|
    // | Inline instrumentation            | async { }.instrument(span).await     |
    // | Function instrumentation          | my_fn().instrument(span).await       |
    // | Spawned task instrumentation       | spawn(async { }.instrument(span))    |
    // | Nested spans                      | outer.instrument(outer_span).await   |
    
    // | Do                                | Don't                                |
    // |-----------------------------------|--------------------------------------|
    // | Use instrument for async code     | Use enter/in_scope across await      |
    // | Attach span before spawn          | Expect context in spawn_blocking     |
    // | Use appropriate log levels         | Create expensive spans always        |
    // | Include relevant fields in span   | Over-instrument low-level code       |
}

Synthesis

Quick reference:

use tracing::{info_span, info, Instrument};
 
// Attach span to future - enters span on every poll
async {
    info!("This event has span context");
    some_async_work().await;
    info!("Still has span context after await");
}
.instrument(info_span!("my_operation", id = 42))
.await;
 
// Compare to in_scope - only for sync code
let span = info_span!("sync_only");
span.in_scope(|| {
    info!("Only works for sync code");
});

Key insight: Instrument::instrument solves the fundamental challenge of maintaining span context across async boundaries. When a future suspends at an await point and later resumes, manual span management (.enter() or .in_scope()) fails because the RAII guard doesn't survive the suspension. .instrument() wraps the future in Instrumented<T>, which enters the span on every poll—effectively re-entering the span each time the runtime polls the future. This means events logged anywhere within the async block have proper span context, regardless of how many times the future is suspended and resumed. For spawned tasks (like tokio::spawn), .instrument() is essential for propagating span context to the new task. The pattern async { ... }.instrument(span).await is idiomatic for annotating async blocks, while my_function().instrument(span).await wraps function calls. Use span fields to attach contextual data (request IDs, user IDs) that should appear in all child events. For blocking code (like spawn_blocking), use .in_scope() manually since there's no future to instrument. The performance cost is primarily span creation, so use appropriate log levels (trace_span! vs info_span!) and consider conditional instrumentation for hot paths.