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"),
});meta fields
Beyond the payload, the meta object carries the fields the SIEM uses to normalize (ECS) and enrich the event. All meta values are strings.
| Field in meta | What it does in the SIEM |
|---|---|
event_name | The event label (e.g. http.GET.auth-me-user, Payment error) — the identifier shown in the listing. Becomes event.action and derives event.category (e.g. a name with login → authentication). |
client_ip | Becomes source.ip with maximum precedence and provenance client_asserted. See the section below. |
immutable_trail | Becomes imtbl.immutable_trail — groups related events for investigation (e.g. one order, one user). |
type | Event severity: error / warning / info / success. Colors the badge in the listing. (event_type is accepted as a synonym.) |
service, env, request_id | Metadata: indexed and queryable in the dashboard (search/filters). |
client_ip — end-user IP
The problem it solves
Between the browser and the core there are proxies/ALB. The IP the core sees on the connection is the previous hop (the client's backend or an AWS proxy), not the user's. Without action, source.ip would be the proxy IP — useless for geo, threat and IP-based detection. (Real example: XFF arrived as 44.192.13.3 (AWS) while the user was 179.110.4.205.)
The solution
The client's backend is the only one that sees the browser's real IP — so it forwards that IP in meta.client_ip when calling POST /v1/events.
How the SIEM handles it
- Validates it is an IP (v4/v6). A non-IP value is ignored — it does not drop the event.
- Writes to
source.ipwith maximum precedence (see order below). - Stamps
imtbl.source_ip_origin = "client_asserted"— the UI shows the “User IP” badge. - Feeds GeoIP, threat intel and IP-based detection rules.
client_ipX-Forwarded-For (1º hop)X-Real-IPconnection IPProvenance and trust
client_ip is asserted by the tenant — the client's backend claims the IP, and it is spoofable by whoever controls that backend. However it is self-contained to the tenant (it never crosses another tenant's boundary). A threat/geo hit on a client_asserted value is a claim, not an edge observation.
How to obtain the IP in your backend
Take the first IP from X-Forwarded-For (closest to the user); as a fallback, use the connection IP.
fn client_ip(req: &HttpRequest) -> Option<String> {
if let Some(xff) = req.headers().get("x-forwarded-for") {
if let Ok(s) = xff.to_str() {
if let Some(first) = s.split(',').next() {
return Some(first.trim().to_string()); // primeiro hop
}
}
}
req.peer_addr().map(|a| a.ip().to_string())
}
let meta = json!({ "event_name": "user.login", "client_ip": client_ip(&req) });Payload example
{
"payload": "{\"user\":\"bob\",\"action\":\"login\"}",
"meta": {
"event_name": "user.login",
"client_ip": "179.110.4.205",
"immutable_trail": "user-bob"
}
}PII note: client_ip is PII and gets sealed into the immutable block (permanent). Decide consciously between forensic evidence and “right to be forgotten” before sending it.
This documentation reflects the current API behavior. For questions or advanced integrations, contact the support team.
