Rust
Complete integration guide for ImmutableLog in Rust. Automatic middleware for Axum and Actix — or a direct reqwest HTTP client for workers, queue consumers, and jobs. Sending runs in a fire-and-forget tokio::spawn, adding no latency to the response.
Middlewares
Automatic integration via middleware — every HTTP request is captured without modifying any handler. Click the framework to see the complete code.
Middleware with axum::middleware::from_fn. Captures the StatusCode after next.run(req), resolves the trail, and sends via tokio::spawn.
pub async fn audit(
State(cfg): State<Arc<Config>>,
req: Request,
next: Next,
) -> Response {
let started = Instant::now();
let res = next.run(req).await;
tokio::spawn(emit(cfg, res.status(), started.elapsed()));
res
}View full documentation →
Middleware via Transform + Service. Captures the status when the future resolves and fires the send with actix_web::rt::spawn.
fn call(&self, req: ServiceRequest) -> Self::Future {
let started = Instant::now();
let fut = self.service.call(req);
Box::pin(async move {
let res = fut.await?;
actix_web::rt::spawn(emit(res.status(), started.elapsed()));
Ok(res)
})
}View full documentation →
Direct HTTP client
Use the Client to send events directly to ImmutableLog without depending on a web framework. Ideal for workers, queue consumers, cron jobs, and any async Rust code that needs to record events.
// Cargo.toml:
// reqwest = { version = "0.12", features = ["json"] }
// tokio = { version = "1", features = ["full"] }
// serde_json = "1"
// uuid = { version = "1", features = ["v4"] }
use std::collections::HashMap;
use std::time::Duration;
use serde_json::json;
use uuid::Uuid;
pub struct Client {
api_key: String,
api_url: String,
service: String,
env: String,
http: reqwest::Client,
}
impl Client {
pub fn new(api_key: impl Into<String>, service: impl Into<String>, env: impl Into<String>) -> Self {
Self {
api_key: api_key.into(),
api_url: "https://api.immutablelog.com".to_string(),
service: service.into(),
env: env.into(),
http: reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()
.expect("build http client"),
}
}
pub async fn send_event(
&self,
event_name: &str,
kind: &str, // "success" | "info" | "error"
payload: serde_json::Value,
trail: Option<&str>,
) -> Result<(), reqwest::Error> {
let request_id = Uuid::new_v4().to_string();
let trail = trail.and_then(sanitize_trail);
// 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.to_string());
meta.insert("service", self.service.clone());
meta.insert("request_id", request_id.clone());
meta.insert("env", self.env.clone());
if let Some(t) = &trail {
meta.insert("immutable_trail", t.clone());
}
let event = json!({ "payload": payload.to_string(), "meta": meta });
let mut req = self
.http
.post(format!("{}/v1/events", self.api_url))
.bearer_auth(&self.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", "America/Sao_Paulo")
.header("X-Client-Offset-Minutes", "-180");
if let Some(t) = &trail {
req = req.header("X-Imtbl-Trail", t);
}
req.json(&event).send().await?;
Ok(())
}
}
// Normaliza o immutable_trail (trim, nao-vazio, max 256, sem ':').
// O servidor responde 400 invalid_immutable_trail se a regra for violada.
pub 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)
}
// Uso (fire-and-forget via tokio::spawn):
// let client = Arc::new(Client::new(std::env::var("IMTBL_API_KEY")?, "my-service", "production"));
// let c = client.clone();
// tokio::spawn(async move {
// let _ = c.send_event("user.created", "success",
// json!({ "user_id": "usr_123", "plan": "pro" }), Some("order-7782")).await;
// });Wrap the Client in an Arc to share it across tasks. send_event respects the reqwest::Client 5s timeout; use tokio::spawn to fire without awaiting.
Retry with exponential backoff
For high-availability production, add retry with exponential backoff. ImmutableLog returns 202 on success and 200 on an idempotent duplicate — never retry on 429 (monthly limit reached).
// Retry em erros transitorios (nunca em 429).
// Retries transient errors (never on 429).
impl Client {
pub async fn send_with_retry(
&self,
event_name: &str,
kind: &str,
payload: serde_json::Value,
trail: Option<&str>,
max_attempts: u32,
) -> Result<(), reqwest::Error> {
let mut delay = Duration::from_millis(500);
let mut last_err = None;
for attempt in 0..max_attempts {
match self.send_event(event_name, kind, payload.clone(), trail).await {
Ok(()) => return Ok(()),
Err(e) => {
// 429 = limite mensal — nao fazer retry.
if e.status().map(|s| s.as_u16()) == Some(429) {
return Err(e);
}
last_err = Some(e);
if attempt + 1 < max_attempts {
tokio::time::sleep(delay).await;
delay *= 2; // 500ms -> 1s -> 2s
}
}
}
}
Err(last_err.expect("at least one attempt"))
}
}Sensitive data hashing
Never send raw personal data (email, CPF, IP) to ImmutableLog. Use SHA-256 to generate a deterministic digest — traceable without exposing the original data.
// Cargo.toml: sha2 = "0.10", hex = "0.4"
use sha2::{Digest, Sha256};
// Faz o hash de dados sensiveis antes de enviar — nunca logue PII bruto.
fn sha256_hex(data: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(data);
hex::encode(hasher.finalize())
}
// Uso no payload do evento:
let payload = serde_json::json!({
"user_id": "usr_123",
"email_hash": sha256_hex(b"user@example.com"),
"ip_hash": sha256_hex(b"192.168.1.1"),
});This documentation reflects the current API behavior. For questions or advanced integrations, contact the support team.
