Pular para o conteúdo principal
ImmutableLog logo
Voltar
QuarkusJAX-RS

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+):

xml
<!-- 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.

java
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:

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

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

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

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

java
@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.

java
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:

java
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 metaO que faz no SIEM
event_nameO 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 loginauthentication).
client_ipVira source.ip com precedência máxima e proveniência client_asserted. Ver a seção abaixo.
immutable_trailVira imtbl.immutable_trail — agrupa eventos relacionados para investigação (ex.: um pedido, um usuário).
typeSeveridade do evento: error / warning / info / success. Colore o badge na listagem. (event_type é aceito como sinônimo.)
service, env, request_idMetadados: 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.ip com 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.
Precedência de source.ip:client_ipX-Forwarded-For (1º hop)X-Real-IPIP de conexão

Proveniê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.

java
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

json
{
  "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.