Pular para o conteúdo principal
ImmutableLog logo
Voltar
ActixRust

Integração com Actix

Integre o ImmutableLog em qualquer aplicação Actix Web com um middleware `Transform`/`Service` que captura automaticamente todas as requisições HTTP. O envio roda em `actix_web::rt::spawn` fire-and-forget — nunca bloqueia a resposta.

Dependências

Adicione as dependências abaixo ao seu `Cargo.toml`. O envio usa `reqwest` com timeout de 5s.

toml
[dependencies]
actix-web = "4"
reqwest = { version = "0.12", features = ["json"] }
serde_json = "1"
uuid = { version = "1", features = ["v4"] }

Código do middleware

Salve em `src/immutablelog.rs`. O middleware implementa `Transform` + `Service`. O status é lido quando o future resolve e o evento é enviado em uma task separada.

rust
// 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
}

Registro e Configuração

Registre o middleware com `.wrap()` na sua `App`. Como cada worker constrói o seu próprio `App`, o `Config` é criado por worker.

rust
// 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
}

Nunca coloque o token no código. Carregue IMTBL_API_KEY de variáveis de ambiente (ex: via dotenvy).

Como funciona

Transform

Fábrica que injeta o Config (Rc) no middleware por worker

service.call(req)

Executa o handler; o status sai na ServiceResponse

rt::spawn(emit)

Evento enviado em task separada — não bloqueia a resposta

Evento e trilha customizados

Um handler pode inserir `EventName` e `Trail` em `req.extensions_mut()`. O middleware lê esses marcadores via `res.request().extensions()` depois que o handler executa.

rust
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" }))
}

Padrão automático: http.METHOD.path.

Trilha imutável (immutable_trail)

A trilha agrupa eventos relacionados em uma mesma linha do tempo auditável. Insira `Trail(...)` via `req.extensions_mut()` em um handler, ou propague entre serviços com o header `X-Imtbl-Trail`. O valor passa por `sanitize_trail` antes do envio.

rust
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" }))
}

A trilha não pode ser vazia, exceder 256 caracteres ou conter `:` — violações retornam `400 invalid_immutable_trail`. O `sanitize_trail` corrige o valor antes do envio.

Resposta e códigos de status

A API de ingestão é assíncrona. Sucesso retorna 202 (novo) ou 200 (duplicata idempotente). Como o middleware é fire-and-forget, esses códigos importam principalmente se você enviar eventos manualmente.

StatusSignificado
202Evento aceito e enfileirado (novo)
200Idempotency-Key já existente — duplicate: true
400Idempotency-Key ausente/vazia ou invalid_immutable_trail
403Assinatura inativa/expirada, escopo ou retenção
413payload_too_large (limite do servidor: 16KB)
429monthly_limit_exceeded — não fazer retry
503mempool_full — transitório, pode dar retry

Esta documentação reflete o comportamento atual da integração. Para dúvidas ou integrações avançadas, entre em contato com o time de suporte.