Quarkus — ImmutableLog
Integre o ImmutableLog em aplicações Quarkus usando ContainerRequestFilter e ContainerResponseFilter do JAX-RS. O provider é descoberto automaticamente — zero configuração extra.
Instalação
O filtro usa apenas a API padrão JAX-RS presente no Quarkus. Para RESTEasy Reactive (padrão Quarkus 3+):
<!-- pom.xml -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-resteasy-reactive</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-client-reactive-jackson</artifactId>
</dependency>Jackson está disponível via quarkus-resteasy-reactive-jackson. O java.net.http.HttpClient está disponível no Java 11+ — sem dependências extras.
JAX-RS ContainerFilter
O Quarkus descobre automaticamente a classe anotada com @Provider. ContainerRequestFilter roda antes do endpoint; ContainerResponseFilter roda depois — com acesso ao status HTTP.
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Priority;
import jakarta.ws.rs.Priorities;
import jakarta.ws.rs.container.*;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.ext.Provider;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import java.io.IOException;
import java.net.URI;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.*;
@Provider
@Priority(Priorities.USER - 100)
public class ImmutableLogFilter implements ContainerRequestFilter, ContainerResponseFilter {
@ConfigProperty(name = "immutablelog.api-key")
String apiKey;
@ConfigProperty(name = "immutablelog.service-name", defaultValue = "my-service")
String serviceName;
@ConfigProperty(name = "immutablelog.env", defaultValue = "production")
String env;
@ConfigProperty(name = "immutablelog.url", defaultValue = "https://api.immutablelog.com")
String apiUrl;
private static final String START_KEY = "imtbl.startedAt";
private static final String REQ_ID_KEY = "imtbl.requestId";
private static final Set<String> SKIP_PATHS = Set.of("/health", "/q/health", "/ping");
private final ObjectMapper mapper = new ObjectMapper();
private final HttpClient http = HttpClient.newHttpClient();
@Override
public void filter(ContainerRequestContext req) {
String path = req.getUriInfo().getPath();
if (SKIP_PATHS.stream().anyMatch(path::startsWith)) return;
req.setProperty(START_KEY, System.currentTimeMillis());
req.setProperty(REQ_ID_KEY, UUID.randomUUID().toString());
}
@Override
public void filter(ContainerRequestContext req, ContainerResponseContext res) {
Long startedAt = (Long) req.getProperty(START_KEY);
if (startedAt == null) return; // skipped
String requestId = (String) req.getProperty(REQ_ID_KEY);
long latencyMs = System.currentTimeMillis() - startedAt;
int status = res.getStatus();
String method = req.getMethod();
String path = req.getUriInfo().getPath();
String customEvent = (String) req.getProperty("imtbl.eventName");
String eventName = customEvent != null
? customEvent
: method.toLowerCase() + "." + path.replace("/", ".").replaceAll("^\\.+", "");
String type = status >= 500 ? "error"
: status >= 400 ? "error"
: status >= 300 ? "info"
: "success";
emit(requestId, eventName, type, method, path, status, latencyMs, req, null);
}
private void emit(String requestId, String eventName, String type,
String method, String path, int status,
long latencyMs, ContainerRequestContext req, Throwable ex) {
try {
Map<String, Object> payloadMap = new LinkedHashMap<>();
payloadMap.put("method", method);
payloadMap.put("path", path);
payloadMap.put("status", status);
payloadMap.put("latency_ms", latencyMs);
payloadMap.put("client_ip", getClientIp(req));
payloadMap.put("user_agent", req.getHeaderString("User-Agent"));
if (ex != null) {
Map<String, Object> errMap = new LinkedHashMap<>();
errMap.put("type", ex.getClass().getName());
errMap.put("message", ex.getMessage());
errMap.put("retryable", !(ex instanceof IllegalArgumentException));
payloadMap.put("error", errMap);
}
// Trilha imutavel: propriedade imtbl.trail -> header X-Imtbl-Trail.
String trail = sanitizeTrail((String) req.getProperty("imtbl.trail"));
if (trail == null) trail = sanitizeTrail(req.getHeaderString("X-Imtbl-Trail"));
// Todos os valores de meta precisam ser strings.
Map<String, Object> meta = new LinkedHashMap<>();
meta.put("type", type);
meta.put("event_name", eventName);
meta.put("service", serviceName);
meta.put("request_id", requestId);
meta.put("env", env);
if (trail != null) meta.put("immutable_trail", trail);
Map<String, Object> body = Map.of(
"payload", mapper.writeValueAsString(payloadMap),
"meta", meta
);
String json = mapper.writeValueAsString(body);
var builder = HttpRequest.newBuilder()
.uri(URI.create(apiUrl + "/v1/events"))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + apiKey)
// Idempotency-Key e OBRIGATORIO (sem ele a API responde 400).
.header("Idempotency-Key", eventName + "-" + requestId)
.header("Request-Id", requestId)
.header("X-Client-TZ", "America/Sao_Paulo")
.header("X-Client-Offset-Minutes", "-180");
if (trail != null) builder.header("X-Imtbl-Trail", trail);
var httpReq = builder
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
http.sendAsync(httpReq, HttpResponse.BodyHandlers.discarding());
} catch (Exception ignored) {}
}
private String getClientIp(ContainerRequestContext req) {
String xff = req.getHeaderString("X-Forwarded-For");
if (xff != null && !xff.isBlank()) return xff.split(",")[0].trim();
// UriInfo doesn't expose remote addr in JAX-RS; inject @Context HttpServletRequest if needed
return "unknown";
}
// Normaliza o immutable_trail (trim, max 256, sem ':').
private String sanitizeTrail(String v) {
if (v == null) return null;
v = v.trim();
if (v.isEmpty()) return null;
v = v.replace(":", "-");
if (v.length() > 256) v = v.substring(0, 256);
return v;
}
}Configuração
Use MicroProfile Config (padrão no Quarkus) via application.properties:
# src/main/resources/application.properties
immutablelog.api-key=${IMTBL_API_KEY}
immutablelog.service-name=${IMTBL_SERVICE_NAME:my-service}
immutablelog.env=${IMTBL_ENV:production}
immutablelog.url=https://api.immutablelog.comComo funciona
1. ContainerRequestFilter (pré-requisição)
O filtro de request registra startedAt e um requestId como propriedades do contexto. Se o path estiver na lista de exclusão, nenhuma propriedade é setada.
2. ContainerResponseFilter (pós-resposta)
O filtro de response verifica se startedAt existe (se não, o path foi ignorado). Calcula latência, monta o payload e envia de forma assíncrona.
3. @Priority(USER - 100)
A prioridade garante que o filtro ImmutableLog roda antes dos filtros de autenticação/autorização da aplicação, capturando inclusive tentativas não autorizadas.
4. GraalVM Native Image
O filtro é compatível com compilação nativa do Quarkus (quarkus build --native). java.net.http.HttpClient e jackson-databind precisam de reflection hints — o Quarkus gera automaticamente para classes anotadas com @Provider.
ExceptionMapper
Para capturar exceções não tratadas e incluí-las no evento, use um ExceptionMapper que armazena a exceção como propriedade do request context antes de retornar a resposta de erro.
import jakarta.ws.rs.core.*;
import jakarta.ws.rs.ext.*;
/**
* ExceptionMapper enriches error events before the response filter runs.
* Set the exception on the request context so the response filter can include it.
*/
@Provider
public class ImmutableLogExceptionMapper implements ExceptionMapper<Exception> {
@Context
ContainerRequest request;
@Override
public Response toResponse(Exception ex) {
// Store exception for the response filter
request.setProperty("imtbl.exception", ex);
int status = 500;
if (ex instanceof NotFoundException) status = 404;
else if (ex instanceof BadRequestException) status = 400;
return Response.status(status)
.entity(Map.of("error", ex.getMessage()))
.type(MediaType.APPLICATION_JSON)
.build();
}
}Evento customizado
Injete ContainerRequestContext no resource e defina imtbl.eventName como propriedade para sobrescrever o nome gerado automaticamente.
import jakarta.ws.rs.*;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.core.*;
@Path("/payments")
public class PaymentResource {
@Context
ContainerRequestContext requestContext;
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public Response create(PaymentDto dto) {
// Override the auto-generated event name
requestContext.setProperty("imtbl.eventName", "payment.created");
// ... process payment ...
return Response.ok(result).build();
}
}Trilha imutável (immutable_trail)
A trilha agrupa eventos relacionados em uma mesma linha do tempo auditável. Defina imtbl.trail como propriedade do ContainerRequestContext, ou propague entre serviços com o header X-Imtbl-Trail.
@POST
@Path("/flows/{id}/run")
public Response runFlow(@PathParam("id") String id, @Context ContainerRequestContext ctx) {
ctx.setProperty("imtbl.trail", "flow-" + id);
// ... business logic ...
return Response.ok(Map.of("status", "ok")).build();
}A trilha não pode ser vazia, exceder 256 caracteres ou conter ":" — violações retornam 400 invalid_immutable_trail. O sanitizeTrail corrige o valor antes do envio.
RESTEasy Reactive
O filtro funciona igualmente com RESTEasy Reactive (Quarkus 3+). Resources retornam Uni/Multi — os filtros JAX-RS ainda são executados antes e depois de cada request.
import io.smallrye.mutiny.Uni;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.*;
/**
* With RESTEasy Reactive, filters work the same way but with reactive types.
* Use io.quarkus.vertx.http.runtime.filters.Filters for lower-level Vert.x filtering.
*/
@Path("/orders")
public class OrderResource {
@GET
@Path("/{id}")
public Uni<Response> get(@PathParam("id") Long id) {
return orderService.findById(id)
.map(order -> Response.ok(order).build());
}
}IP do cliente (modo servlet)
Para acessar o endereço remoto real no Quarkus no modo JVM com Undertow, injete HttpServletRequest via @Context:
import jakarta.servlet.http.HttpServletRequest;
import jakarta.ws.rs.container.*;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.ext.Provider;
@Provider
@Priority(Priorities.USER - 100)
public class ImmutableLogFilter implements ContainerRequestFilter, ContainerResponseFilter {
// Inject HttpServletRequest to access remote address
@Context
HttpServletRequest servletRequest;
private String getClientIp(ContainerRequestContext req) {
String xff = req.getHeaderString("X-Forwarded-For");
if (xff != null && !xff.isBlank()) return xff.split(",")[0].trim();
String xri = req.getHeaderString("X-Real-IP");
if (xri != null && !xri.isBlank()) return xri;
return servletRequest.getRemoteAddr();
}
}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.
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.
String clientIp(HttpServletRequest req) {
String xff = req.getHeader("X-Forwarded-For");
if (xff != null && !xff.isBlank()) {
return xff.split(",")[0].trim(); // primeiro hop = IP do usuario
}
return req.getRemoteAddr();
}
Map<String, String> meta = Map.of("event_name", "user.login", "client_ip", clientIp(req));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.
