Skip to content
Maple Docs
Open app
Browse the docs
On this page

Rust instrumentation

Instrument a Rust application with OpenTelemetry and send traces, logs, and metrics to Maple.

This guide sets up the OpenTelemetry Rust SDK so your application sends traces, logs and metrics to Maple. Spans and log events come from the tracing crate, bridged to OpenTelemetry.

To have a coding agent do this setup, use the maple-onboard skill, and maple-audit to check an existing setup.

Prerequisites

  • Rust 1.75+
  • An ingest key from Settings → Ingestion in Maple. Use the private key (maple_sk_…) for server applications.

Install

Add these dependencies to Cargo.toml. The opentelemetry* crates must share one minor version, and tracing-opentelemetry must be the release built for it (0.32 pairs with 0.31). This guide pins 0.31, and the reqwest-tracing feature further down names the same version.

[dependencies]
opentelemetry = "0.31"
opentelemetry_sdk = "0.31"
opentelemetry-otlp = "0.31"
opentelemetry-appender-tracing = "0.31"
tracing-opentelemetry = "0.32"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tokio = { version = "1", features = ["full"] }

The default features of opentelemetry-otlp export OTLP over HTTP with protobuf encoding, using a blocking HTTP client on the SDK’s own background threads.

Configure

// src/telemetry.rs
use opentelemetry::{global, trace::TracerProvider as _, KeyValue};
use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge;
use opentelemetry_otlp::{LogExporter, MetricExporter, SpanExporter, WithExportConfig, WithHttpConfig};
use opentelemetry_sdk::{
    logs::SdkLoggerProvider, metrics::SdkMeterProvider, propagation::TraceContextPropagator,
    trace::SdkTracerProvider, Resource,
};
use std::collections::HashMap;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, Layer};

const MAPLE_ENDPOINT: &str = "https://ingest.maple.dev"; // EU: https://ingest.eu.maple.dev
const MAPLE_KEY: &str = "YOUR_INGEST_KEY";

pub struct Telemetry {
    tracer_provider: SdkTracerProvider,
    meter_provider: SdkMeterProvider,
    logger_provider: SdkLoggerProvider,
}

impl Telemetry {
    pub fn shutdown(&self) {
        let _ = self.tracer_provider.shutdown();
        let _ = self.meter_provider.shutdown();
        let _ = self.logger_provider.shutdown();
    }
}

pub fn init() -> Result<Telemetry, Box<dyn std::error::Error>> {
    let headers = HashMap::from([("Authorization".to_string(), format!("Bearer {MAPLE_KEY}"))]);

    let resource = Resource::builder()
        .with_service_name("my-rust-app")
        .with_attribute(KeyValue::new(
            "deployment.environment.name",
            std::env::var("DEPLOYMENT_ENV").unwrap_or_else(|_| "development".into()),
        ))
        .with_attribute(KeyValue::new(
            "vcs.repository.url.full",
            "https://github.com/acme/my-rust-app",
        ))
        .build();

    let tracer_provider = SdkTracerProvider::builder()
        .with_batch_exporter(
            SpanExporter::builder()
                .with_http()
                .with_endpoint(format!("{MAPLE_ENDPOINT}/v1/traces"))
                .with_headers(headers.clone())
                .build()?,
        )
        .with_resource(resource.clone())
        .build();
    global::set_tracer_provider(tracer_provider.clone());
    // Lets HTTP integrations read and write `traceparent` headers.
    global::set_text_map_propagator(TraceContextPropagator::new());

    let meter_provider = SdkMeterProvider::builder()
        .with_periodic_exporter(
            MetricExporter::builder()
                .with_http()
                .with_endpoint(format!("{MAPLE_ENDPOINT}/v1/metrics"))
                .with_headers(headers.clone())
                .build()?,
        )
        .with_resource(resource.clone())
        .build();
    global::set_meter_provider(meter_provider.clone());

    let logger_provider = SdkLoggerProvider::builder()
        .with_batch_exporter(
            LogExporter::builder()
                .with_http()
                .with_endpoint(format!("{MAPLE_ENDPOINT}/v1/logs"))
                .with_headers(headers)
                .build()?,
        )
        .with_resource(resource)
        .build();

    // Keep the exporter's own HTTP client logs out of the OpenTelemetry log pipeline.
    let otel_log_filter = EnvFilter::new("info")
        .add_directive("hyper=off".parse()?)
        .add_directive("h2=off".parse()?)
        .add_directive("reqwest=off".parse()?);

    tracing_subscriber::registry()
        .with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
        .with(tracing_subscriber::fmt::layer())
        .with(tracing_opentelemetry::layer().with_tracer(tracer_provider.tracer("my-rust-app")))
        .with(OpenTelemetryTracingBridge::new(&logger_provider).with_filter(otel_log_filter))
        .init();

    Ok(Telemetry { tracer_provider, meter_provider, logger_provider })
}

Call init from main, and shut down before exit so the last batches are sent:

mod telemetry;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let telemetry = telemetry::init()?;

    // Your application code here

    telemetry.shutdown();
    Ok(())
}

The example puts the endpoint and key in source. An ingest key can only write telemetry to your organization. It cannot read data or call the Maple API. Keeping it in source means the SDK always starts with a complete configuration, so a deploy that is missing an environment variable cannot silently turn telemetry off. To keep the key out of source, use environment variables instead.

Environment variables

When you build an exporter without with_endpoint and with_headers, it reads the standard OpenTelemetry variables, and Resource::builder() reads OTEL_SERVICE_NAME and OTEL_RESOURCE_ATTRIBUTES:

export OTEL_SERVICE_NAME="my-rust-app"
export OTEL_EXPORTER_OTLP_ENDPOINT="https://ingest.maple.dev"
export OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf"
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer YOUR_INGEST_KEY"
export OTEL_RESOURCE_ATTRIBUTES="deployment.environment.name=production,vcs.repository.url.full=https://github.com/acme/my-rust-app"

With OTEL_EXPORTER_OTLP_ENDPOINT set to the base URL, each exporter appends its own signal path.

Auto-instrumentation

Rust has no automatic library instrumentation. You add a tracing integration for each crate you use, and the bridge above turns its spans into OpenTelemetry spans.

Axum and Tower

[dependencies]
axum = "0.8"
tower-http = { version = "0.6", features = ["trace"] }
use axum::{routing::get, Router};
use tower_http::trace::TraceLayer;

let app = Router::new()
    .route("/api/orders", get(handle_orders))
    .layer(TraceLayer::new_for_http());

TraceLayer opens a tracing span for every request. It does not read incoming traceparent headers or set OpenTelemetry HTTP attributes. For that, use a crate built for it, such as axum-tracing-opentelemetry.

reqwest

[dependencies]
reqwest = "0.13"
reqwest-middleware = "0.5"
reqwest-tracing = { version = "0.7", features = ["opentelemetry_0_31"] }
use reqwest_middleware::ClientBuilder;
use reqwest_tracing::TracingMiddleware;

let client = ClientBuilder::new(reqwest::Client::new())
    .with(TracingMiddleware::default())
    .build();

reqwest-tracing injects the trace context into outgoing requests. Its opentelemetry_0_31 feature must match your opentelemetry version; change both together when you upgrade.

Custom spans

The #[instrument] attribute opens a span for each call:

use tracing::instrument;

#[instrument(skip(payment_client), fields(order.id = %order_id, payment.method = "card"))]
async fn process_order(payment_client: &PaymentClient, order_id: String) -> Result<(), PaymentError> {
    payment_client.charge(&order_id).await?;
    Ok(())
}

Service map edges come from instrumented client spans that propagate traceparent to an instrumented callee, not from attributes such as peer.service. See Service map.

For a span around a block:

use tracing::{info_span, Instrument};

async fn process_order(order_id: String) {
    let span = info_span!("process-order", order.id = %order_id);
    async move {
        charge_payment(&order_id).await;
    }
    .instrument(span)
    .await;
}

Log correlation

tracing events go to the OpenTelemetryTracingBridge layer as OpenTelemetry log records. Events emitted inside a span carry its trace and span IDs:

use tracing::{error, info};

#[tracing::instrument]
async fn process_order(order_id: String) {
    info!(order.id = %order_id, "processing order");

    if let Err(e) = charge_payment(&order_id).await {
        error!(error = %e, "payment failed");
    }
}

Verify

  1. Start your application and send it a few requests.
  2. In Maple, open Explore → Traces. The SDK sends spans in batches every 5 seconds by default, and metrics every 60 seconds.
  3. Each request should show up as one trace with a single root span for the request, and child spans for the functions you instrumented.

Your service also appears on the Services page once its first spans arrive.

Troubleshooting

  • 401 responses. The key is wrong, was copied from the other region, or the header is malformed. The header must be Authorization: Bearer YOUR_INGEST_KEY. In OTEL_EXPORTER_OTLP_HEADERS it is written Authorization=Bearer YOUR_INGEST_KEY. See Ingest API status codes.
  • Wrong protocol or path. Maple accepts OTLP over HTTP. Build exporters with .with_http(), not .with_tonic(). with_endpoint takes the full URL including /v1/traces; OTEL_EXPORTER_OTLP_ENDPOINT takes only the base URL.
  • Network. From the machine running the app, run curl -i https://ingest.maple.dev/v1/traces -X POST. Any HTTP status code means the host can reach Maple. A timeout or DNS error means a firewall or proxy is blocking outbound HTTPS.
  • Nothing exported. Shut the providers down before the process exits so buffered data is sent. If the build fails with trait errors between tracing-opentelemetry and opentelemetry, the versions do not match.

Next steps