Actix Integration
Integrate ImmutableLog into any Actix Web application with a `Transform`/`Service` middleware that automatically captures all HTTP requests. Sending runs in a fire-and-forget `actix_web::rt::spawn` — it never blocks the response.
Dependencies
Add the dependencies below to your `Cargo.toml`. Sending uses `reqwest` with a 5s timeout.
[dependencies]
actix-web = "4"
reqwest = { version = "0.12", features = ["json"] }
serde_json = "1"
uuid = { version = "1", features = ["v4"] }Middleware code
Save it in `src/immutablelog.rs`. The middleware implements `Transform` + `Service`. The status is read when the future resolves and the event is sent in a separate task.
// src/immutablelog.rs
use std::collections::HashMap;
use std::future::{ready, Future, Ready};
use std::pin::Pin;
use std::rc::Rc;
use std::time::{Duration, Instant};
use actix_web::{
dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform},
Error, HttpMessage,
};
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()),
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 insere via req.extensions_mut().
#[derive(Clone)]
pub struct EventName(pub String);
#[derive(Clone)]
pub struct Trail(pub String);
// Normaliza o immutable_trail (trim, nao-vazio, max 256, sem ':').
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" }
}
// Factory (Transform)
pub struct ImmutableLog { cfg: Rc<Config> }
impl ImmutableLog {
pub fn new(cfg: Config) -> Self { Self { cfg: Rc::new(cfg) } }
}
impl<S, B> Transform<S, ServiceRequest> for ImmutableLog
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = Error;
type Transform = ImmutableLogMiddleware<S>;
type InitError = ();
type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
ready(Ok(ImmutableLogMiddleware { service: Rc::new(service), cfg: self.cfg.clone() }))
}
}
pub struct ImmutableLogMiddleware<S> { service: Rc<S>, cfg: Rc<Config> }
impl<S, B> Service<ServiceRequest> for ImmutableLogMiddleware<S>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = Error;
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>>>>;
forward_ready!(service);
fn call(&self, req: ServiceRequest) -> Self::Future {
let started = Instant::now();
let cfg = self.cfg.clone();
let service = self.service.clone();
let method = req.method().to_string();
let path = req.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);
Box::pin(async move {
let res = service.call(req).await?;
let status = res.status().as_u16();
// Handler pode ter inserido EventName/Trail via req.extensions_mut().
let event_name;
let route_trail;
{
let exts = res.request().extensions();
event_name = exts.get::<EventName>().map(|e| e.0.clone());
route_trail = exts.get::<Trail>().map(|tr| tr.0.clone());
}
actix_web::rt::spawn(emit(
cfg, method, path, status, started.elapsed(),
request_id, event_name, route_trail.or(header_trail),
));
Ok(res)
})
}
}
async fn emit(
cfg: Rc<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),
});
}
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 `.wrap()` on your `App`. Since each worker builds its own `App`, the `Config` is created per worker.
// src/main.rs
use actix_web::{web, App, HttpServer, Responder};
mod immutablelog;
use immutablelog::{Config, ImmutableLog};
#[actix_web::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(|| {
App::new()
// Cada worker constrói o seu App — Config criado por worker.
.wrap(ImmutableLog::new(Config::from_env("my-actix-service")))
.route("/users", web::get().to(|| async { "ok" }))
.route("/payments", web::post().to(payments))
})
.bind(("0.0.0.0", 8080))?
.run()
.await
}Never hardcode the token. Load IMTBL_API_KEY from environment variables (e.g. via dotenvy).
How it works
Transform
Factory that injects the Config (Rc) into the middleware per worker
service.call(req)
Runs the handler; the status comes out on the ServiceResponse
rt::spawn(emit)
Event sent in a separate task — never blocks the response
Custom event and trail
A handler can insert `EventName` and `Trail` into `req.extensions_mut()`. The middleware reads these markers via `res.request().extensions()` after the handler runs.
use actix_web::{web, HttpMessage, HttpRequest, Responder};
use serde_json::json;
use immutablelog::{EventName, Trail};
async fn payments(req: HttpRequest) -> impl Responder {
// Sobrescreve o nome do evento e agrupa numa trilha auditavel.
req.extensions_mut().insert(EventName("payment.created".into()));
req.extensions_mut().insert(Trail("order-7782".into()));
web::Json(json!({ "ok": true, "payment_id": "pay_123" }))
}Auto default: http.METHOD.path.
Immutable trail (immutable_trail)
A trail groups related events into a single auditable timeline. Insert `Trail(...)` via `req.extensions_mut()` in a handler, or propagate it across services with the `X-Imtbl-Trail` header. The value goes through `sanitize_trail` before sending.
async fn run_flow(req: HttpRequest, path: web::Path<String>) -> impl Responder {
req.extensions_mut().insert(Trail(format!("flow-{}", path.into_inner())));
web::Json(json!({ "status": "ok" }))
}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.
| Status | Meaning |
|---|---|
| 202 | Event accepted and queued (new) |
| 200 | Idempotency-Key already exists — duplicate: true |
| 400 | Missing/empty Idempotency-Key or invalid_immutable_trail |
| 403 | Inactive/expired subscription, scope or retention |
| 413 | payload_too_large (server limit: 16KB) |
| 429 | monthly_limit_exceeded — do not retry |
| 503 | mempool_full — transient, retryable |
This documentation reflects the current integration behavior. For questions or advanced integrations, contact the support team.
