Skip to main content
ImmutableLog logo
BackRuby

Ruby

Complete integration guide for ImmutableLog in Ruby. Automatic middleware for Rails (Rack) and Sinatra — or a direct Net::HTTP client for Sidekiq workers, rake tasks, and scripts. Sending runs in a fire-and-forget Thread.

Direct HTTP client (Net::HTTP)

Use the ImmutableLog class to send events directly without depending on a web framework. Ideal for Sidekiq workers, rake tasks, and any Ruby script that needs to record events. No dependencies beyond stdlib.

ruby
# lib/immutable_log.rb
require "net/http"
require "json"
require "securerandom"
require "time"
require "uri"

class ImmutableLog
  def initialize(api_key:, service: "ruby-service", env: "production", api_url: nil)
    @api_key = api_key
    @service = service
    @env = env
    @api_url = api_url || ENV.fetch("IMTBL_URL", "https://api.immutablelog.com")
  end

  def send_event(event_name:, kind:, payload:, immutable_trail: nil)
    request_id = SecureRandom.uuid
    trail = self.class.sanitize_trail(immutable_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

    body = {
      "payload" => JSON.generate(payload.merge("timestamp" => Time.now.utc.iso8601)),
      "meta" => meta,
    }

    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

  # Normaliza o immutable_trail (trim, nao-vazio, max 256, sem ':').
  # O servidor responde 400 invalid_immutable_trail se a regra for violada.
  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

# Uso (fire-and-forget via Thread):
log = ImmutableLog.new(api_key: ENV["IMTBL_API_KEY"], service: "payments-service")
Thread.new do
  log.send_event(
    event_name: "payment.approved", kind: "success",
    payload: { "payment_id" => "pay_abc123", "amount" => 299.90 },
    immutable_trail: "order-7782",
  )
end

To avoid blocking the request, wrap send_event in a Thread (fire-and-forget) or dispatch it to a Sidekiq/ActiveJob worker.

Retry with exponential backoff

For high-availability production, add retry with exponential backoff. ImmutableLog returns 202 on success and 200 on an idempotent duplicate — never retry on 429 (monthly limit reached).

ruby
def send_with_retry(event_name:, kind:, payload:, immutable_trail: nil, max_attempts: 3)
  delay = 0.5

  max_attempts.times do |attempt|
    res = send_event(event_name: event_name, kind: kind,
                     payload: payload, immutable_trail: immutable_trail)
    code = res.code.to_i
    return res if code < 400

    # 429 = limite mensal — nao fazer retry.
    return res if code == 429

    if attempt + 1 < max_attempts
      sleep(delay)
      delay *= 2 # 0.5s -> 1s -> 2s
    end
  end
  nil
rescue StandardError
  nil
end

Sensitive data hashing

Never send raw personal data (email, CPF, IP) to ImmutableLog. Use SHA-256 to generate a deterministic digest — traceable without exposing the original data.

ruby
require "digest"

# Faz o hash de dados sensiveis antes de enviar — nunca logue PII bruto.
def sha256_hex(data)
  Digest::SHA256.hexdigest(data)
end

# Uso no payload do evento:
payload = {
  "user_id"    => "usr_123",
  "email_hash" => sha256_hex("user@example.com"),
  "ip_hash"    => sha256_hex("192.168.1.1"),
}

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 API behavior. For questions or advanced integrations, contact the support team.