OpenTelemetry Rust lets you instrument your asynchronous Tokio applications, but the standard instrumentation often misses spans for tasks that are awaited after the initial span starts.

Let’s see it in action.

Imagine you have a simple Tokio application that fetches data from two different services concurrently.

use opentelemetry::{
    global,
    trace::{FutureExt, Tracer},
};
use opentelemetry_sdk::runtime;
use tokio::time::{sleep, Duration};

async fn fetch_data_from_service_a() -> String {
    sleep(Duration::from_millis(100)).await;
    "Data from Service A".to_string()
}

async fn fetch_data_from_service_b() -> String {
    sleep(Duration::from_millis(150)).await;
    "Data from Service B".to_string()
}

#[tokio::main]
async fn main() {
    // Initialize OpenTelemetry
    let tracer = opentelemetry_sdk::trace::TracerProvider::builder()
        .with_simple_exporter(opentelemetry_stdout::new_pipeline())
        .build();
    global::set_tracer_provider(tracer);

    let tracer = global::tracer("my-app");

    // Start a root span
    let root_span = tracer.start("fetch_all_data");
    let _guard = root_span.get_span().enter();

    // Spawn tasks that will be awaited
    let task_a = tokio::spawn(
        async {
            let data = fetch_data_from_service_a().await;
            println!("{}", data);
        }
        .with_span(tracer.span_builder("fetch_service_a").start_with_context()),
    );

    let task_b = tokio::spawn(
        async {
            let data = fetch_data_from_service_b().await;
            println!("{}", data);
        }
        .with_span(tracer.span_builder("fetch_service_b").start_with_context()),
    );

    // Await the spawned tasks
    let (res_a, res_b) = tokio::try_join!(task_a, task_b).unwrap();

    // The root span ends here
    drop(_guard);
    println!("All data fetched.");
}

When you run this, you’ll see output like this (the exact timing will vary):

{
    "name": "fetch_service_a",
    "start_time": "2023-10-27T10:00:00.123456789Z",
    "end_time": "2023-10-27T10:00:00.223456789Z",
    "attributes": [],
    "events": [],
    "span_context": {
        "trace_id": "...",
        "span_id": "...",
        "trace_flags": 1
    },
    "parent_span_id": "..."
}
{
    "name": "fetch_service_b",
    "start_time": "2023-10-27T10:00:00.123456789Z",
    "end_time": "2023-10-27T10:00:00.273456789Z",
    "attributes": [],
    "events": [],
    "span_context": {
        "trace_id": "...",
        "span_id": "...",
        "trace_flags": 1
    },
    "parent_span_id": "..."
}
{
    "name": "fetch_all_data",
    "start_time": "2023-10-27T10:00:00.100000000Z",
    "end_time": "2023-10-27T10:00:00.280000000Z",
    "attributes": [],
    "events": [],
    "span_context": {
        "trace_id": "...",
        "span_id": "...",
        "trace_flags": 1
    }
}

Notice how fetch_service_a and fetch_service_b start after fetch_all_data has already begun. The with_span combinator from opentelemetry::trace::FutureExt is crucial here. It attaches the span to the Future before it’s spawned or awaited. When the Future is polled, the span’s context is propagated. This ensures that even though the actual work (fetch_data_from_service_a().await) happens later, the span correctly reflects its execution time relative to the parent.

The core problem OpenTelemetry Rust solves for async Tokio is the correct propagation of trace context across async/await boundaries and spawned tasks. Without explicit handling, the context might not be carried forward when a task yields and resumes. The with_span combinator is the idiomatic way to ensure that a Future’s execution, even if it involves multiple awaits, is correctly attributed to a specific span.

The opentelemetry::trace::FutureExt trait provides the .with_span() method. This method takes a Span and returns a new Future that, when polled, ensures the provided Span is entered. This is critical for tokio::spawn because the spawned Future might not be executed immediately. The span needs to be associated with the Future before it’s handed off to the Tokio runtime.

You control the granularity of your traces by how you wrap your async blocks. Each .with_span() call creates a new span. You can nest these calls to represent complex asynchronous workflows. The opentelemetry_sdk::runtime module provides the necessary integration for various asynchronous runtimes, including Tokio.

The with_span combinator itself doesn’t start the span; it simply ensures the span is entered when the future is polled. This means you typically create the Span using tracer.start() or tracer.span_builder().start_with_context() before calling .with_span(). The start_with_context() is particularly useful for creating child spans that inherit the current active span’s context.

The most surprising thing is that tokio::spawn itself doesn’t automatically propagate the trace context. You have to explicitly tell OpenTelemetry which Futures belong to which spans using combinators like with_span.

The next step is to explore distributed tracing, where you’d configure a proper exporter (like OTLP) to send these traces to a backend system for visualization and analysis.

Want structured learning?

Take the full Opentelemetry course →