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

meta fields

Beyond the payload, the meta object carries the fields the SIEM uses to normalize (ECS) and enrich the event. All meta values are strings.

Field in metaWhat it does in the SIEM
event_nameThe event label (e.g. http.GET.auth-me-user, Payment error) — the identifier shown in the listing. Becomes event.action and derives event.category (e.g. a name with loginauthentication).
client_ipBecomes source.ip with maximum precedence and provenance client_asserted. See the section below.
immutable_trailBecomes imtbl.immutable_trail — groups related events for investigation (e.g. one order, one user).
typeEvent severity: error / warning / info / success. Colors the badge in the listing. (event_type is accepted as a synonym.)
service, env, request_idMetadata: indexed and queryable in the dashboard (search/filters).

client_ip — end-user IP

The problem it solves

Between the browser and the core there are proxies/ALB. The IP the core sees on the connection is the previous hop (the client's backend or an AWS proxy), not the user's. Without action, source.ip would be the proxy IP — useless for geo, threat and IP-based detection. (Real example: XFF arrived as 44.192.13.3 (AWS) while the user was 179.110.4.205.)

The solution

The client's backend is the only one that sees the browser's real IP — so it forwards that IP in meta.client_ip when calling POST /v1/events.

How the SIEM handles it

  • Validates it is an IP (v4/v6). A non-IP value is ignored — it does not drop the event.
  • Writes to source.ip with maximum precedence (see order below).
  • Stamps imtbl.source_ip_origin = "client_asserted" — the UI shows the “User IP” badge.
  • Feeds GeoIP, threat intel and IP-based detection rules.
source.ip precedence:client_ipX-Forwarded-For (1º hop)X-Real-IPconnection IP

Provenance and trust

client_ip is asserted by the tenant — the client's backend claims the IP, and it is spoofable by whoever controls that backend. However it is self-contained to the tenant (it never crosses another tenant's boundary). A threat/geo hit on a client_asserted value is a claim, not an edge observation.

How to obtain the IP in your backend

Take the first IP from X-Forwarded-For (closest to the user); as a fallback, use the connection IP.

ruby
def client_ip(request)
  xff = request.get_header("HTTP_X_FORWARDED_FOR").to_s
  return xff.split(",").first.strip unless xff.empty? # primeiro hop
  request.ip
end

meta = { event_name: "user.login", client_ip: client_ip(request) }

Payload example

json
{
  "payload": "{\"user\":\"bob\",\"action\":\"login\"}",
  "meta": {
    "event_name": "user.login",
    "client_ip": "179.110.4.205",
    "immutable_trail": "user-bob"
  }
}

PII note: client_ip is PII and gets sealed into the immutable block (permanent). Decide consciously between forensic evidence and “right to be forgotten” before sending it.

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