Skip to main content

Rust

Implement OpenTelemetry custom instrumentation for Rust applications to collect traces, metrics, and logs using the tracing ecosystem and OpenTelemetry SDK. This guide covers manual instrumentation for any Rust application, including Axum, Actix-web, Rocket, and custom frameworks.

Note: This guide provides a practical overview based on the official OpenTelemetry documentation. For complete information, please consult the official OpenTelemetry Rust documentation.

Overview​

This guide demonstrates how to:

  • Set up OpenTelemetry SDK with the tracing ecosystem
  • Create and manage custom spans using #[instrument] and manual spans
  • Add attributes, events, and exception tracking
  • Implement metrics collection with counters, gauges, and histograms
  • Propagate context across service boundaries
  • Instrument common Rust patterns and async code

Complete Working Examples: This guide includes code snippets for learning. For full implementations, see the Complete Examples section.

Prerequisites​

Before starting, ensure you have:

  • Rust 1.75 or later installed (Rust 1.80+ recommended)
  • Cargo package manager
  • base14 Scout account with collector endpoint and API key
  • Basic familiarity with async Rust and the tracing crate

Required Packages​

Add these dependencies to your Cargo.toml:

Cargo.toml
[dependencies]
# Tracing ecosystem
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }

# OpenTelemetry core
opentelemetry = "0.29"
opentelemetry_sdk = { version = "0.29", features = ["rt-tokio"] }
opentelemetry-otlp = { version = "0.29", features = ["tonic"] }

# Tracing-OpenTelemetry bridge
tracing-opentelemetry = "0.30"

# Async runtime
tokio = { version = "1", features = ["full"] }

Telemetry Initialization​

Set up the OpenTelemetry SDK with OTLP export:

src/telemetry/init.rs
use opentelemetry::trace::TracerProvider;
use opentelemetry::{global, KeyValue};
use opentelemetry_otlp::WithExportConfig;
use opentelemetry_sdk::{
Resource,
propagation::TraceContextPropagator,
trace::{SdkTracerProvider, TracerProviderBuilder},
};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};

pub struct TelemetryGuard {
tracer_provider: SdkTracerProvider,
}

impl Drop for TelemetryGuard {
fn drop(&mut self) {
if let Err(e) = self.tracer_provider.shutdown() {
eprintln!("Failed to shutdown tracer provider: {e}");
}
}
}

pub fn init_telemetry(service_name: &str, otlp_endpoint: &str) -> TelemetryGuard {
global::set_text_map_propagator(TraceContextPropagator::new());

let resource = Resource::builder()
.with_service_name(service_name)
.with_attribute(KeyValue::new("deployment.environment", "production"))
.build();

let exporter = opentelemetry_otlp::SpanExporter::builder()
.with_tonic()
.with_endpoint(otlp_endpoint)
.build()
.expect("Failed to create OTLP exporter");

let tracer_provider = TracerProviderBuilder::default()
.with_resource(resource)
.with_batch_exporter(exporter)
.build();

let tracer = tracer_provider.tracer(service_name);

let telemetry_layer = tracing_opentelemetry::layer().with_tracer(tracer);

let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("info"));

tracing_subscriber::registry()
.with(filter)
.with(telemetry_layer)
.with(tracing_subscriber::fmt::layer())
.init();

TelemetryGuard { tracer_provider }
}

Traces​

Using the #[instrument] Attribute​

The simplest way to create spans is using the #[instrument] attribute:

use tracing::instrument;

#[instrument(name = "user.create")]
pub async fn create_user(email: &str, name: &str) -> Result<User, Error> {
// Function body becomes a span
let user = db.insert_user(email, name).await?;
Ok(user)
}

Customizing Instrumented Spans​

Control what gets captured in spans:

use tracing::instrument;

#[instrument(
name = "order.process",
skip(self, payment_details), // Don't log sensitive data
fields(order_id, customer_id = %customer.id)
)]
pub async fn process_order(
&self,
customer: &Customer,
payment_details: PaymentDetails,
) -> Result<Order, Error> {
// Record the order_id field dynamically
let order = self.create_order(customer).await?;
tracing::Span::current().record("order_id", order.id);

self.charge_payment(&order, payment_details).await?;

Ok(order)
}

Manual Span Creation​

For more control, create spans manually:

use tracing::{span, Level, Instrument};

pub async fn batch_process(items: Vec<Item>) -> Result<(), Error> {
let span = span!(Level::INFO, "batch.process", item_count = items.len());
let _guard = span.enter();

for item in items {
process_item(item).await?;
}

Ok(())
}

// Or use .instrument() for async code
pub async fn fetch_data(url: &str) -> Result<Data, Error> {
let span = span!(Level::INFO, "http.fetch", url = %url);

async {
let response = client.get(url).send().await?;
let data = response.json().await?;
Ok(data)
}
.instrument(span)
.await
}

Nested Spans​

Spans automatically nest based on the call hierarchy:

#[instrument(name = "api.handler")]
pub async fn handle_request(req: Request) -> Response {
let user = authenticate(&req).await?; // Creates child span
let data = fetch_user_data(&user).await?; // Creates child span
process_response(data) // Creates child span
}

#[instrument(name = "auth.verify")]
async fn authenticate(req: &Request) -> Result<User, Error> {
// This span is a child of "api.handler"
validate_token(req.token()).await
}

#[instrument(name = "data.fetch")]
async fn fetch_user_data(user: &User) -> Result<Data, Error> {
// This span is a child of "api.handler"
db.get_user_data(user.id).await
}

Attributes​

Adding Span Attributes​

Add attributes to provide context:

use tracing::instrument;

#[instrument(
name = "article.create",
fields(
author_id = %author.id,
title = %input.title,
article_id = tracing::field::Empty // Filled later
)
)]
pub async fn create_article(author: &User, input: CreateArticle) -> Result<Article, Error> {
let article = db.insert_article(&input).await?;

// Record the article_id after creation
tracing::Span::current().record("article_id", article.id);

Ok(article)
}

Using Span Extensions​

Add attributes dynamically within a span:

use tracing::Span;

pub async fn process_payment(order_id: i64, amount: f64) -> Result<(), Error> {
let span = Span::current();

span.record("order.id", order_id);
span.record("payment.amount", amount);

let result = payment_gateway.charge(amount).await?;

span.record("payment.transaction_id", &result.transaction_id);
span.record("payment.status", &result.status);

Ok(())
}

Semantic Conventions​

Follow OpenTelemetry semantic conventions for common attributes:

#[instrument(
name = "http.request",
fields(
http.method = %method,
http.url = %url,
http.status_code = tracing::field::Empty,
http.request.body.size = body_size,
)
)]
pub async fn make_request(
method: &str,
url: &str,
body_size: usize,
) -> Result<Response, Error> {
let response = client.request(method, url).send().await?;

tracing::Span::current().record("http.status_code", response.status().as_u16());

Ok(response)
}

Events​

Logging Events Within Spans​

Use tracing macros to add events:

use tracing::{info, warn, error, debug, instrument};

#[instrument(name = "order.fulfill")]
pub async fn fulfill_order(order_id: i64) -> Result<(), Error> {
info!(order_id, "Starting order fulfillment");

let inventory = check_inventory(order_id).await?;

if inventory.low_stock {
warn!(
order_id,
available = inventory.available,
required = inventory.required,
"Low inventory warning"
);
}

debug!(order_id, step = "payment", "Processing payment");
process_payment(order_id).await?;

debug!(order_id, step = "shipping", "Arranging shipping");
arrange_shipping(order_id).await?;

info!(order_id, "Order fulfilled successfully");

Ok(())
}

Structured Event Data​

Add structured data to events:

use tracing::{info, instrument};
use serde::Serialize;

#[derive(Serialize)]
struct OrderMetrics {
item_count: usize,
total_amount: f64,
discount_applied: bool,
}

#[instrument(name = "order.complete")]
pub async fn complete_order(order: &Order) -> Result<(), Error> {
let metrics = OrderMetrics {
item_count: order.items.len(),
total_amount: order.total,
discount_applied: order.discount.is_some(),
};

info!(
order_id = order.id,
item_count = metrics.item_count,
total_amount = metrics.total_amount,
discount_applied = metrics.discount_applied,
"Order completed"
);

Ok(())
}

Exception Recording​

Recording Errors​

Record exceptions with full context:

use tracing::{error, instrument, Span};

#[instrument(name = "user.login")]
pub async fn login(credentials: Credentials) -> Result<Session, AuthError> {
match authenticate(&credentials).await {
Ok(user) => {
let session = create_session(&user).await?;
Ok(session)
}
Err(e) => {
error!(
error = %e,
error.type = std::any::type_name_of_val(&e),
username = %credentials.username,
"Authentication failed"
);
Err(e)
}
}
}

Custom Error Recording​

Create a helper for consistent error recording:

use tracing::{error, Span};
use std::fmt::Display;

pub trait SpanErrorExt {
fn record_error<E: Display>(&self, error: &E);
}

impl SpanErrorExt for Span {
fn record_error<E: Display>(&self, error: &E) {
error!(
parent: self,
error = %error,
"Operation failed"
);
}
}

// Usage
#[instrument(name = "data.fetch")]
pub async fn fetch_data(id: i64) -> Result<Data, Error> {
db.get(id).await.map_err(|e| {
Span::current().record_error(&e);
e
})
}

Error Boundaries​

Handle errors at service boundaries:

use tracing::{error, instrument};

#[instrument(name = "api.request", skip(body))]
pub async fn handle_api_request(
method: &str,
path: &str,
body: Bytes,
) -> Result<Response, ApiError> {
let result = route_request(method, path, body).await;

match &result {
Ok(response) => {
tracing::info!(
status = response.status().as_u16(),
"Request completed"
);
}
Err(e) => {
error!(
error = %e,
error.code = e.code(),
"Request failed"
);
}
}

result
}

Metrics​

Setting Up Metrics​

Initialize the metrics provider:

src/telemetry/metrics.rs
use opentelemetry::{
global,
metrics::{Counter, Histogram, Meter},
};
use std::sync::LazyLock;

pub static METER: LazyLock<Meter> = LazyLock::new(|| {
global::meter("my-service")
});

pub static HTTP_REQUESTS_TOTAL: LazyLock<Counter<u64>> = LazyLock::new(|| {
METER
.u64_counter("http.requests.total")
.with_description("Total number of HTTP requests")
.with_unit("{request}")
.build()
});

pub static HTTP_REQUEST_DURATION: LazyLock<Histogram<f64>> = LazyLock::new(|| {
METER
.f64_histogram("http.request.duration")
.with_description("HTTP request duration in milliseconds")
.with_unit("ms")
.with_boundaries(vec![
1.0, 5.0, 10.0, 25.0, 50.0, 100.0, 250.0, 500.0, 1000.0,
])
.build()
});

Counter Metrics​

Track counts of events:

use opentelemetry::KeyValue;
use crate::telemetry::{HTTP_REQUESTS_TOTAL, USERS_REGISTERED};

pub async fn handle_request(method: &str, path: &str) -> Response {
HTTP_REQUESTS_TOTAL.add(
1,
&[
KeyValue::new("http.method", method.to_string()),
KeyValue::new("http.route", path.to_string()),
],
);

// Handle request...
}

pub async fn register_user(input: RegisterUser) -> Result<User, Error> {
let user = db.create_user(&input).await?;

USERS_REGISTERED.add(1, &[]);

Ok(user)
}

Histogram Metrics​

Record distributions of values:

use std::time::Instant;
use opentelemetry::KeyValue;
use crate::telemetry::HTTP_REQUEST_DURATION;

pub async fn timed_request<F, T>(handler: F) -> T
where
F: Future<Output = T>,
{
let start = Instant::now();

let result = handler.await;

let duration = start.elapsed().as_millis() as f64;
HTTP_REQUEST_DURATION.record(
duration,
&[KeyValue::new("http.route", "/api/users")],
);

result
}

Gauge Metrics​

Track current values:

use opentelemetry::KeyValue;
use std::sync::LazyLock;

pub static ACTIVE_CONNECTIONS: LazyLock<opentelemetry::metrics::Gauge<i64>> =
LazyLock::new(|| {
METER
.i64_gauge("connections.active")
.with_description("Number of active connections")
.build()
});

pub fn update_connection_count(count: i64) {
ACTIVE_CONNECTIONS.record(count, &[]);
}

Business Metrics​

Track domain-specific metrics:

use opentelemetry::KeyValue;
use std::sync::LazyLock;

pub static ARTICLES_CREATED: LazyLock<Counter<u64>> = LazyLock::new(|| {
METER
.u64_counter("articles.created")
.with_description("Total articles created")
.build()
});

pub static ORDERS_TOTAL: LazyLock<Counter<u64>> = LazyLock::new(|| {
METER
.u64_counter("orders.total")
.with_description("Total orders placed")
.build()
});

pub static ORDER_VALUE: LazyLock<Histogram<f64>> = LazyLock::new(|| {
METER
.f64_histogram("order.value")
.with_description("Order value in dollars")
.with_unit("USD")
.build()
});

// Usage
pub async fn create_order(order: &Order) -> Result<(), Error> {
// Process order...

ORDERS_TOTAL.add(
1,
&[KeyValue::new("order.type", order.order_type.to_string())],
);

ORDER_VALUE.record(order.total, &[]);

Ok(())
}

Context Propagation​

HTTP Context Propagation​

Propagate trace context across HTTP boundaries:

use opentelemetry::global;
use opentelemetry::propagation::Injector;
use reqwest::header::HeaderMap;

struct HeaderInjector<'a>(&'a mut HeaderMap);

impl<'a> Injector for HeaderInjector<'a> {
fn set(&mut self, key: &str, value: String) {
if let Ok(header_name) = key.parse() {
if let Ok(header_value) = value.parse() {
self.0.insert(header_name, header_value);
}
}
}
}

pub async fn call_service(url: &str) -> Result<Response, Error> {
let mut headers = HeaderMap::new();

// Inject current trace context into headers
global::get_text_map_propagator(|propagator| {
propagator.inject_context(
&tracing::Span::current().context(),
&mut HeaderInjector(&mut headers),
);
});

let response = reqwest::Client::new()
.get(url)
.headers(headers)
.send()
.await?;

Ok(response)
}

Extracting Context from Incoming Requests​

Extract trace context from incoming HTTP requests:

use opentelemetry::propagation::Extractor;
use axum::http::HeaderMap;

struct HeaderExtractor<'a>(&'a HeaderMap);

impl<'a> Extractor for HeaderExtractor<'a> {
fn get(&self, key: &str) -> Option<&str> {
self.0.get(key).and_then(|v| v.to_str().ok())
}

fn keys(&self) -> Vec<&str> {
self.0.keys().map(|k| k.as_str()).collect()
}
}

pub fn extract_context(headers: &HeaderMap) -> opentelemetry::Context {
global::get_text_map_propagator(|propagator| {
propagator.extract(&HeaderExtractor(headers))
})
}

Async Task Context​

Propagate context to spawned tasks:

use tracing::Instrument;

pub async fn process_in_background(data: Data) {
let span = tracing::span!(tracing::Level::INFO, "background.task");

tokio::spawn(
async move {
// This task carries the trace context
process_data(data).await;
}
.instrument(span),
);
}

Framework-Specific Examples​

Axum Middleware​

Create tracing middleware for Axum:

use axum::{
extract::Request,
middleware::Next,
response::Response,
};
use tracing::{instrument, Span};
use std::time::Instant;

pub async fn tracing_middleware(request: Request, next: Next) -> Response {
let method = request.method().to_string();
let uri = request.uri().path().to_string();

let span = tracing::span!(
tracing::Level::INFO,
"http.request",
http.method = %method,
http.uri = %uri,
http.status_code = tracing::field::Empty,
);

let start = Instant::now();

let response = next.run(request).instrument(span.clone()).await;

let duration = start.elapsed();
span.record("http.status_code", response.status().as_u16());

tracing::info!(
parent: &span,
duration_ms = duration.as_millis(),
"Request completed"
);

response
}

Tower Service Instrumentation​

Instrument Tower services:

use tower_http::trace::{TraceLayer, DefaultMakeSpan, DefaultOnResponse};
use tracing::Level;

let app = Router::new()
.route("/api/users", get(list_users))
.layer(
TraceLayer::new_for_http()
.make_span_with(DefaultMakeSpan::new().level(Level::INFO))
.on_response(DefaultOnResponse::new().level(Level::INFO)),
);

Best Practices​

1. Use Structured Fields​

Always use structured fields instead of string interpolation:

// Good
tracing::info!(user_id = 123, action = "login", "User logged in");

// Avoid
tracing::info!("User 123 logged in");

2. Skip Sensitive Data​

Never log sensitive information:

#[instrument(skip(password, credit_card))]
pub async fn process_payment(
user_id: i64,
password: &str,
credit_card: &CreditCard,
) -> Result<(), Error> {
// ...
}

3. Use Appropriate Span Names​

Follow a consistent naming convention:

// Good: domain.action format
#[instrument(name = "user.create")]
#[instrument(name = "order.process")]
#[instrument(name = "payment.charge")]

// Avoid: inconsistent naming
#[instrument(name = "createUser")]
#[instrument(name = "process_order")]

4. Handle Errors Consistently​

Always record errors before returning:

#[instrument(name = "data.fetch")]
pub async fn fetch_data(id: i64) -> Result<Data, Error> {
match db.get(id).await {
Ok(data) => Ok(data),
Err(e) => {
tracing::error!(error = %e, id, "Failed to fetch data");
Err(e)
}
}
}

5. Use Field Placeholders for Dynamic Values​

Record values that aren't known at span creation:

#[instrument(
name = "request.process",
fields(response_size = tracing::field::Empty)
)]
pub async fn process() -> Response {
let response = generate_response().await;
Span::current().record("response_size", response.body().len());
response
}

Complete Examples​

Full Service Setup​

src/main.rs
use std::net::SocketAddr;
use axum::{Router, routing::get};
use tracing::info;

mod telemetry;
mod handlers;

#[tokio::main]
async fn main() {
let _guard = telemetry::init_telemetry(
"my-rust-service",
"https://scout-collector.base14.io:4317",
);

let app = Router::new()
.route("/health", get(handlers::health))
.route("/api/users", get(handlers::list_users));

let addr = SocketAddr::from(([0, 0, 0, 0], 3000));
info!("Starting server on {}", addr);

let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
}

Instrumented Handler​

src/handlers.rs
use axum::Json;
use tracing::instrument;
use crate::telemetry::USERS_FETCHED;

#[instrument(name = "handler.list_users")]
pub async fn list_users() -> Json<Vec<User>> {
let users = fetch_users_from_db().await;

USERS_FETCHED.add(users.len() as u64, &[]);

Json(users)
}

#[instrument(name = "db.fetch_users")]
async fn fetch_users_from_db() -> Vec<User> {
// Database query with automatic span
sqlx::query_as!(User, "SELECT * FROM users")
.fetch_all(&pool)
.await
.unwrap_or_default()
}

Extracting Trace and Span IDs​

Extract trace context for logging or correlation:

use tracing::Span;
use tracing_opentelemetry::OpenTelemetrySpanExt;

pub fn get_trace_ids() -> (String, String) {
let span = Span::current();
let context = span.context();
let span_ref = context.span();
let span_context = span_ref.span_context();

let trace_id = span_context.trace_id().to_string();
let span_id = span_context.span_id().to_string();

(trace_id, span_id)
}

// Include in error responses
pub async fn handle_error(error: Error) -> Response {
let (trace_id, span_id) = get_trace_ids();

Json(json!({
"error": error.to_string(),
"trace_id": trace_id,
"span_id": span_id,
}))
.into_response()
}

Proper Shutdown and Resource Cleanup​

Ensure telemetry is properly flushed on shutdown:

use tokio::signal;

#[tokio::main]
async fn main() {
// The guard ensures cleanup on drop
let _telemetry_guard = telemetry::init_telemetry(
"my-service",
"https://scout-collector.base14.io:4317",
);

let app = create_app();

// Graceful shutdown
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();

axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await
.unwrap();

// Guard drops here, flushing all telemetry
}

async fn shutdown_signal() {
let ctrl_c = async {
signal::ctrl_c().await.expect("Failed to install Ctrl+C handler");
};

#[cfg(unix)]
let terminate = async {
signal::unix::signal(signal::unix::SignalKind::terminate())
.expect("Failed to install signal handler")
.recv()
.await;
};

#[cfg(not(unix))]
let terminate = std::future::pending::<()>();

tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}

tracing::info!("Shutdown signal received, flushing telemetry...");
}

Database Instrumentation Patterns​

SQLx Query Instrumentation​

SQLx provides automatic tracing when the tracing feature is enabled:

Cargo.toml
[dependencies]
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "tracing"] }
#[instrument(name = "db.get_user", skip(pool))]
pub async fn get_user(pool: &PgPool, id: i64) -> Result<User, Error> {
sqlx::query_as!(User, "SELECT * FROM users WHERE id = $1", id)
.fetch_one(pool)
.await
.map_err(Into::into)
}

#[instrument(name = "db.create_user", skip(pool))]
pub async fn create_user(pool: &PgPool, input: &CreateUser) -> Result<User, Error> {
sqlx::query_as!(
User,
r#"
INSERT INTO users (email, name)
VALUES ($1, $2)
RETURNING *
"#,
input.email,
input.name
)
.fetch_one(pool)
.await
.map_err(Into::into)
}

Transaction Instrumentation​

Instrument database transactions:

#[instrument(name = "db.transfer_funds", skip(pool))]
pub async fn transfer_funds(
pool: &PgPool,
from_id: i64,
to_id: i64,
amount: f64,
) -> Result<(), Error> {
let mut tx = pool.begin().await?;

tracing::info!(from_id, to_id, amount, "Starting fund transfer");

sqlx::query!(
"UPDATE accounts SET balance = balance - $1 WHERE id = $2",
amount,
from_id
)
.execute(&mut *tx)
.await?;

sqlx::query!(
"UPDATE accounts SET balance = balance + $1 WHERE id = $2",
amount,
to_id
)
.execute(&mut *tx)
.await?;

tx.commit().await?;

tracing::info!(from_id, to_id, amount, "Fund transfer completed");

Ok(())
}

References​

Was this page helpful?