eyecatch

Implement an observability's tracing in Rust with Sentry

Posted on 2024/09/23
# Technology

Introduction

Observability means that the system’s state or status can be measured using logs, traces, and metrics. When the system has issues, observability makes it easier to understand the current system state and identify bugs.

The key components of observability are:

  • Traces
  • Logs
  • Metrics

You can find more details about observability in Observability primer.
This time, we will focus on traces.

Trace and Span

A span represents a task or event, containing information about that task or event. A trace consists of one or more spans and shows the workflow of an entire process.

An example below is data of a span.

parent_id represents the parent span ID. If parent_id is empty, it means the span is a root span.
attributes store custom data, such as function parameters.

{
  "name": "post-order",
  "context": {
    "trace_id": "0x5b8aa5a2d2c872e8321cf37308d69df2",
    "span_id": "0x93564f51e1abe1c2"
  },
  "parent_id": "0x051581bf3cb55c13",
  "start_time": "2022-04-29T18:52:58.114492Z",
  "end_time": "2022-04-29T18:52:58.114631Z",
  "attributes": {
    "http.route": "payment_route"
  },
  "events": [
    {
      "name": "check a payment",
      "timestamp": "2022-04-29T18:52:58.114561Z",
      "attributes": {
        "payment_id": 1
      }
    }
  ]
}

Implement tracing in Rust

tokio-rs/tracing provides tracing functions. You can find more information at https://github.com/tokio-rs/tracing?tab=readme-ov-file

First add dependencies.

# Cargo.toml
tracing = { version = "0.1", features = ["log"] }
tracing-subscriber = { version = "0.3", features = ["registry", "env-filter", "json"] }
sentry = {version = "0.34.0", features = ["tracing"] }
sentry-tracing = "0.34.0"

Then initialize tracing as follows:

# main.rs
// Register tracing
tracing_subscriber::registry()
        .with(
            tracing_subscriber::EnvFilter::try_from_default_env()
                .unwrap_or_else(|_| "DEBUG".into()),
        )
        .with(
            tracing_subscriber::fmt::layer(),
        )
        .with(sentry_tracing::layer()) // Sentry
        .init();

// Init sentry with tracing feature
let sentry_dsn = std::env::var("SENTRY_DSN").expect("SENTRY_DSN must be set");
let _guard: sentry::ClientInitGuard = sentry::init((
    sentry_dsn,
    sentry::ClientOptions {
        release: sentry::release_name!(),
        traces_sample_rate: 1.0,
        ..Default::default()
    },
));

The order system checks the name and price inputs, and if the price is negative, it returns an error.
The order system's workflow looks like

// Custom result
pub type Result<T, E = Error> = ::std::result::Result<T, E>;

// Custom error
#[derive(thiserror::Error, Debug)]
pub enum Error {
    #[error("an internal server error occurred")]
    InternalServerError,

    #[error("{0}")]
    UnprocessableEntity(String),
}

static TAX_RATE: f64 = 0.08;

#[derive(Debug)]
struct NewOrder {
    product_name: String,
    price: f64,
}

// Handler
#[instrument] <- this attribute makes span when the function is called
fn request_order_handler(new_order: NewOrder) -> Result<()> {
    tracing::info!("request_order_handler is called");

    match order_service(new_order) {
        Ok(_) => Ok(()),
        Err(e) => {
            tracing::error!("request_order_handler error: {}", e);
            Err(Error::InternalServerError)
        }
    }
}

#[derive(Debug)]
struct NewOrderEntity {
    product_name: String,
    total_price: f64,
}

// Service
#[instrument]
fn order_service(new_order: NewOrder) -> Result<()> {
    tracing::info!("order_service is called");

    let price = new_order.price * (1.0 + TAX_RATE);
    let new_order_entity = NewOrderEntity {
        product_name: new_order.product_name,
        total_price: price,
    };

    match order_repository(new_order_entity) {
        Ok(_) => Ok(()),
        Err(e) => {
            tracing::error!("order_service error: {}", e);
            Err(Error::UnprocessableEntity(format!(
                "order_service error: {}",
                e
            )))
        }
    }
}

// Repository
#[instrument]
fn order_repository(new_order_entity: NewOrderEntity) -> Result<()> {
    tracing::info!("order_repository called");

    if new_order_entity.total_price.is_sign_negative() {
        tracing::error!("total_price is negative");
        return Err(Error::UnprocessableEntity(
            "total_price is negative".to_string(),
        ));
    }

    // Store data into database

    Ok(())
}

Then if execute this process with params below, we can see logs

NewOrder {
    product_name: "Rust for rustaceans".to_string(),
    price: -58.0,
};

The log shows more detail for each spans.
You may notice that each functions has #[instrument] attribute, this create a new span when the instrumented function is called. The created span contains current event context so each span has relationships to each other.

Check trace in Sentry

There are new issues in Sentry.

The each issue have same Trace ID.

We can check overview of the trace from Trace menu.

We can check more detail by clicking an issue and shows span data at right side of screen.

Conclusion

Observability gives you the ability to check the system's status.
When something goes wrong with the system, tracing can speed up the process of identifying the cause of the bug.