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);
}
// Trilha imutavel: atributo imtbl.trail -> header X-Imtbl-Trail.
String trail = sanitizeTrail(request.getAttribute("imtbl.trail", String.class).orElse(null));
if (trail == null) trail = sanitizeTrail(request.getHeaders().get("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> bodyMap = Map.of(
"payload", mapper.writeValueAsString(payloadMap),
"meta", meta
);
String json = mapper.writeValueAsString(bodyMap);
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(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();
}
// 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;
}
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;
}
}Immutable trail (immutable_trail)
A trail groups related events into a single auditable timeline. Set the imtbl.trail request attribute, or propagate it across services with the X-Imtbl-Trail header.
@Post("/flows/{id}/run")
public HttpResponse<?> runFlow(String id, HttpRequest<?> request) {
request.setAttribute("imtbl.trail", "flow-" + id);
// ... business logic ...
return HttpResponse.ok(Map.of("status", "ok"));
}The trail cannot be empty, exceed 256 characters, or contain ":" — violations return 400 invalid_immutable_trail. sanitizeTrail fixes the value before sending.
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.
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 meta | What it does in the SIEM |
|---|---|
event_name | The 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 login → authentication). |
client_ip | Becomes source.ip with maximum precedence and provenance client_asserted. See the section below. |
immutable_trail | Becomes imtbl.immutable_trail — groups related events for investigation (e.g. one order, one user). |
type | Event severity: error / warning / info / success. Colors the badge in the listing. (event_type is accepted as a synonym.) |
service, env, request_id | Metadata: 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.ipwith 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.
client_ipX-Forwarded-For (1º hop)X-Real-IPconnection IPProvenance 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.
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
{
"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.
