Skip to main content
ImmutableLog logo
Back
MicronautHttpServerFilter

Micronaut — ImmutableLog

Integrate ImmutableLog into Micronaut applications using HttpServerFilter. Zero reflection overhead — compatible with GraalVM native compilation and cloud-native deployments.

Installation

The filter uses only Micronaut core APIs. Add the dependencies below if you don't have them:

xml
<!-- pom.xml -->
<dependency>
  <groupId>io.micronaut</groupId>
  <artifactId>micronaut-http-server-netty</artifactId>
</dependency>
<dependency>
  <groupId>io.micronaut.serde</groupId>
  <artifactId>micronaut-serde-jackson</artifactId>
</dependency>

HttpServerFilter

Create the class below. The @Filter("/**") annotation registers the filter globally. Use Publishers.map() to transform the response reactively without blocking the thread.

java
import com.fasterxml.jackson.databind.ObjectMapper;
import io.micronaut.context.annotation.Value;
import io.micronaut.core.async.publisher.Publishers;
import io.micronaut.http.*;
import io.micronaut.http.annotation.Filter;
import io.micronaut.http.filter.*;
import jakarta.inject.Singleton;
import org.reactivestreams.Publisher;

import java.net.URI;
import java.net.http.*;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.*;

@Filter("/**")
@Singleton
public class ImmutableLogFilter implements HttpServerFilter {

    @Value("${immutablelog.api-key}")
    String apiKey;

    @Value("${immutablelog.service-name:my-service}")
    String serviceName;

    @Value("${immutablelog.env:production}")
    String env;

    @Value("${immutablelog.url:https://api.immutablelog.com}")
    String apiUrl;

    private static final Set<String> SKIP_PATHS = Set.of("/health", "/ping");
    private final ObjectMapper mapper = new ObjectMapper();
    private final HttpClient http = HttpClient.newHttpClient();

    @Override
    public int getOrder() {
        return ServerFilterPhase.FIRST.order();
    }

    @Override
    public Publisher<MutableHttpResponse<?>> doFilter(
        HttpRequest<?> request,
        ServerFilterChain chain
    ) {
        String path = request.getPath();

        if (SKIP_PATHS.stream().anyMatch(path::startsWith)) {
            return chain.proceed(request);
        }

        long startedAt = System.currentTimeMillis();
        String requestId = UUID.randomUUID().toString();

        return Publishers.map(chain.proceed(request), response -> {
            long latencyMs = System.currentTimeMillis() - startedAt;
            emit(request, response, requestId, latencyMs, null);
            return response;
        });
    }

    public void emitError(HttpRequest<?> request, String requestId,
                          long latencyMs, Throwable ex) {
        emit(request, null, requestId, latencyMs, ex);
    }

    private void emit(HttpRequest<?> request, MutableHttpResponse<?> response,
                      String requestId, long latencyMs, Throwable ex) {
        try {
            String method = request.getMethodName();
            String path = request.getPath();
            int status = response != null ? response.getStatus().getCode() : 500;

            // Custom event name via request attribute
            String customEvent = request.getAttribute("imtbl.eventName", String.class).orElse(null);
            String eventName = customEvent != null
                ? customEvent
                : method.toLowerCase() + "." + path.replace("/", ".").replaceAll("^\\.+", "");

            String type = status >= 500 ? "error"
                : status >= 400 ? "error"
                : status >= 300 ? "info"
                : "success";

            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(request));
            payloadMap.put("user_agent", request.getHeaders().get("User-Agent"));

            request.getBody(byte[].class).ifPresent(body -> {
                if (body.length > 0) {
                    payloadMap.put("request_body_hash", sha256Hex(body));
                }
            });

            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> bodyMap = Map.of(
                "payload", mapper.writeValueAsString(payloadMap),
                "meta", meta
            );

            String json = mapper.writeValueAsString(bodyMap);

            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(HttpRequest<?> request) {
        String xff = request.getHeaders().get("X-Forwarded-For");
        if (xff != null && !xff.isBlank()) return xff.split(",")[0].trim();
        String xri = request.getHeaders().get("X-Real-IP");
        if (xri != null && !xri.isBlank()) return xri;
        return request.getRemoteAddress().getAddress().getHostAddress();
    }

    private String sha256Hex(byte[] data) {
        try {
            var digest = MessageDigest.getInstance("SHA-256");
            byte[] hash = digest.digest(data);
            StringBuilder sb = new StringBuilder();
            for (byte b : hash) sb.append(String.format("%02x", b));
            return sb.toString();
        } catch (Exception e) {
            return "hash-error";
        }
    }
}

Configuration

Add the properties to application.yml:

yaml
# src/main/resources/application.yml
immutablelog:
  api-key: ${IMTBL_API_KEY}
  service-name: ${IMTBL_SERVICE_NAME:my-service}
  env: ${IMTBL_ENV:production}
  url: https://api.immutablelog.com

How it works

1. ServerFilterPhase.FIRST

getOrder() returns the FIRST phase to ensure the filter runs before any other application filter, capturing even requests that fail in subsequent filters.

2. Reactive Publishers.map()

Publishers.map() is used to intercept the response without blocking. Micronaut uses Netty internally — never block the event loop I/O thread.

3. request.getAttribute()

Request attributes are thread-safe and propagated by the reactive context. Use them to pass the custom event name from the controller to the filter.

4. HttpClient.sendAsync (Java 11+)

Sending to ImmutableLog is fire-and-forget via HttpClient.sendAsync(). Never blocks the Netty thread — the client response was already sent before the POST.

ExceptionHandler

To capture unhandled exceptions, implement ExceptionHandler. It is called by Micronaut before returning the error response, allowing you to enrich the event with exception details.

java
import io.micronaut.context.annotation.Requires;
import io.micronaut.http.*;
import io.micronaut.http.annotation.Produces;
import io.micronaut.http.server.exceptions.*;
import jakarta.inject.Singleton;

/**
 * ExceptionHandler to capture errors and include them in the audit event.
 * Register alongside the filter to capture unhandled exceptions.
 */
@Produces
@Singleton
@Requires(classes = {Exception.class, ExceptionHandler.class})
public class ImmutableLogExceptionHandler
    implements ExceptionHandler<Exception, HttpResponse<?>> {

    private final ImmutableLogFilter filter;

    public ImmutableLogExceptionHandler(ImmutableLogFilter filter) {
        this.filter = filter;
    }

    @Override
    public HttpResponse<?> handle(HttpRequest request, Exception ex) {
        String requestId = request.getAttribute("imtbl.requestId", String.class)
            .orElse(UUID.randomUUID().toString());
        long startedAt = request.getAttribute("imtbl.startedAt", Long.class)
            .orElse(System.currentTimeMillis());
        long latencyMs = System.currentTimeMillis() - startedAt;

        filter.emitError(request, requestId, latencyMs, ex);

        return HttpResponse.serverError(Map.of("error", ex.getMessage()));
    }
}

Custom event

Inject HttpRequest into the controller and set the imtbl.eventName attribute to override the name auto-generated by the filter.

java
import io.micronaut.http.HttpRequest;
import io.micronaut.http.annotation.*;

@Controller("/payments")
public class PaymentController {

    @Post
    public Payment create(HttpRequest<?> request, @Body PaymentDto dto) {
        // Override the auto-generated event name
        request.setAttribute("imtbl.eventName", "payment.created");

        // ... process payment ...
        return payment;
    }
}

Reactive filter (Reactor)

Alternative using Reactor (Mono/Flux) for more expressive reactive composition. Useful when your stack uses Micronaut + Reactor instead of RxJava.

java
import io.micronaut.http.annotation.Filter;
import io.micronaut.http.filter.*;
import reactor.core.publisher.Mono;

/**
 * Alternative: Reactive filter using Reactor for non-blocking chains.
 * Use when your handlers are reactive (Mono/Flux from Reactor).
 */
@Filter("/**")
public class ImmutableLogReactiveFilter implements HttpServerFilter {

    @Override
    public Publisher<MutableHttpResponse<?>> doFilter(
        HttpRequest<?> request, ServerFilterChain chain
    ) {
        long startedAt = System.currentTimeMillis();
        String requestId = UUID.randomUUID().toString();

        return Mono.from(chain.proceed(request))
            .doOnSuccess(response -> {
                long latencyMs = System.currentTimeMillis() - startedAt;
                emitAsync(request, response, requestId, latencyMs, null);
            })
            .doOnError(ex -> {
                long latencyMs = System.currentTimeMillis() - startedAt;
                emitAsync(request, null, requestId, latencyMs, ex);
            });
    }

    private void emitAsync(/* ... */) {
        // same emit logic as above
    }
}

Native Image (GraalVM)

Micronaut processes annotations at compile time — no runtime reflection. The filter is fully compatible with native compilation. If needed, add manual hints:

json
// src/main/resources/META-INF/native-image/reflect-config.json
// For GraalVM native image compilation, add reflection hints if needed:
[
  {
    "name": "com.example.ImmutableLogFilter",
    "allPublicConstructors": true,
    "allPublicMethods": true
  }
]

// With Micronaut's @Singleton + @Filter, AOT processing handles most cases automatically.
// Run: ./mvnw package -Dpackaging=native-image

In most cases Micronaut AOT generates the hints automatically for classes with @Singleton and @Filter. Test with: mvn package -Dpackaging=native-image

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