Skip to main content
ImmutableLog logo
Back
RailsRuby

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

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

Middleware registration

Add the middleware to the Rack stack in `config/application.rb`. Use environment variables for credentials.

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

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

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

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

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

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 sends in a Thread, these codes matter mostly if you send events manually.

StatusMeaning
202Event accepted and queued (new)
200Idempotency-Key already exists — duplicate: true
400Missing/empty Idempotency-Key or invalid_immutable_trail
403Inactive/expired subscription, scope or retention
413payload_too_large (server limit: 16KB)
429monthly_limit_exceeded — do not retry
503mempool_full — transient, retryable

This documentation reflects the current integration behavior. For questions or advanced integrations, contact the support team.