Pular para o conteúdo principal
ImmutableLog logo
Voltar
Spring BootFilter

Spring Boot — ImmutableLog

Integre o ImmutableLog em aplicações Spring Boot usando OncePerRequestFilter para captura automática de todas as requisições HTTP, com suporte a AOP para instrumentar métodos de serviço.

Instalação

O filtro usa apenas bibliotecas incluídas no Spring Boot starter. Para AOP, adicione a dependência abaixo:

Maven

xml
<!-- pom.xml -->
<dependency>
  <groupId>com.fasterxml.jackson.core</groupId>
  <artifactId>jackson-databind</artifactId>
</dependency>

Gradle

groovy
// build.gradle
implementation 'com.fasterxml.jackson.core:jackson-databind'

Jackson já vem incluso no spring-boot-starter-web. Para AOP, adicione spring-boot-starter-aop.

Filtro HTTP (OncePerRequestFilter)

Crie a classe abaixo no seu projeto. O Spring Boot detecta o @Component e registra o filtro automaticamente sem nenhuma configuração adicional.

java
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.*;
import jakarta.servlet.http.*;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.util.ContentCachingRequestWrapper;
import org.springframework.web.util.ContentCachingResponseWrapper;

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.*;

@Component
public class ImmutableLogFilter extends OncePerRequestFilter {

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

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

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

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

    private final ObjectMapper mapper = new ObjectMapper();
    private final HttpClient http = HttpClient.newHttpClient();

    private static final Set<String> SKIP_PATHS = Set.of(
        "/health", "/actuator/health", "/ping"
    );

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        return SKIP_PATHS.contains(request.getRequestURI());
    }

    @Override
    protected void doFilterInternal(
        HttpServletRequest request,
        HttpServletResponse response,
        FilterChain chain
    ) throws ServletException, IOException {

        var wrappedReq = new ContentCachingRequestWrapper(request);
        var wrappedRes = new ContentCachingResponseWrapper(response);
        long startMs = System.currentTimeMillis();
        Throwable caught = null;

        try {
            chain.doFilter(wrappedReq, wrappedRes);
        } catch (Exception ex) {
            caught = ex;
            throw ex;
        } finally {
            long latencyMs = System.currentTimeMillis() - startMs;
            emit(wrappedReq, wrappedRes, latencyMs, caught);
            wrappedRes.copyBodyToResponse();
        }
    }

    private void emit(
        ContentCachingRequestWrapper req,
        ContentCachingResponseWrapper res,
        long latencyMs,
        Throwable ex
    ) {
        try {
            int status = res.getStatus();
            String method = req.getMethod();
            String path = req.getRequestURI();
            String requestId = UUID.randomUUID().toString();

            // Custom event name via request attribute
            String customEvent = (String) req.getAttribute("imtbl.eventName");
            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(req));
            payloadMap.put("user_agent", req.getHeader("User-Agent"));

            byte[] body = req.getContentAsByteArray();
            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> body2 = Map.of(
                "payload", mapper.writeValueAsString(payloadMap),
                "meta", meta
            );

            String json = mapper.writeValueAsString(body2);

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

            // Fire and forget — never blocks the response
            http.sendAsync(httpReq, HttpResponse.BodyHandlers.discarding());

        } catch (Exception ignored) {
            // Never let audit logging break the application
        }
    }

    private String getClientIp(HttpServletRequest req) {
        String xff = req.getHeader("X-Forwarded-For");
        if (xff != null && !xff.isBlank()) return xff.split(",")[0].trim();
        String xri = req.getHeader("X-Real-IP");
        if (xri != null && !xri.isBlank()) return xri;
        return req.getRemoteAddr();
    }

    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 variáveis de configuração no application.properties ou application.yml:

application.properties

properties
# 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

application.yml

yaml
# 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. Wrapping da requisição

O filtro envolve request e response em ContentCachingWrapper para permitir leitura do body sem consumir o stream original.

2. Passagem pela chain

chain.doFilter() é chamado normalmente — a requisição flui para o controller sem alteração. Exceções são capturadas e re-lançadas após o registro.

3. Emissão assíncrona no finally

No bloco finally, o evento é montado e enviado via HttpClient.sendAsync(). O cliente HTTP não bloqueia — a resposta já foi enviada ao usuário.

4. Hash do body

O body da requisição é hasheado com SHA-256. Nunca o conteúdo bruto — apenas o digest — é enviado ao ImmutableLog, preservando a privacidade dos dados.

Evento customizado

Defina um nome semântico para o evento via request attribute. O filtro usa esse valor no lugar do nome gerado automaticamente (método + path).

java
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/payments")
public class PaymentController {

    @PostMapping
    public ResponseEntity<?> create(HttpServletRequest request, @RequestBody PaymentDto dto) {
        // Override the auto-generated event name
        request.setAttribute("imtbl.eventName", "payment.created");

        // ... process payment ...
        return ResponseEntity.ok(result);
    }
}

Exclusão de paths

Sobrescreva shouldNotFilter() para ignorar health checks e endpoints de monitoramento. O filtro não é executado para esses paths.

java
@Component
public class ImmutableLogFilter extends OncePerRequestFilter {

    // Skip specific paths — health checks and actuator endpoints
    private static final Set<String> SKIP_PATHS = Set.of(
        "/health", "/actuator/health", "/actuator/info", "/ping"
    );

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        String path = request.getRequestURI();
        return SKIP_PATHS.stream().anyMatch(path::startsWith);
    }

    // ... rest of the filter
}

HandlerInterceptor (alternativa)

Use HandlerInterceptor quando precisar acessar o handler (método do controller) ou anotações. Registre no WebMvcConfigurer.

java
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import jakarta.servlet.http.*;

/**
 * Alternative: HandlerInterceptor gives access to the handler method.
 * Use this when you need the controller method name or annotations.
 * Register in WebMvcConfigurer.addInterceptors().
 */
@Component
public class ImmutableLogInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) {
        req.setAttribute("imtbl.startedAt", System.currentTimeMillis());
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest req, HttpServletResponse res,
                                 Object handler, Exception ex) {
        long startedAt = (long) req.getAttribute("imtbl.startedAt");
        long latencyMs = System.currentTimeMillis() - startedAt;
        // emit event with latencyMs, req, res, ex ...
    }
}

// In your WebMvcConfigurer:
// registry.addInterceptor(immutableLogInterceptor).addPathPatterns("/**");

AOP — @AuditEvent para métodos de serviço

Com Spring AOP, instrumente qualquer método de serviço sem modificar a lógica de negócio. Adicione spring-boot-starter-aop e crie a anotação + aspect abaixo.

java
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;
import org.springframework.stereotype.Component;
import java.lang.annotation.*;

// 1. Define the annotation
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface AuditEvent {
    String value() default "";
}

// 2. Implement the aspect
@Aspect
@Component
public class AuditEventAspect {

    private final ImmutableLogClient client; // inject your HTTP client

    public AuditEventAspect(ImmutableLogClient client) {
        this.client = client;
    }

    @Around("@annotation(auditEvent)")
    public Object audit(ProceedingJoinPoint pjp, AuditEvent auditEvent) throws Throwable {
        long startMs = System.currentTimeMillis();
        String eventName = auditEvent.value().isBlank()
            ? pjp.getSignature().getName()
            : auditEvent.value();

        try {
            Object result = pjp.proceed();
            long latencyMs = System.currentTimeMillis() - startMs;
            client.emitAsync(eventName, "success", latencyMs, null);
            return result;
        } catch (Throwable ex) {
            long latencyMs = System.currentTimeMillis() - startMs;
            client.emitAsync(eventName, "error", latencyMs, ex);
            throw ex;
        }
    }
}

// 3. Use in any Spring bean:
@Service
public class PaymentService {

    @AuditEvent("payment.processed")
    public Payment process(PaymentRequest req) {
        // ... logic
    }
}

@AuditEvent funciona em qualquer Spring bean (Service, Component, Repository). O aspecto captura exceções e sempre emite o evento no bloco finally, garantindo rastreabilidade mesmo em erros.

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.