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

            Map<String, Object> meta = Map.of(
                "type", type,
                "event_name", eventName,
                "service", serviceName,
                "request_id", requestId,
                "env", env
            );

            Map<String, Object> body = Map.of(
                "payload", mapper.writeValueAsString(payloadMap),
                "meta", meta
            );

            String json = mapper.writeValueAsString(body);

            var httpReq = HttpRequest.newBuilder()
                .uri(URI.create(apiUrl + "/v1/events"))
                .header("Content-Type", "application/json")
                .header("Authorization", "Bearer " + apiKey)
                .header("Idempotency-Key", requestId)
                .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";
    }
}

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

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.