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 |
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 integration behavior. For questions or advanced integrations, contact the support team.
