Pular para o conteúdo principal
ImmutableLog logo
Voltar
RailsRuby

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`.

ruby
# 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
end

Registro do middleware

Adicione o middleware na pilha Rack no `config/application.rb`. Use variáveis de ambiente para as credenciais.

ruby
# 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
end

Nunca 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.

ruby
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
end

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. 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.

ruby
def run
  request.env["imtbl.trail"] = "flow-#{params[:id]}"
  # ... business logic ...
  render json: { status: "ok" }
end

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 envia em uma Thread, 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.