Rails Integration
Integrate ImmutableLog into any Rails application with a Rack middleware. It captures the status from `@app.call(env)` and sends the event in a fire-and-forget Thread — without changing any controller.
Middleware code
Save it in `app/middleware/immutable_log_middleware.rb`. The middleware reads request_id and the trail from `env`, classifies the event, and sends it in a Thread. Query params go through `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
endMiddleware registration
Add the middleware to the Rack stack in `config/application.rb`. Use environment variables for credentials.
# 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
endNever hardcode the token. Use ENV["IMTBL_API_KEY"] (e.g. via Rails credentials or dotenv).
How it works
@app.call(env)
Runs the app; the return gives [status, headers, body]
Thread.new
Event sent in a Thread — never blocks the response
rescue + raise
Exceptions become an error event and are re-raised
Custom event and trail
In any controller, set `request.env["imtbl.event_name"]` and `request.env["imtbl.trail"]`. The middleware reads these values from `env` when building the event.
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
endAuto default: http.METHOD.path.
Immutable trail (immutable_trail)
A trail groups related events into a single auditable timeline. Set `request.env["imtbl.trail"]` in a controller, or propagate it across services with the `X-Imtbl-Trail` header. The value goes through `sanitize_trail` before sending.
def run
request.env["imtbl.trail"] = "flow-#{params[:id]}"
# ... business logic ...
render json: { status: "ok" }
endThe 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 sends in a Thread, 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.
