Unveiling Observability: Tracing with Spans, Events, and Tower-HTTP in Rust
Takashi Yamamoto
Infrastructure Engineer · Leapcell

Introduction
In the intricate world of modern software development, understanding the behavior of your applications, especially distributed systems, is paramount. When things go awry, or even when they're working splendidly, gaining insights into the flow of execution, the time taken for operations, and the context around events becomes critical for debugging, performance optimization, and general system health monitoring. This is where robust observability tools step in. The Rust ecosystem offers tracing, a powerful and flexible framework for structured logging and distributed tracing. It provides a foundational layer upon which sophisticated diagnostic tools can be built. This article will embark on a deep dive into tracing, exploring its fundamental components – spans and events – and then demonstrate how to seamlessly integrate it with tower-http, a popular collection of HTTP middleware, to bring actionable observability to your web services.
Understanding the Core of Tracing
Before we plunge into practical examples, let's establish a solid understanding of the two cornerstone concepts within tracing: spans and events.
Spans: The Envelopes of Execution
A span represents a period of execution within your application. Think of it as a logical unit of work, a bounded context that encapsulates a specific operation. Spans have a start and an end, and they form a hierarchical structure. For instance, a single HTTP request handler might be a span. Within that handler, a database query or an external API call could be nested spans. This hierarchical relationship is crucial for understanding the causal chain of operations and reconstructing the complete flow of requests through your system. Spans can carry associated data, called fields, which provide contextual information relevant to that specific operation – such as request IDs, user IDs, or input parameters.
Events: Points in Time
In contrast to spans, events represent discrete, instantaneous occurrences at a specific point in time within your application's execution. They are atomic log messages, often used for reporting significant occurrences that don't necessarily encompass a duration, such as an error being caught, a user logging in, or a specific data transformation step completing. Like spans, events also carry fields to provide relevant context.
The power of tracing lies in how spans and events work together. Events often occur within the context of an active span, inheriting its contextual fields and reinforcing the narrative of what's happening within that particular unit of work.
Layers and Subscribers
tracing doesn't directly print output to the console or send data to a tracing backend. Instead, it operates through a system of subscribers and layers. A subscriber is a type that implements the tracing Subscriber trait, responsible for processing tracing data (spans and events) as it's generated. Layers are composable units that sit between the trace data source and the subscriber, allowing for filtering, formatting, and enriching the data before it reaches the final subscriber. This architecture provides immense flexibility, enabling you to tailor your observability solution to specific needs, such as logging to different destinations or filtering certain types of events.
Practical Tracing with Spans and Events
Let's illustrate these concepts with some Rust code. First, you'll need to add tracing and tracing-subscriber to your Cargo.toml:
[dependencies] tracing = "0.1" tracing-subscriber = "0.3"
Now, consider a simple function that simulates some work:
use tracing::{info, span, Level}; #[tracing::instrument] // This macro creates a span for the function async fn perform_complex_operation(input_value: u32) -> String { // An event inside the span info!("Starting complex operation with input_value={}", input_value); // Simulate some work tokio::time::sleep(std::time::Duration::from_millis(50)).await; let intermediate_result = input_value * 2; // Another event info!("Calculated intermediate_result={}", intermediate_result); // Enter a new nested span let nested_span = span!(Level::INFO, "nested_processing", step = 1); let _guard = nested_span.enter(); // Enter the span context tokio::time::sleep(std::time::Duration::from_millis(30)).await; let final_result = format!("Processed: {}", intermediate_result + 10); info!("Finished nested processing"); drop(_guard); // Explicitly exit the nested span info!("Complex operation completed, returning: {}", final_result); final_result } #[tokio::main] async fn main() { // Initialize a simple subscriber for console output tracing_subscriber::fmt::init(); let result = perform_complex_operation(42).await; info!("Application finished with result: {}", result); }
In this example:
#[tracing::instrument]onperform_complex_operationautomatically creates a span that encompasses the entire function's execution. It also automatically includes function arguments as fields in the span.info!macro calls generate events. Notice howinfo!("Starting complex operation with input_value={}", input_value);automatically picks upinput_valueas a field because it's available in the current span's context (due to#[tracing::instrument]).- We manually create a nested span
nested_processingusingspan!and enter its context withnested_span.enter(). The_guardensures the span is exited when it goes out of scope (or explicitly withdrop). This demonstrates manual span creation for fine-grained control. tracing_subscriber::fmt::init()sets up a basic subscriber that prints formatted trace data to the console, allowing us to see the hierarchical structure.
When you run this, you'll observe output similar to this (simplified for clarity):
INFO tokio_app: Starting complex operation with input_value=42 span=perform_complex_operation
INFO tokio_app: Calculated intermediate_result=84 span=perform_complex_operation
INFO tokio_app: Finished nested processing span=perform_complex_operation::nested_processing step=1
INFO tokio_app: Complex operation completed, returning: Processed: 94 span=perform_complex_operation
INFO tokio_app: Application finished with result: Processed: 94
Notice how span=... indicates the active span for each event, and span=perform_complex_operation::nested_processing shows the nested span.
Integrating with Tower-HTTP
Now, let's elevate our observability by integrating tracing with tower-http, a collection of HTTP middleware for tower services. tower-http provides a Trace middleware specifically designed for this purpose.
First, add the necessary dependencies to your Cargo.toml:
[dependencies] tracing = "0.1" tracing-subscriber = "0.3" tokio = { version = "1", features = ["full"] } axum = "0.6" # Using axum for a simple web server tower = "0.4" tower-http = { version = "0.4", features = ["trace"] }
Next, let's create a small axum application (which uses tower internally) and apply the Trace middleware.
use axum::{routing::get, Router}; use tower_http::trace::{TraceLayer, DefaultOnRequest, DefaultOnResponse}; use tracing::{info, Level}; use std::time::Duration; // A simple handler that uses our traced function async fn hello_handler() -> String { info!("Handler received request"); let result = perform_complex_operation(100).await; format!("Hello, from handler! {}", result) } // Our previous traced function #[tracing::instrument] async fn perform_complex_operation(input_value: u32) -> String { info!("Starting complex operation with input_value={}", input_value); tokio::time::sleep(Duration::from_millis(50)).await; let intermediate_result = input_value * 2; info!("Calculated intermediate_result={}", intermediate_result); // ... (rest of the function as before) let nested_span = tracing::span!(Level::INFO, "nested_processing", step = 1); let _guard = nested_span.enter(); tokio::time::sleep(Duration::from_millis(30)).await; let final_result = format!("Processed: {}", intermediate_result + 10); info!("Finished nested processing"); drop(_guard); info!("Complex operation completed, returning: {}", final_result); final_result } #[tokio::main] async fn main() { tracing_subscriber::fmt::init(); let app = Router::new() .route("/hello", get(hello_handler)) .layer( TraceLayer::new_for_http() // Create a new TraceLayer for HTTP services .on_request(DefaultOnRequest::new().level(Level::INFO)) // Log request details .on_response(DefaultOnResponse::new().level(Level::INFO)), // Log response details ); let listener = tokio::net::TcpListener::bind("127.0.0.1:3000") .await .unwrap(); info!("Listening on {}", listener.local_addr().unwrap()); axum::serve(listener, app).await.unwrap(); }
In this enhanced example:
- We've created an
axumRouterwith a/helloendpoint. - The
TraceLayer::new_for_http()middleware is applied to our application. This automatically creates a root span for each incoming HTTP request. .on_request(DefaultOnRequest::new().level(Level::INFO))configures the middleware to emit anINFOlevel event when a request begins, including details like method and path..on_response(DefaultOnResponse::new().level(Level::INFO))configures it to emit anINFOlevel event when a response is sent, including status code and response time.
When you run this application and send a request to http://127.0.0.1:3000/hello (e.g., using curl), you'll see comprehensive trace output:
INFO tower_http::trace::make_span: started processing request request.method=GET request.uri=/hello request.version=HTTP/1.1 remote_addr=127.0.0.1:49877 request_id=... span=http_request
INFO axum_app: Handler received request span=http_request
INFO axum_app: Starting complex operation with input_value=100 span=http_request::perform_complex_operation
INFO axum_app: Calculated intermediate_result=200 span=http_request::perform_complex_operation
INFO axum_app: Finished nested processing span=http_request::perform_complex_operation::nested_processing step=1
INFO axum_app: Complex operation completed, returning: Processed: 210 span=http_request::perform_complex_operation
INFO tower_http::trace::make_span: finished processing request status=200 response.time=100ms span=http_request
Observe how tower-http creates an top-level http_request span, and all subsequent info! events and manual spans within your handler are automatically nested under this request span. This clearly shows the full lifecycle of an HTTP request, from its arrival to the final response, with all the internal operations providing detailed context. This structured, hierarchical view is invaluable for identifying bottlenecks, understanding request flow, and debugging issues within your web services.
Conclusion
The tracing crate is an indispensable tool in the Rust observability toolkit, offering a robust and flexible framework for understanding your application's behavior. By mastering the concepts of spans as encompassing units of work and events as instantaneous occurrences, you gain the power to paint a detailed picture of your code's execution. Integrating tracing with tower-http brings this unparalleled visibility directly to your web services, transforming opaque HTTP requests into clear, traceable narratives. Embracing tracing empowers developers to build more resilient, performant, and understandable Rust applications.

