Ruby
Guia completo de integração com o ImmutableLog em Ruby. Middleware automático para Rails (Rack) e Sinatra — ou um cliente HTTP direto com Net::HTTP para workers Sidekiq, rake tasks e scripts. O envio roda em uma Thread fire-and-forget.
Middlewares
Integração automática via middleware — toda requisição HTTP é capturada sem modificar nenhum controller. Clique no framework para ver o código completo.
Rack middleware inserido via config.middleware. Captura o status do retorno de @app.call(env) e envia em uma Thread.
def call(env)
started = now
status, headers, body = @app.call(env)
Thread.new { emit(env, status, latency(started)) }
[status, headers, body]
endVer documentação completa →
Filtros before/after + bloco error. Reutiliza o cliente ImmutableLog e envia o evento em uma Thread fire-and-forget.
after do
Thread.new do
settings.imtbl.send_event(
event_name: @imtbl_event_name, kind: kind, payload: payload)
end
endVer documentação completa →
Cliente HTTP direto (Net::HTTP)
Use a classe ImmutableLog para enviar eventos diretamente sem depender de um framework web. Ideal para workers Sidekiq, rake tasks e qualquer script Ruby que precise registrar eventos. Sem dependências além da stdlib.
# 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",
)
endPara não bloquear o request, envolva o send_event em uma Thread (fire-and-forget) ou despache para um worker Sidekiq/ActiveJob.
Retry com backoff exponencial
Para produção de alta disponibilidade, adicione retry com backoff exponencial. O ImmutableLog retorna 202 em sucesso e 200 em duplicata idempotente — nunca faça retry em 429 (limite mensal atingido).
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
endHash de dados sensíveis
Nunca envie dados pessoais brutos (e-mail, CPF, IP) para o ImmutableLog. Use SHA-256 para gerar um digest determinístico — rastreável sem expor o dado original.
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"),
}Campos de meta
Além do payload, o objeto meta carrega os campos que o SIEM usa para normalizar (ECS) e enriquecer o evento. Todos os valores de meta são strings.
| Campo em meta | O que faz no SIEM |
|---|---|
event_name | O rótulo do evento (ex.: http.GET.auth-me-user, Pagamento erro) — o identificador que aparece na listagem. Vira event.action e deriva event.category (ex.: nome com login → authentication). |
client_ip | Vira source.ip com precedência máxima e proveniência client_asserted. Ver a seção abaixo. |
immutable_trail | Vira imtbl.immutable_trail — agrupa eventos relacionados para investigação (ex.: um pedido, um usuário). |
type | Severidade do evento: error / warning / info / success. Colore o badge na listagem. (event_type é aceito como sinônimo.) |
service, env, request_id | Metadados: indexados e consultáveis no painel (busca/filtros). |
client_ip — IP do usuário final
O problema que resolve
Entre o navegador e o core há proxies/ALB. O IP que o core observa na conexão é o do salto anterior (o backend do cliente ou um proxy da AWS), não o do usuário. Sem ação, source.ip seria o IP do proxy — inútil para geo, threat e detecção por IP. (Exemplo real: o XFF chegava 44.192.13.3 (AWS) enquanto o usuário era 179.110.4.205.)
A solução
O backend do cliente é o único que enxerga o IP real do navegador — então ele encaminha esse IP em meta.client_ip ao chamar POST /v1/events.
Como o SIEM trata
- Valida que é um IP (v4/v6). Um valor não-IP é ignorado — não derruba o evento.
- Escreve em
source.ipcom precedência máxima (ver ordem abaixo). - Carimba
imtbl.source_ip_origin = "client_asserted"— a UI mostra o selo “IP do usuário”. - Alimenta GeoIP, threat intel e regras de detecção por IP.
client_ipX-Forwarded-For (1º hop)X-Real-IPIP de conexãoProveniência e confiança
client_ip é asserido pelo tenant — o backend do cliente afirma o IP, e é spoofável por quem controla aquele backend. Porém é auto-contido ao próprio tenant (não cruza a fronteira de outro tenant). Um hit de threat/geo sobre um client_asserted é uma alegação, não uma observação de borda.
Como obter o IP no seu backend
Pegue o primeiro IP do X-Forwarded-For (o mais próximo do usuário); no fallback, use o IP de conexão.
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) }Exemplo de payload
{
"payload": "{\"user\":\"bob\",\"action\":\"login\"}",
"meta": {
"event_name": "user.login",
"client_ip": "179.110.4.205",
"immutable_trail": "user-bob"
}
}Nota de PII: client_ip é PII e vai selado no bloco imutável (permanente). Decida conscientemente entre evidência forense e “direito ao esquecimento” antes de enviá-lo.
Esta documentação reflete o comportamento atual da API. Para dúvidas ou integrações avançadas, entre em contato com o time de suporte.
