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:
<!-- 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.
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:
# 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.comHow 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.
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.
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.
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:
// 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-imageIn 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.
