Logging Errors without Leaking Details

Log enough to debug a failure while clients get a safe message and an error id, and keep secrets and personal data out of both.

SyntaxString errorId = UUID.randomUUID().toString(); log.atError().setCause(ex).addKeyValue("errorId", errorId).log("Unhandled exception"); problem.setProperty("errorId", errorId);

Every error has two audiences. The client needs to know what happened and whether retrying helps. Whoever is on call needs the exception, the stack trace and the request that caused it. Mixing the two up fails in both directions: stack traces in responses hand attackers a map of your system, and a generic 500 with nothing in the log leaves you blind.

What never goes in a response

  • Exception messages you did not write. Driver, ORM and HTTP client messages include SQL, hostnames, ports, file paths and sometimes values from the row.
  • Stack traces and exception class names. They reveal libraries and versions.
  • Anything about another user, and anything that helps guess valid identifiers.

Spring Boot's own /error body already leaves these out; keep spring.web.error.include-* at never. Your own handlers are where leaks creep in, usually as ex.getMessage() returned for every exception.

What goes in the log

Exceptions handled by an @ExceptionHandler are not logged by Spring, so the handler decides. Match the level to who must act:

  • Expected client errors (404, 409, validation): debug or info, one line, no stack trace. Logging them at error floods alerts with other people's mistakes.
  • Unexpected failures (5xx): error, with the exception passed as the cause so the stack trace is kept.

Pass the exception as the cause and once only. Logging it, wrapping it and rethrowing it to be logged again produces three stack traces for one failure.

Join the two with an id

Generate an error id in the catch-all handler, log it as a field and return it as a Problem Details extension. When a customer quotes it, one search finds the stack trace. A request id in the logging context (MDC) connects every log line from the same request. With tracing enabled, Micrometer puts traceId and spanId into the logging context, and you can return the trace id instead.

Keep secrets out of the log too

Logs are copied to more places and read by more people than your database. Do not log request bodies, Authorization headers, tokens or passwords. Be careful with toString() on records that hold credentials, and with exception messages built from user input. Structured logging (logging.structured.format.console=ecs) makes fields such as errorId searchable without pasting values into the message text.

Example

Example · java
import java.util.UUID;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;

class OrderNotFoundException extends RuntimeException {
    OrderNotFoundException(long id) {
        super("Order " + id + " not found");
    }
}

@RestControllerAdvice
public class SafeErrorHandler extends ResponseEntityExceptionHandler {

    private static final Logger log = LoggerFactory.getLogger(SafeErrorHandler.class);

    // Expected, client-caused: one line at debug, no stack trace.
    @ExceptionHandler
    ProblemDetail notFound(OrderNotFoundException ex) {
        log.debug("Order lookup failed: {}", ex.getMessage());
        return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
    }

    // Not an error: let Spring Security answer 401 or 403.
    @ExceptionHandler
    void accessDenied(AccessDeniedException ex) {
        throw ex;
    }

    // Unexpected: full stack trace in the log, a reference number for the client.
    @ExceptionHandler
    ProblemDetail unexpected(Exception ex) {
        String errorId = UUID.randomUUID().toString();
        log.atError()
                .setCause(ex)
                .addKeyValue("errorId", errorId)
                .log("Unhandled exception");

        ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR,
                "Something went wrong on our side. Quote the error id if you contact support.");
        problem.setProperty("errorId", errorId);
        return problem;
    }
}

When to use it

  • Give support an error id that leads straight to the stack trace of a customer's failed checkout.
  • Stop a database connection string from appearing in a 500 response after a pool outage.
  • Keep 404s and validation failures out of the error-level alerts while 5xx failures still page someone.

More examples

Settings that keep internals out of responses

The first three lines are the defaults made explicit, which protects them from a debug override sneaking into a production profile. Structured console output turns key-value pairs added with SLF4J's fluent API, such as <code>errorId</code>, into separate JSON fields that a log platform can index.

Example · properties
# Never show internals in Boot's /error body.
spring.web.error.include-message=never
spring.web.error.include-stacktrace=never
spring.web.error.include-exception=false

# JSON log lines, so errorId and traceId become searchable fields.
logging.structured.format.console=ecs

# Raise to debug while investigating how Spring MVC resolved an exception.
logging.level.org.springframework.web.servlet.mvc.method.annotation=info

A request id in every log line

The filter runs before Spring Security and the controllers, so every line logged during the request, including the catch-all handler's, carries <code>requestId</code>. Validating an incoming id stops a caller from injecting newlines or huge strings into your logs. Removing the value in <code>finally</code> matters because servlet threads are reused; without it the next request on that thread inherits a stale id.

Example · java
import java.io.IOException;
import java.util.UUID;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import org.slf4j.MDC;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

// Every log line written during the request carries requestId, and the client
// gets the same value back, so a support ticket leads straight to the logs.
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class RequestIdFilter extends OncePerRequestFilter {

    private static final String HEADER = "X-Request-Id";

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {
        String incoming = request.getHeader(HEADER);
        // Accept a caller's id only if it looks like one; never log arbitrary header text.
        String requestId = incoming != null && incoming.matches("[A-Za-z0-9-]{8,64}")
                ? incoming
                : UUID.randomUUID().toString();
        MDC.put("requestId", requestId);
        response.setHeader(HEADER, requestId);
        try {
            chain.doFilter(request, response);
        } finally {
            MDC.remove("requestId");
        }
    }
}

A test that proves nothing leaks

The controller throws an exception whose message contains a connection string, the sort of text a real driver produces. The test asserts that none of it reaches the response body and that the error id does. Keeping a test like this beside the advice catches the day someone changes the handler to return <code>ex.getMessage()</code> for convenience.

Example · java
import static org.assertj.core.api.Assertions.assertThat;

import java.util.List;
import java.util.UUID;

import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestController
class LeakyController {
    @GetMapping("/api/reports")
    String reports() {
        throw new IllegalStateException("connection to jdbc:postgresql://db-internal:5432/reports refused");
    }
}

@RestControllerAdvice
class FallbackAdvice {
    @ExceptionHandler
    ProblemDetail unexpected(Exception ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, "Something went wrong on our side.");
        problem.setProperty("errorId", UUID.randomUUID().toString());
        return problem;
    }
}

class ErrorLeakTest {

    private final MockMvcTester mvc = MockMvcTester.of(List.of(new LeakyController()),
            builder -> builder.setControllerAdvice(new FallbackAdvice()).build());

    @Test
    void internalDetailsStayOnTheServer() {
        assertThat(mvc.get().uri("/api/reports"))
                .hasStatus(HttpStatus.INTERNAL_SERVER_ERROR)
                .bodyText()
                .doesNotContain("jdbc:", "db-internal", "IllegalStateException")
                .contains("errorId");
    }
}

Discussion

  • Be the first to comment on this lesson.