Spans in Rust

This post is going to focus on creating spans in rust.

I probably should have mentioned it in the previous post, but Wikipedia has a good high level description of tracing if you’re unsure of the purpose and usage.

I want to start by looking more thoroughly at the tracing-example: step1 title bar that was clicked on in Using Jaeger to Capture Traces: Jaeger Step 1

This page represents a full trace. At the bottom of the image one can see it says tracing-example step1. On the next line and indented we see tracing-example inside_.... If this weren’t cut off it would have said tracing-example inside_step1. Each one of these lines is a Span.

One thing to notice in the Jaeger UI is it uses a black line to emphasize what it thinks is the currently executing function. The trace shows that we entered the function step1() and executed in the function for a bit less than 12ms, 10ms of which was the sleep call. Then from within step1() we entered inside_step1(). We spent about 31.5ms in inside_step1(), 30ms of which was the sleep. We returned to executing in step1() for almost 10ms again. To be honest I’m not sure why there was 10ms at the end, and perhaps we’ll find out as we dig in to this post.

The instrument Macro

The previous spans were created via the the instrument attribute macro from the tracing crate. The macro was placed on each function.

#[instrument]
fn step1() {
    sleep(Duration::from_millis(10));
    println!("Step 1");
    inside_step1();
}

#[instrument]
fn inside_step1() {
    sleep(Duration::from_millis(30));
    println!("Inside Step 1");
}

The instrument macro is a nice convenience to create a span per function. The macro’s full capabilities are more than we want to cover here, I suggest you read its documentation if you get a chance. In particular, the macro can help avoid some rough edges for async functions. We’re going to ignore async functions for now.

The macro is equivalent to placing the following lines at the start of the function.

    let span = span!(Level::INFO, "step1");
    let _guard = span.enter();

The first line creates a Span from the tracing crate. The second line enters the span. When the _guard is dropped the span will be exited.

Let’s remove the macro and create the spans ourselves. I chose a slightly different name than the function name to show it can any str you want.

use tracing::{Level, span};
fn step1() {
    let span = span!(Level::INFO, "manual span for step1");
    let _guard = span.enter();
    sleep(Duration::from_millis(10));
    println!("Step 1");
    inside_step1();
}

fn inside_step1() {
    let span = span!(Level::INFO, "manual span for inside_step1");
    let _guard = span.enter();
    sleep(Duration::from_millis(30));
    println!("Inside Step 1");
}

If we run the example with this change we will see output similar to: Jaeger Trace With Manual Spans

We can see there is a trace title bar for tracing-example: manual span for step1, which matches the name we provided to the span!() macro.

If we click on the title bar for tracing-example: manual span for step1 we’ll see a page similar to the following: Jaeger Manual Spans

Multiple Spans In a Function

By creating spans manually we can create multiple spans in a functions. For example the println!() macro doesn’t have a span on it. We could try to create a function that wraps println!() and use instrument on that function, but that would get a bit tedious.

Using block scopes {}, we can create spans and rely on them being dropped at the end of their respective scope.

fn step1() {
    let span = span!(Level::INFO, "step1");
    let _guard = span.enter();
    {
        let span = span!(Level::INFO, "sleeping");
        let _guard = span.enter();
        sleep(Duration::from_millis(10));
    }
    {
        let span = span!(Level::INFO, "printing");
        let _guard = span.enter();
        println!("Step 1");
    }
    inside_step1();
}

If we run this we should be able to find a trace similar to: Jaeger Multiple Spans

I noticed that adding spans this way increased the time before calling inside_step1() to ~20ms. I hovered over the tick on the black line in the Jaeger UI and it happened to show an event HttpTracesClient.ExportSucceeded. This helps to explain the ~10ms at the end of step1() I couldn’t account for above. This example uses a SimpleSpanProcessor and per the docs.

Spans are exported synchronously in the same thread

This means it’s taking 4-10ms to export spans when they complete.

Instead of using blocks we can use the in_scope() function.

fn step1() {
    let span = span!(Level::INFO, "step1");
    let _guard = span.enter();
    span!(Level::INFO, "sleeping").in_scope(|| sleep(Duration::from_millis(10)));
    span!(Level::INFO, "printing").in_scope(|| println!("Step 1"));
    inside_step1();
}

Peeling Back Rust Tracing

The previous traces were created using the tracing crate. I started these posts talking about Opentelemetry tracing. The reason being that Opentelemetry is starting to be the common interface for tracing, and more. The tracing crate with tracing_opentelemetry crate provides a nice abstraction over generating spans and traces. We can drop the tracing crate and instead use the Opentelemetry crates directly.

#!/usr/bin/env -S cargo +nightly -Zscript
---cargo
[dependencies]
opentelemetry = "0.31"
opentelemetry_sdk = "0.31"
opentelemetry-otlp = "0.31"
---

use opentelemetry::{
    Context,
    trace::{TraceContextExt, Tracer},
};
use opentelemetry_otlp::{Protocol, WithExportConfig};
use opentelemetry_sdk::Resource;
use std::{thread::sleep, time::Duration};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let exporter = opentelemetry_otlp::SpanExporter::builder()
        .with_http()
        .with_protocol(Protocol::HttpJson)
        .build()?;
    let resource = Resource::builder()
        .with_service_name("tracing-example")
        .build();
    let provider = opentelemetry_sdk::trace::SdkTracerProvider::builder()
        .with_resource(resource)
        .with_simple_exporter(exporter)
        .build();
    opentelemetry::global::set_tracer_provider(provider);

    step1();
    step2();
    Ok(())
}

fn step1() {
    let tracer = opentelemetry::global::tracer("tracer-name");
    let span = tracer.start("step1");
    let context = Context::current_with_span(span);
    let _guard = context.attach();
    sleep(Duration::from_millis(10));
    println!("Step 1");
    inside_step1();
}

fn inside_step1() {
    opentelemetry::global::tracer("tracer-name").in_span("inside_step1", |_| {
        sleep(Duration::from_millis(30));
        println!("Inside Step 1");
    });
}

fn step2() {
    opentelemetry::global::tracer("tracer-name").in_span("step2", |_| {
        sleep(Duration::from_millis(20));
        println!("Step 2")
    });
}

step1() has the fully expanded logic, while the other functions leverage the in_span() convenience function. The expanded logic more closely follows the Opentelemetry api specification. The Opentelemetry crates need to conform to the api specification. The specification is written to be programming language agnostic and thus can’t leverage language specific features.

Using only the Opentelemetry crates can reduce some of the dependencies, but the tracing crate provides a more idiomatic interface for creating and working with spans.