Skip to main content
ImmutableLog logo
Back
QuarkusJAX-RS

Quarkus — ImmutableLog

Integrate ImmutableLog into Quarkus applications using JAX-RS ContainerRequestFilter and ContainerResponseFilter. The provider is discovered automatically — zero extra configuration.

Installation

The filter uses only the standard JAX-RS API present in Quarkus. For RESTEasy Reactive (default in 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 is available via quarkus-resteasy-reactive-jackson. The java.net.http.HttpClient is available in Java 11+ — no extra dependencies.

JAX-RS ContainerFilter

Quarkus automatically discovers the class annotated with @Provider. ContainerRequestFilter runs before the endpoint; ContainerResponseFilter runs after — with access to the HTTP status.

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;
    }
}

Configuration

Use MicroProfile Config (standard in 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

How it works

1. ContainerRequestFilter (pre-request)

The request filter records startedAt and a requestId as context properties. If the path is in the exclusion list, no property is set.

2. ContainerResponseFilter (post-response)

The response filter checks if startedAt exists (if not, the path was skipped). Calculates latency, assembles the payload, and sends asynchronously.

3. @Priority(USER - 100)

The priority ensures the ImmutableLog filter runs before application authentication/authorization filters, capturing even unauthorized attempts.

4. GraalVM Native Image

The filter is compatible with Quarkus native compilation (quarkus build --native). java.net.http.HttpClient and jackson-databind need reflection hints — Quarkus generates them automatically for @Provider annotated classes.

ExceptionMapper

To capture unhandled exceptions and include them in the event, use an ExceptionMapper that stores the exception as a request context property before returning the error response.

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();
    }
}

Custom event

Inject ContainerRequestContext in the resource and set imtbl.eventName as a property to override the auto-generated name.

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();
    }
}

Immutable trail (immutable_trail)

A trail groups related events into a single auditable timeline. Set imtbl.trail as a ContainerRequestContext property, or propagate it across services with the X-Imtbl-Trail header.

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();
}

The trail cannot be empty, exceed 256 characters, or contain ":" — violations return 400 invalid_immutable_trail. sanitizeTrail fixes the value before sending.

RESTEasy Reactive

The filter works equally with RESTEasy Reactive (Quarkus 3+). Resources return Uni/Multi — JAX-RS filters are still executed before and after each 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());
    }
}

Client IP (servlet mode)

To access the real remote address in Quarkus JVM mode with Undertow, inject 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();
    }
}

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

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.

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));

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.