Skip to main content
ImmutableLog logo
Back
Spring BootFilter

Spring Boot — ImmutableLog

Integrate ImmutableLog into Spring Boot applications using OncePerRequestFilter for automatic capture of all HTTP requests, with AOP support to instrument service methods.

Installation

The filter uses only libraries included in the Spring Boot starter. For AOP, add the dependency below:

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 is already included in spring-boot-starter-web. For AOP, add spring-boot-starter-aop.

HTTP Filter (OncePerRequestFilter)

Create the class below in your project. Spring Boot detects the @Component and registers the filter automatically without any additional configuration.

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

Configuration

Add the configuration variables to application.properties or 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

How it works

1. Request wrapping

The filter wraps request and response in ContentCachingWrapper to allow reading the body without consuming the original stream.

2. Chain passthrough

chain.doFilter() is called normally — the request flows to the controller unchanged. Exceptions are captured and re-thrown after recording.

3. Async emission in finally

In the finally block, the event is assembled and sent via HttpClient.sendAsync(). The HTTP client is non-blocking — the response was already sent to the user.

4. Body hash

The request body is hashed with SHA-256. Never the raw content — only the digest — is sent to ImmutableLog, preserving data privacy.

Custom event

Set a semantic event name via request attribute. The filter uses this value instead of the auto-generated name (method + 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);
    }
}

Path exclusion

Override shouldNotFilter() to skip health checks and monitoring endpoints. The filter is not executed for these 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 (alternative)

Use HandlerInterceptor when you need access to the handler (controller method) or annotations. Register in 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 for service methods

With Spring AOP, instrument any service method without modifying the business logic. Add spring-boot-starter-aop and create the annotation + aspect below.

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 works on any Spring bean (Service, Component, Repository). The aspect captures exceptions and always emits the event in the finally block, guaranteeing traceability even on errors.

This documentation reflects the current integration behavior. For questions or advanced integrations, contact the support team.