Integração com Rails
Integre o ImmutableLog em qualquer aplicação Rails com um Rack middleware. Ele captura o status do retorno de `@app.call(env)` e envia o evento em uma Thread fire-and-forget — sem alterar nenhum controller.
Código do middleware
Salve em `app/middleware/immutable_log_middleware.rb`. O middleware lê request_id e a trilha do `env`, classifica o evento e envia em uma Thread. Query params passam por `redact`.
# app/middleware/immutable_log_middleware.rb
require "net/http"
require "json"
require "securerandom"
require "set"
require "time"
require "uri"
class ImmutableLogMiddleware
# Chaves cujos valores sao mascarados antes do envio (PII/segredos).
SENSITIVE = %w[
password pwd senha token access_token refresh_token api_key apikey
secret client_secret authorization auth credit_card card_number cvv cpf otp code
].freeze
def initialize(app, api_key:, service: "rails-service", env: "production",
api_url: nil, skip_paths: ["/health", "/up"])
@app = app
@api_key = api_key
@service = service
@env = env
@api_url = api_url || ENV.fetch("IMTBL_URL", "https://api.immutablelog.com")
@skip = skip_paths.to_set
end
def call(env)
return @app.call(env) if @skip.include?(env["PATH_INFO"])
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
status, headers, body = @app.call(env)
latency_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).to_i
request_id = env["HTTP_X_REQUEST_ID"] || SecureRandom.uuid
# fire-and-forget — nao bloqueia a resposta.
Thread.new { emit(env, status, latency_ms, request_id) }
[status, headers, body]
rescue StandardError => e
rid = env["HTTP_X_REQUEST_ID"] || SecureRandom.uuid
Thread.new { emit(env, 500, 0, rid, e) }
raise
end
private
def emit(env, status, latency_ms, request_id, exc = nil)
return if @api_key.to_s.empty?
kind = if exc || status >= 400 then "error"
elsif status >= 300 then "info"
elsif status >= 200 then "success"
else "info" end
method = env["REQUEST_METHOD"]
path = env["PATH_INFO"]
event_name = env["imtbl.event_name"] || "http.#{method}.#{path}"
query = Rack::Utils.parse_nested_query(env["QUERY_STRING"].to_s)
payload = {
"id" => SecureRandom.uuid,
"kind" => kind,
"message" => "#{method} #{path} -> #{status}",
"timestamp" => Time.now.utc.iso8601,
"context" => { "ip" => client_ip(env), "user_agent" => env["HTTP_USER_AGENT"] || "unknown" },
"request" => {
"request_id" => request_id, "method" => method, "path" => path,
# query params passam por redact() — nunca enviar segredos em claro.
"query_params" => query.empty? ? nil : redact(query),
},
"metrics" => { "latency_ms" => latency_ms, "status_code" => status },
"severity" => kind == "error" ? "high" : "low",
}
if kind == "error"
err = { "status_code" => status, "retryable" => [408, 429, 500, 502, 503, 504].include?(status) }
if exc
err["exception"] = exc.class.name
err["exception_message"] = exc.message.to_s[0, 500]
end
payload["error"] = err
end
# Trilha: env["imtbl.trail"] -> header X-Imtbl-Trail.
trail = self.class.sanitize_trail(env["imtbl.trail"] || env["HTTP_X_IMTBL_TRAIL"])
# Todos os valores de meta precisam ser strings.
meta = {
"type" => kind, "event_name" => event_name, "service" => @service,
"request_id" => request_id, "env" => @env,
}
meta["immutable_trail"] = trail if trail
post({ "payload" => JSON.generate(payload), "meta" => meta }, event_name, request_id, trail)
rescue StandardError
# Never let audit logging break the application.
end
def post(body, event_name, request_id, trail)
uri = URI.join(@api_url, "/v1/events")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = uri.scheme == "https"
http.open_timeout = 5
http.read_timeout = 5
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{@api_key}"
req["Content-Type"] = "application/json"
# Idempotency-Key e OBRIGATORIO (sem ele a API responde 400).
req["Idempotency-Key"] = "#{event_name}-#{request_id}"
req["Request-Id"] = request_id
req["X-Client-TZ"] = "America/Sao_Paulo"
req["X-Client-Offset-Minutes"] = "-180"
req["X-Imtbl-Trail"] = trail if trail
req.body = JSON.generate(body)
http.request(req)
end
def client_ip(env)
(env["HTTP_X_FORWARDED_FOR"] || env["REMOTE_ADDR"] || "unknown").split(",").first.to_s.strip
end
def redact(obj)
case obj
when Hash
obj.each_with_object({}) do |(k, v), h|
h[k] = SENSITIVE.include?(k.to_s.downcase) ? "***REDACTED***" : redact(v)
end
when Array then obj.map { |v| redact(v) }
else obj
end
end
# Normaliza o immutable_trail (trim, nao-vazio, max 256, sem ':').
def self.sanitize_trail(value)
return nil unless value.is_a?(String)
v = value.strip
return nil if v.empty?
v = v.tr(":", "-")
v = v[0, 256] if v.length > 256
v
end
endRegistro do middleware
Adicione o middleware na pilha Rack no `config/application.rb`. Use variáveis de ambiente para as credenciais.
# config/application.rb
require_relative "../app/middleware/immutable_log_middleware"
module MyApp
class Application < Rails::Application
# Registre o middleware na pilha Rack.
config.middleware.use ImmutableLogMiddleware,
api_key: ENV["IMTBL_API_KEY"],
service: "my-rails-app",
env: Rails.env.to_s
end
endNunca coloque o token no código. Use ENV["IMTBL_API_KEY"] (ex: via Rails credentials ou dotenv).
Como funciona
@app.call(env)
Executa a app; o retorno traz [status, headers, body]
Thread.new
Evento enviado em uma Thread — não bloqueia a resposta
rescue + raise
Exceções viram evento de erro e são re-lançadas
Evento e trilha customizados
Em qualquer controller, defina `request.env["imtbl.event_name"]` e `request.env["imtbl.trail"]`. O middleware lê esses valores no `env` ao montar o evento.
class PaymentsController < ApplicationController
def create
# Sobrescreve o nome do evento e agrupa numa trilha auditavel.
request.env["imtbl.event_name"] = "payment.created"
request.env["imtbl.trail"] = "order-7782"
# ... lógica de negócio / business logic ...
render json: { ok: true }
end
endPadrão automático: http.METHOD.path.
Trilha imutável (immutable_trail)
A trilha agrupa eventos relacionados em uma mesma linha do tempo auditável. Defina `request.env["imtbl.trail"]` em um controller, ou propague entre serviços com o header `X-Imtbl-Trail`. O valor passa por `sanitize_trail` antes do envio.
def run
request.env["imtbl.trail"] = "flow-#{params[:id]}"
# ... business logic ...
render json: { status: "ok" }
endA 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 envia em uma Thread, esses códigos importam principalmente se você enviar eventos manualmente.
| Status | Significado |
|---|---|
| 202 | Evento aceito e enfileirado (novo) |
| 200 | Idempotency-Key já existente — duplicate: true |
| 400 | Idempotency-Key ausente/vazia ou invalid_immutable_trail |
| 403 | Assinatura inativa/expirada, escopo ou retenção |
| 413 | payload_too_large (limite do servidor: 16KB) |
| 429 | monthly_limit_exceeded — não fazer retry |
| 503 | mempool_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.
