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
<!-- pom.xml -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>Gradle
// 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.
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
# 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.comapplication.yml
# application.yml
immutablelog:
api-key: ${IMTBL_API_KEY}
service-name: ${IMTBL_SERVICE_NAME:my-service}
env: ${IMTBL_ENV:production}
url: https://api.immutablelog.comComo 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).
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.
@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.
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.
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.
