Skip to main content

Logs

Complete Code
The end result of the code developed in this document can be found in the GitHub monorepo springboot-demo-projects, under the tag logs.

How Spring Boot Logs

In Spring Boot, SLF4J and Logback work together to provide a logging setup.

  • SLF4J = Simple Logging Facade for Java. Think of it as a logging API (an abstraction layer). Your application code talks to SLF4J, not directly to a specific logging implementation. This is brilliant because it decouples your code from how logs are written.
  • Logback = The actual logging implementation (the engine that does the heavy lifting). It's the default logging backend that SLF4J uses in a Spring Boot application.

Even though Spring Boot includes SLF4J and Logback automatically and no setup is required to get basic logging working, improving the logging experience is highly recommended. Structured logging with trace IDs, centralized sensitive data masking, and consistent request/response logging make debugging production issues significantly easier.

Files to Create/Modify
File Tree
├── build.gradle
├── ...
└── src
├── main
│ ├── java
│ │ └── dev
│ │ └── pollito
│ │ └── spring_java
│ │ ├── config
│ │ │ ├── log
│ │ │ │ ├── LogAspect.java
│ │ │ │ ├── LogFilter.java
│ │ │ │ ├── MaskingPatternLayout.java
│ │ │ │ └── TraceIdFilter.java
│ │ │ └── ...
│ │ └── ...
│ └── resources
│ ├── application-dev.yaml
│ └── ...
└── test
└── ...

Dependencies

build.gradle
// ...
dependencies {
// ...
implementation 'org.aspectj:aspectjtools:1.9.25.1'
implementation 'org.springframework.boot:spring-boot-starter-opentelemetry'
}
// ...
Logging in Kotling Projects

kotlin-logging is a wrapper around SLF4J that provides a more idiomatic Kotlin way to log messages. It does not replace SLF4J or Logback; instead, it simplifies logging calls in Kotlin by using extension functions and lazy evaluation for messages. The logging hierarchy remains: Kotlin code → kotlin-logging → SLF4J → Logback (Spring Boot default)

Avoid Local OTLP Log Publishing

When you add spring-boot-starter-opentelemetry to your dependencies, Spring Boot automatically configures OpenTelemetry exporters to publish metrics and traces via OTLP (OpenTelemetry Protocol).

The problem is that on local development, you typically don't have an OTLP collector running. Spring Boot will repeatedly try to connect to the default OTLP endpoint and fail, spamming your console with connection error messages that drown out your actual application logs.

To avoid this noise during local development, disable OTLP metrics export in your dev profile:

resources/application-dev.yaml
spring:
application:
name: spring_java
management:
otlp:
metrics:
export:
enabled: false

From now on, run the bootRun Gradle task with the SPRING_PROFILES_ACTIVE=dev environment variable to activate the dev profile:

SPRING_PROFILES_ACTIVE=dev ./gradlew bootRun

Log Aspect

Using an Aspect for logging is a classic example of AOP in action. Instead of scattering log statements all over your business logic (which clutters the code and mixes concerns), you define the logging behavior once in a separate module. This aspect then automatically intercepts method calls you care about and applies the logging logic without the target code even knowing it's happening.

For a deeper dive, check out the AOP section on Cross-Cutting Concerns.

java/dev/pollito/spring_java/config/log/LogAspect.java
package dev.pollito.spring_java.config.log;

import java.util.Arrays;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.AfterReturning;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.jspecify.annotations.NonNull;
import org.springframework.stereotype.Component;

@Aspect
@Component
@Slf4j
public class LogAspect {
@Pointcut("within(@org.springframework.web.bind.annotation.RestController *)")
public void controllerPublicMethodsPointcut() {}

@Before("controllerPublicMethodsPointcut()")
public void logBefore(@NonNull JoinPoint joinPoint) {
log.info(
"[{}] Args: {}",
joinPoint.getSignature().toShortString(),
Arrays.toString(joinPoint.getArgs()));
}

@AfterReturning(pointcut = "controllerPublicMethodsPointcut()", returning = "result")
public void logAfterReturning(@NonNull JoinPoint joinPoint, Object result) {
log.info("[{}] Response: {}", joinPoint.getSignature().toShortString(), result);
}
}

Log Filter

Sometimes, a request might not even reach your Spring MVC controllers. By logging at the Servlet level, it grants you visibility into every incoming request and outgoing response, regardless of whether it hits your application's specific endpoints.

This can be incredibly useful for debugging issues like authentication failures, routing problems, or requests that are blocked higher up in the filter chain.

java/dev/pollito/spring_java/config/log/LogFilter.java
package dev.pollito.spring_java.config.log;

import static java.util.Collections.list;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import lombok.extern.slf4j.Slf4j;
import org.jspecify.annotations.NonNull;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

@Component
@Order()
@Slf4j
public class LogFilter extends OncePerRequestFilter {

@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain)
throws ServletException, IOException {
logRequestDetails(request);
filterChain.doFilter(request, response);
logResponseDetails(response);
}

private void logRequestDetails(@NonNull HttpServletRequest request) {
log.info(
">>>> Method: {}; URI: {}; QueryString: {}; Headers: {}",
request.getMethod(),
request.getRequestURI(),
request.getQueryString(),
headersToString(request));
}

private @NonNull String headersToString(@NonNull HttpServletRequest request) {
List<String> headers = new ArrayList<>();

List<String> headerNames = list(request.getHeaderNames());

for (String headerName : headerNames) {
if (headerName != null && !headerName.isBlank()) {
String headerValue = request.getHeader(headerName);
if (headerValue != null && !headerValue.isBlank()) {
headers.add(headerName + ": " + headerValue);
}
}
}

if (headers.isEmpty()) {
return "{}";
}

return "{" + String.join(", ", headers) + "}";
}

private void logResponseDetails(@NonNull HttpServletResponse response) {
log.info("<<<< Response Status: {}", response.getStatus());
}
}

Tracing

Ever tried to debug an issue in a busy system by trawling through a massive log file? It's like trying to find a specific needle in a haystack of needles.

This is where tracing comes in. By assigning a unique ID (a 'Trace ID') to each incoming request, you can tag every single log entry generated during that request's lifecycle. Suddenly, you can filter the entire log file to see the journey of just one request across multiple methods, services, or threads.

java/dev/pollito/spring_java/config/log/TraceIdFilter.java
package dev.pollito.spring_java.config.log;

import static io.opentelemetry.api.trace.Span.current;
import static org.slf4j.MDC.put;
import static org.slf4j.MDC.remove;
import static org.springframework.core.Ordered.LOWEST_PRECEDENCE;

import io.opentelemetry.api.trace.SpanContext;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.jspecify.annotations.NonNull;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

@Component
@Order(LOWEST_PRECEDENCE - 1)
public class TraceIdFilter extends OncePerRequestFilter {

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

SpanContext spanContext = current().getSpanContext();
if (spanContext.isValid()) {
put("trace_id", spanContext.getTraceId());
put("span_id", spanContext.getSpanId());
put("trace_flags", spanContext.getTraceFlags().isSampled() ? "01" : "00");
}

try {
filterChain.doFilter(request, response);
} finally {
remove("trace_id");
remove("span_id");
remove("trace_flags");
}
}
}

Mask Sensitive Data in Logs With Logback

It's important to mask sensitive details when logging (i.e., passwords, SSN, etc.). Let's mask the logs centrally by configuring masking rules for all log entries produced by Logback.

  1. Create MaskingPatternLayout:

    java/dev/pollito/spring_java/config/log/MaskingPatternLayout.java
    package dev.pollito.spring_java.config.log;

    import static java.util.regex.Matcher.quoteReplacement;
    import static java.util.regex.Pattern.CASE_INSENSITIVE;
    import static java.util.regex.Pattern.MULTILINE;

    import ch.qos.logback.classic.PatternLayout;
    import ch.qos.logback.classic.spi.ILoggingEvent;
    import java.util.ArrayList;
    import java.util.List;
    import java.util.Objects;
    import java.util.regex.MatchResult;
    import java.util.regex.Pattern;
    import java.util.stream.IntStream;
    import org.jspecify.annotations.NonNull;

    public class MaskingPatternLayout extends PatternLayout {

    private Pattern multilinePattern;
    private final List<String> maskPatterns = new ArrayList<>();

    public void addMaskPattern(String maskPattern) {
    maskPatterns.add(maskPattern);
    multilinePattern =
    Pattern.compile(String.join("|", maskPatterns), MULTILINE | CASE_INSENSITIVE);
    }

    @Override
    public String doLayout(ILoggingEvent event) {
    return maskMessage(super.doLayout(event));
    }

    private String maskMessage(String message) {
    if (multilinePattern == null) {
    return message;
    }
    return multilinePattern.matcher(message).replaceAll(this::computeReplacement);
    }

    private String computeReplacement(@NonNull MatchResult matchResult) {
    List<String> nonNullGroups =
    IntStream.rangeClosed(1, matchResult.groupCount())
    .mapToObj(matchResult::group)
    .filter(Objects::nonNull)
    .limit(2)
    .toList();

    String replacement =
    switch (nonNullGroups.size()) {
    case 0 -> matchResult.group(0);
    case 1 -> nonNullGroups.getFirst();
    default -> nonNullGroups.getFirst() + "****";
    };

    return quoteReplacement(replacement);
    }
    }
  2. Add regex patterns in maskPattern tags inside logback.xml:

    resources/logback-spring.xml
    <configuration>
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
    <encoder class="ch.qos.logback.core.encoder.LayoutWrappingEncoder">
    <layout class="dev.pollito.spring_java.config.log.MaskingPatternLayout">
    <maskPattern>(?i)((?:authorization|proxy-authorization|cookie|x-api-key|x-auth-token|x-csrf-token):\s+)([^\r\n,]+)</maskPattern>
    <maskPattern>(?i)((?:password|token|secret)[\s:="]+)(\S+)</maskPattern>

    <pattern>%d{yyyy-MM-dd} %d{HH:mm:ss.SSS} trace_id=%X{trace_id} span_id=%X{span_id} trace_flags=%X{trace_flags} %-5level %thread --- %logger{36} %msg%n</pattern>
    </layout>
    </encoder>
    </appender>

    <root level="INFO">
    <appender-ref ref="CONSOLE"/>
    </root>
    </configuration>

Check How Logs Look Like

Run the following curl command (with placeholder values) to see everything working: sensitive data masking, the aspect intercepting controller method invocations, how the logs contain the trace, and the filter printing the HTTP request and response.

Terminal
curl -s --request GET   --url http://localhost:8080/api/films/42   --header 'Accept: application/json'   --header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'   --header 'Cookie: JSESSIONID=A1B2C3D4E5F6G7H8I9J0; auth_token=secret123token456'   --header 'Proxy-Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ='   --header 'User-Agent: Mozilla/5.0 (Test Client)'   --header 'X-API-Key: super-secret-api-key'   --header 'X-Auth-Token: super-secret-auth-token-12345'   --header 'X-CSRF-Token: csrf_abc123def456ghi789' | jq

Logs should look something like this:

Application logs
2026-02-18 15:28:11.600 trace_id=b8e1447340832e9b466fde0a1f172b55 span_id=a4fa1234784f7c02 trace_flags=01 INFO  http-nio-8080-exec-1 --- d.p.spring_java.config.log.LogFilter >>>> Method: GET; URI: /api/films/42; QueryString: null; Headers: {Host: localhost:8080, Accept: application/json, Authorization: ****, Cookie: ****, Proxy-Authorization: ****, User-Agent: Mozilla/5.0 (Test Client), X-API-Key: ****, X-Auth-Token: ****, X-CSRF-Token: ****
2026-02-18 15:28:11.619 trace_id=b8e1447340832e9b466fde0a1f172b55 span_id=a4fa1234784f7c02 trace_flags=01 INFO http-nio-8080-exec-1 --- d.p.spring_java.config.log.LogAspect [FilmRestController.findById(..)] Args: [42]
2026-02-18 15:28:11.620 trace_id=b8e1447340832e9b466fde0a1f172b55 span_id=a4fa1234784f7c02 trace_flags=01 INFO http-nio-8080-exec-1 --- d.p.spring_java.config.log.LogAspect [FilmRestController.findById(..)] Response: FilmResponse(id=42, title=ACADEMY DINOSAUR, description=A Epic Drama of a Feminist And a Mad Scientist who must Battle a Teacher in The Canadian Rockies, releaseYear=2006, rating=PG, lengthMinutes=86, language=English)
2026-02-18 15:28:11.664 trace_id=b8e1447340832e9b466fde0a1f172b55 span_id=a4fa1234784f7c02 trace_flags=01 INFO http-nio-8080-exec-1 --- d.p.spring_java.config.log.LogFilter <<<< Response Status: 200
Scroll to zoom • Drag corner to resize