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);
}
// Trilha imutavel: atributo imtbl.trail -> header X-Imtbl-Trail.
String trail = sanitizeTrail((String) req.getAttribute("imtbl.trail"));
if (trail == null) trail = sanitizeTrail(req.getHeader("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> body2 = Map.of(
"payload", mapper.writeValueAsString(payloadMap),
"meta", meta
);
String json = mapper.writeValueAsString(body2);
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();
// 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();
}
// Normaliza o immutable_trail (trim, max 256, sem ':').
// O servidor responde 400 invalid_immutable_trail se a regra for violada.
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";
}
}
}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);
}
}Trilha imutável (immutable_trail)
A trilha agrupa eventos relacionados em uma mesma linha do tempo auditável. Defina o atributo imtbl.trail na requisição, ou propague entre serviços com o header X-Imtbl-Trail. O valor passa por sanitizeTrail antes do envio.
@PostMapping("/flows/{id}/run")
public ResponseEntity<?> runFlow(@PathVariable String id, HttpServletRequest req) {
req.setAttribute("imtbl.trail", "flow-" + id);
// ... business logic ...
return ResponseEntity.ok(Map.of("status", "ok"));
}A trilha não pode ser vazia, exceder 256 caracteres ou conter ":" — violações retornam 400 invalid_immutable_trail. O sanitizeTrail corrige o valor antes do envio.
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.
