Pular para o conteúdo principal
ImmutableLog logo
Voltar
MicronautHttpServerFilter

Micronaut — ImmutableLog

Integre o ImmutableLog em aplicações Micronaut usando HttpServerFilter. Zero overhead de reflection — compatível com compilação nativa GraalVM e cloud-native deployments.

Instalação

O filtro usa apenas APIs do Micronaut core. Adicione as dependências abaixo se não as tiver:

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

Crie a classe abaixo. A anotação @Filter("/**") registra o filtro globalmente. Use Publishers.map() para transformar a response de forma reativa sem bloquear a 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";
        }
    }
}

Configuração

Adicione as propriedades no 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

Como funciona

1. ServerFilterPhase.FIRST

getOrder() retorna a fase FIRST para garantir que o filtro rode antes de qualquer outro filtro da aplicação, capturando inclusive requisições que falham em filtros subsequentes.

2. Publishers.map() reativo

Publishers.map() é usado para interceptar a resposta sem bloquear. O Micronaut usa Netty internamente — nunca bloqueie a thread de I/O do event loop.

3. request.getAttribute()

Atributos do request são thread-safe e propagados pelo contexto reativo. Use para passar o nome de evento customizado do controller para o filtro.

4. HttpClient.sendAsync (Java 11+)

O envio ao ImmutableLog é fire-and-forget via HttpClient.sendAsync(). Nunca bloqueia a thread do Netty — a resposta ao cliente já foi enviada antes do POST.

ExceptionHandler

Para capturar exceções não tratadas, implemente ExceptionHandler. Ele é chamado pelo Micronaut antes de retornar a resposta de erro, permitindo enriquecer o evento com detalhes da exceção.

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

Evento customizado

Injete HttpRequest no controller e defina o atributo imtbl.eventName para sobrescrever o nome gerado automaticamente pelo filtro.

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

Filtro reativo (Reactor)

Alternativa usando Reactor (Mono/Flux) para composição reativa mais expressiva. Útil quando sua stack usa Micronaut + Reactor ao invés de 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)

O Micronaut processa anotações em tempo de compilação — sem reflection em runtime. O filtro é totalmente compatível com compilação nativa. Se necessário, adicione hints manuais:

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

Na maioria dos casos o Micronaut AOT gera os hints automaticamente para classes com @Singleton e @Filter. Teste com: mvn package -Dpackaging=native-image

Esta documentação reflete o comportamento atual da integração. Para dúvidas ou integrações avançadas, entre em contato com o time de suporte.