Skip to main content
ImmutableLog logo
Back
AxumRust

Axum Integration

Integrate ImmutableLog into any Axum application with a `from_fn_with_state` middleware that automatically captures all HTTP requests. Sending runs in a fire-and-forget `tokio::spawn` — it never blocks the response.

Dependencies

Add the dependencies below to your `Cargo.toml`. Sending uses `reqwest` with a 5s timeout.

toml
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", features = ["json"] }
serde_json = "1"
uuid = { version = "1", features = ["v4"] }

Middleware code

Save it in `src/immutablelog.rs`. The middleware reads `request_id` and the trail from headers before `next.run(req)`, captures the status on the response, and sends the event in a separate task.

rust
// src/immutablelog.rs
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};

use axum::{extract::{Request, State}, middleware::Next, response::Response};
use serde_json::json;
use uuid::Uuid;

// Margem de seguranca: o limite real do servidor e 16KB (retorna 413 acima disso).
const MAX_PAYLOAD_BYTES: usize = 12_000;

#[derive(Clone)]
pub struct Config {
    pub api_key: String,
    pub api_url: String,
    pub service: String,
    pub env: String,
    pub client_tz: String,
    pub client_offset_minutes: i32,
    pub http: reqwest::Client,
}

impl Config {
    pub fn from_env(service: &str) -> Self {
        Self {
            api_key: std::env::var("IMTBL_API_KEY").unwrap_or_default(),
            api_url: std::env::var("IMTBL_URL").unwrap_or_else(|_| "https://api.immutablelog.com".into()),
            service: service.to_string(),
            env: std::env::var("IMTBL_ENV").unwrap_or_else(|_| "production".into()),
            // Timezone do cliente — usado pelo dashboard para exibir os timestamps.
            client_tz: "America/Sao_Paulo".to_string(),
            client_offset_minutes: -180,
            http: reqwest::Client::builder()
                .timeout(Duration::from_secs(5))
                .build()
                .expect("build http client"),
        }
    }
}

// Marcadores opcionais que um handler anexa na resposta.
#[derive(Clone)]
pub struct EventName(pub String);
#[derive(Clone)]
pub struct Trail(pub String);

// Normaliza o immutable_trail (trim, nao-vazio, max 256, sem ':').
// O servidor responde 400 invalid_immutable_trail se a regra for violada.
fn sanitize_trail(v: &str) -> Option<String> {
    let mut s = v.trim().replace(':', "-");
    if s.is_empty() { return None; }
    if s.chars().count() > 256 { s = s.chars().take(256).collect(); }
    Some(s)
}

fn kind_from(status: u16) -> &'static str {
    if status >= 400 { "error" }
    else if status >= 300 { "info" }
    else if status >= 200 { "success" }
    else { "info" }
}

// Registre com: middleware::from_fn_with_state(cfg.clone(), audit)
pub async fn audit(State(cfg): State<Arc<Config>>, req: Request, next: Next) -> Response {
    let started = Instant::now();
    let method = req.method().to_string();
    let path = req.uri().path().to_string();

    let request_id = req
        .headers()
        .get("x-request-id")
        .and_then(|v| v.to_str().ok())
        .map(str::to_string)
        .unwrap_or_else(|| Uuid::new_v4().to_string());
    let header_trail = req
        .headers()
        .get("x-imtbl-trail")
        .and_then(|v| v.to_str().ok())
        .and_then(sanitize_trail);

    let res = next.run(req).await;
    let status = res.status().as_u16();

    // Handler pode anexar EventName/Trail nas extensions da resposta.
    let event_name = res.extensions().get::<EventName>().map(|e| e.0.clone());
    let route_trail = res.extensions().get::<Trail>().map(|tr| tr.0.clone());

    tokio::spawn(emit(
        cfg, method, path, status, started.elapsed(),
        request_id, event_name, route_trail.or(header_trail),
    ));

    res
}

async fn emit(
    cfg: Arc<Config>, method: String, path: String, status: u16, elapsed: Duration,
    request_id: String, event_name: Option<String>, trail: Option<String>,
) {
    if cfg.api_key.is_empty() { return; }

    let kind = kind_from(status);
    let event_name = event_name.unwrap_or_else(|| format!("http.{}.{}", method, path));
    let trail = trail.and_then(|t| sanitize_trail(&t));

    let mut payload = json!({
        "id": Uuid::new_v4().to_string(),
        "kind": kind,
        "message": format!("{} {} -> {}", method, path, status),
        "request": { "request_id": request_id, "method": method, "path": path },
        "metrics": { "latency_ms": elapsed.as_millis() as u64, "status_code": status },
        "severity": if kind == "error" { "high" } else { "low" },
    });
    if kind == "error" {
        payload["error"] = json!({
            "status_code": status,
            "retryable": [408, 429, 500, 502, 503, 504].contains(&status),
        });
    }

    // clamp: remove o campo grande se passar do limite.
    let mut payload_str = payload.to_string();
    if payload_str.len() > MAX_PAYLOAD_BYTES {
        if let Some(obj) = payload.as_object_mut() { obj.remove("error"); }
        payload_str = payload.to_string();
    }

    // Todos os valores de meta precisam ser strings.
    let mut meta: HashMap<&str, String> = HashMap::new();
    meta.insert("type", kind.to_string());
    meta.insert("event_name", event_name.clone());
    meta.insert("service", cfg.service.clone());
    meta.insert("request_id", request_id.clone());
    meta.insert("env", cfg.env.clone());
    if let Some(t) = &trail { meta.insert("immutable_trail", t.clone()); }

    let event = json!({ "payload": payload_str, "meta": meta });

    let mut builder = cfg.http
        .post(format!("{}/v1/events", cfg.api_url))
        .bearer_auth(&cfg.api_key)
        .header("Content-Type", "application/json")
        // Idempotency-Key e OBRIGATORIO (sem ele a API responde 400).
        .header("Idempotency-Key", format!("{}-{}", event_name, request_id))
        .header("Request-Id", &request_id)
        .header("X-Client-TZ", &cfg.client_tz)
        .header("X-Client-Offset-Minutes", cfg.client_offset_minutes.to_string());
    if let Some(t) = &trail { builder = builder.header("X-Imtbl-Trail", t); }

    let _ = builder.json(&event).send().await; // fire-and-forget
}

Registration and Configuration

Register the middleware with `from_fn_with_state`, passing the shared `Config` via `Arc`. Use environment variables for credentials.

rust
// src/main.rs
use std::sync::Arc;
use axum::{middleware, routing::{get, post}, Router};

mod immutablelog;
use immutablelog::{audit, Config};

#[tokio::main]
async fn main() {
    let cfg = Arc::new(Config::from_env("my-axum-service"));

    let app = Router::new()
        .route("/users", get(|| async { "ok" }))
        .route("/payments", post(payments_handler))
        // Registre o middleware com o Config compartilhado via Arc.
        .layer(middleware::from_fn_with_state(cfg.clone(), audit));

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

Never hardcode the token. Load IMTBL_API_KEY from environment variables (e.g. via dotenvy).

How it works

from_fn_with_state

Reads request_id and trail from headers before next.run

next.run(req).await

Runs the chain; the status comes out on the Response

tokio::spawn(emit)

Event sent in a separate task — never blocks the response

Custom event and trail

Since the middleware reads the response after `next.run`, a handler can attach `EventName` and `Trail` to the response extensions. The middleware reads these markers when emitting the event.

rust
use axum::{response::{IntoResponse, Response}, Json};
use serde_json::json;
use immutablelog::{EventName, Trail};

async fn payments_handler() -> Response {
    let mut res = Json(json!({ "ok": true, "payment_id": "pay_123" })).into_response();

    // Sobrescreve o nome do evento e agrupa numa trilha auditavel.
    res.extensions_mut().insert(EventName("payment.created".into()));
    res.extensions_mut().insert(Trail("order-7782".into()));

    res
}

Auto default: http.METHOD.path.

Immutable trail (immutable_trail)

A trail groups related events into a single auditable timeline. Attach `Trail(...)` to a handler's response, or propagate it across services with the `X-Imtbl-Trail` header. The value goes through `sanitize_trail` before sending.

rust
async fn run_flow(/* ... */) -> Response {
    let mut res = Json(json!({ "status": "ok" })).into_response();
    res.extensions_mut().insert(Trail("flow-123".into()));
    res
}

The trail cannot be empty, exceed 256 characters, or contain `:` — violations return `400 invalid_immutable_trail`. `sanitize_trail` fixes the value before sending.

Response and status codes

The ingestion API is asynchronous. Success returns 202 (new) or 200 (idempotent duplicate). Since the middleware is fire-and-forget, these codes matter mostly if you send events manually.

StatusMeaning
202Event accepted and queued (new)
200Idempotency-Key already exists — duplicate: true
400Missing/empty Idempotency-Key or invalid_immutable_trail
403Inactive/expired subscription, scope or retention
413payload_too_large (server limit: 16KB)
429monthly_limit_exceeded — do not retry
503mempool_full — transient, retryable

This documentation reflects the current integration behavior. For questions or advanced integrations, contact the support team.