Post

Logging Best Practices in Spring Boot — Structured, Correlated, Production-Ready

Production logging done right with Spring Boot. Covers structured JSON logging, correlation IDs with Micrometer Tracing, MDC context propagation, log levels strategy, Logback configuration, sensitive data masking, and log aggregation patterns.

Logging Best Practices in Spring Boot — Structured, Correlated, Production-Ready

The Problem: Unstructured Logs Are Unsearchable at Scale

Your application logs look like this:

1
2
2026-08-22 10:15:42.123 INFO  [main] c.a.UserService - User created: john@email.com
2026-08-22 10:15:42.456 ERROR [http-nio-8080-1] c.a.PaymentService - Payment failed for order 12345

In development, this is fine. In production with 50 instances, 10,000 requests/second, and a log aggregator (ELK, Loki, Datadog):

  • How do you find all logs for a specific request?
  • How do you filter errors for a specific service?
  • How do you parse the timestamp, level, and message reliably?
  • How do you correlate a user’s journey across microservices?

Unstructured text logs require regex parsing. Structured JSON logs are instantly queryable.


Log Levels Strategy

LevelUse ForExample
TRACEVery detailed diagnostic info. Never in production.Method entry/exit, loop iterations
DEBUGDiagnostic info for development. Production: off by default.Variable values, SQL queries, request details
INFONormal business events. Production baseline.“User registered”, “Order placed”, “Payment processed”
WARNSomething unexpected but recoverable. Investigate soon.“Retry attempt 2/3”, “Cache miss — falling back to DB”
ERRORSomething failed. Requires attention. May lose data.“Payment gateway timeout”, “Database connection refused”

Guidelines

  • INFO should tell the story of what your application is doing
  • WARN should be actionable — if nobody would investigate it, it’s DEBUG
  • ERROR means something is broken right now
  • Never log at ERROR for expected business cases (e.g., “user not found” is WARN, not ERROR)
  • Never log sensitive data at any level

Structured JSON Logging

Why JSON?

flowchart LR
    A[Application] --> B[JSON Logs to stdout]
    B --> C[Log Shipper: Filebeat/Fluentd]
    C --> D[Log Aggregator: Elasticsearch/Loki]
    D --> E[Search + Dashboard: Kibana/Grafana]

diagram

JSON logs are machine-parseable without custom regex. Every field is a queryable attribute.

Setup with logstash-logback-encoder

Add the dependency:

1
2
3
4
5
<dependency>
    <groupId>net.logstash.logback</groupId>
    <artifactId>logstash-logback-encoder</artifactId>
    <version>7.4</version>
</dependency>

Create src/main/resources/logback-spring.xml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <springProfile name="dev">
        <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
            <encoder>
                <pattern>%d{HH:mm:ss.SSS} %highlight(%-5level) [%thread] %cyan(%logger{36}) - %msg%n</pattern>
            </encoder>
        </appender>
        <root level="DEBUG">
            <appender-ref ref="CONSOLE"/>
        </root>
    </springProfile>

    <springProfile name="prod">
        <appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
            <encoder class="net.logstash.logback.encoder.LogstashEncoder">
                <includeMdcKeyName>traceId</includeMdcKeyName>
                <includeMdcKeyName>spanId</includeMdcKeyName>
                <includeMdcKeyName>userId</includeMdcKeyName>
                <customFields>{"service":"my-service","env":"prod"}</customFields>
            </encoder>
        </appender>
        <root level="INFO">
            <appender-ref ref="JSON"/>
        </root>
    </springProfile>
</configuration>

Result in production:

1
2
3
4
5
6
7
8
9
10
11
12
{
  "@timestamp": "2026-08-22T10:15:42.123Z",
  "level": "INFO",
  "logger_name": "com.anupam.UserService",
  "message": "User registered successfully",
  "thread_name": "http-nio-8080-exec-1",
  "traceId": "6a2b3c4d5e6f7890",
  "spanId": "a1b2c3d4",
  "userId": "usr_123",
  "service": "my-service",
  "env": "prod"
}

Every field is searchable in Elasticsearch/Loki without regex.


Correlation IDs with Micrometer Tracing

A correlation ID links all logs from a single request — even across microservices.

Spring Boot 3.x integrates Micrometer Tracing which automatically propagates traceId and spanId via MDC:

1
2
3
4
<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-tracing-bridge-brave</artifactId>
</dependency>

Configuration:

1
2
3
4
5
6
7
8
management:
  tracing:
    sampling:
      probability: 1.0  # 100% in dev, reduce in prod

logging:
  pattern:
    level: "%5p [${spring.application.name:},%X{traceId:-},%X{spanId:-}]"

Now every log line automatically includes:

1
2
INFO [my-service,6a2b3c4d5e6f7890,a1b2c3d4] c.a.OrderService - Order created: ORD-001
INFO [my-service,6a2b3c4d5e6f7890,b2c3d4e5] c.a.PaymentService - Payment initiated for ORD-001

Search by traceId to see the full request journey.


MDC Context Propagation

MDC (Mapped Diagnostic Context) stores per-thread context that’s included in every log line. Problem: it breaks with @Async and thread pools.

Adding Custom MDC Values

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Component
public class UserContextFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        try {
            String userId = extractUserId(request);
            if (userId != null) {
                MDC.put("userId", userId);
            }
            MDC.put("requestPath", request.getRequestURI());
            filterChain.doFilter(request, response);
        } finally {
            MDC.clear();
        }
    }
}

Propagating MDC to @Async Threads

MDC is ThreadLocal — it doesn’t propagate to child threads by default:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(10);
        executor.setTaskDecorator(new MdcTaskDecorator());
        executor.initialize();
        return executor;
    }
}

public class MdcTaskDecorator implements TaskDecorator {

    @Override
    public Runnable decorate(Runnable runnable) {
        Map<String, String> contextMap = MDC.getCopyOfContextMap();
        return () -> {
            try {
                if (contextMap != null) {
                    MDC.setContextMap(contextMap);
                }
                runnable.run();
            } finally {
                MDC.clear();
            }
        };
    }
}

Now @Async methods inherit the MDC context (traceId, userId, etc.) from the calling thread.


What to Log and What NOT to Log

DO LogDO NOT Log
Business events (user registered, order placed)Passwords or credentials
Errors with full stack traceCredit card numbers (full)
Request IDs / correlation IDsSession tokens / JWT contents
Performance metrics (duration, count)PII without consent (SSN, full address)
External service calls (URL, status, duration)Request/response bodies (unless DEBUG + sanitized)
State transitions (order PENDING → PAID)File contents

Sensitive Data Masking

Even with careful coding, sensitive data can leak into logs. Add a safety net:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
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), Pattern.MULTILINE);
    }

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

    private String maskMessage(String message) {
        if (multilinePattern == null) return message;

        StringBuilder sb = new StringBuilder(message);
        Matcher matcher = multilinePattern.matcher(sb);
        while (matcher.find()) {
            if (matcher.group().length() > 4) {
                int start = matcher.start() + 4;
                int end = matcher.end();
                for (int i = start; i < end; i++) {
                    sb.setCharAt(i, '*');
                }
            }
        }
        return sb.toString();
    }
}

Configure in Logback:

1
2
3
4
5
6
7
8
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
    <layout class="com.anupam.logging.MaskingPatternLayout">
        <maskPattern>\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b</maskPattern>
        <maskPattern>(?i)password\s*[:=]\s*\S+</maskPattern>
        <maskPattern>Bearer\s+[A-Za-z0-9\-._~+/]+=*</maskPattern>
        <pattern>%d{HH:mm:ss.SSS} %-5level [%thread] %logger{36} - %msg%n</pattern>
    </layout>
</encoder>

Credit cards become 4242************4242, passwords become password=****, tokens get masked.


Log Configuration per Environment

EnvironmentFormatLevelAppender
Local DevColored consoleDEBUGConsole
CI/TestPlain textINFOConsole
ProductionJSONINFOConsole (→ stdout → log shipper)

Spring profiles make this seamless:

1
2
3
4
5
6
7
<springProfile name="dev">
    <!-- colored human-readable console -->
</springProfile>

<springProfile name="prod">
    <!-- JSON to stdout, picked up by Filebeat/Fluentd -->
</springProfile>

Activate with:

1
2
3
4
5
# Dev
./mvnw spring-boot:run -Dspring-boot.run.profiles=dev

# Production
java -jar app.jar --spring.profiles.active=prod

Performance: Async Appender and Log Level Guards

Async Appender

Logging blocks the calling thread. For high-throughput apps, use async:

1
2
3
4
5
6
<appender name="ASYNC_JSON" class="ch.qos.logback.classic.AsyncAppender">
    <queueSize>1024</queueSize>
    <discardingThreshold>0</discardingThreshold>
    <neverBlock>true</neverBlock>
    <appender-ref ref="JSON"/>
</appender>
  • neverBlock=true: never block the application thread (drop log if queue is full)
  • discardingThreshold=0: don’t discard any logs (except when queue is full + neverBlock)

Log Level Guards

Avoid expensive string construction when the level is disabled:

1
2
3
4
5
6
7
8
9
10
// BAD: String is always constructed
log.debug("Processing user: " + user.toDetailedString());

// GOOD: String only constructed if DEBUG is enabled
log.debug("Processing user: {}", user.getId());

// GOOD for expensive operations: explicit guard
if (log.isDebugEnabled()) {
    log.debug("Full user details: {}", user.toDetailedString());
}

SLF4J’s {} placeholders use lazy evaluation — the toString() is only called if the level is active.


Complete Logback Configuration

A production-ready logback-spring.xml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="true" scanPeriod="30 seconds">

    <springProperty scope="context" name="APP_NAME"
                    source="spring.application.name" defaultValue="unknown"/>

    <!-- Dev: colored human-readable -->
    <springProfile name="dev,local">
        <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
            <encoder>
                <pattern>%d{HH:mm:ss.SSS} %highlight(%-5level) [%15.15thread] %cyan(%-40.40logger{39}) : %msg%n</pattern>
            </encoder>
        </appender>
        <root level="DEBUG">
            <appender-ref ref="CONSOLE"/>
        </root>
        <logger name="org.springframework" level="INFO"/>
        <logger name="org.hibernate.SQL" level="DEBUG"/>
    </springProfile>

    <!-- Production: structured JSON -->
    <springProfile name="prod,staging">
        <appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
            <encoder class="net.logstash.logback.encoder.LogstashEncoder">
                <includeMdcKeyName>traceId</includeMdcKeyName>
                <includeMdcKeyName>spanId</includeMdcKeyName>
                <includeMdcKeyName>userId</includeMdcKeyName>
                <customFields>{"service":"${APP_NAME}"}</customFields>
                <timestampPattern>yyyy-MM-dd'T'HH:mm:ss.SSS'Z'</timestampPattern>
                <fieldNames>
                    <timestamp>@timestamp</timestamp>
                    <version>[ignore]</version>
                </fieldNames>
            </encoder>
        </appender>

        <appender name="ASYNC_JSON" class="ch.qos.logback.classic.AsyncAppender">
            <queueSize>2048</queueSize>
            <discardingThreshold>0</discardingThreshold>
            <neverBlock>true</neverBlock>
            <appender-ref ref="JSON"/>
        </appender>

        <root level="INFO">
            <appender-ref ref="ASYNC_JSON"/>
        </root>
        <logger name="org.springframework" level="WARN"/>
        <logger name="org.hibernate" level="WARN"/>
    </springProfile>
</configuration>

Common Anti-Patterns

Anti-PatternProblemFix
e.printStackTrace()Goes to stderr, no level, no contextUse log.error("msg", e)
Logging and throwingSame error logged twice (or more)Log at the boundary, throw everywhere else
log.error("Error: " + e.getMessage())Loses stack traceUse log.error("Error processing", e) — exception as last arg
Excessive INFO in loopsNoise drowns signalUse DEBUG for per-item, INFO for batch summary
Logging full request/response bodiesPII leaks, huge volumeLog summary only (method, path, status, duration)
Different log format per serviceUnparseable in aggregatorStandardize JSON format across all services
Not logging correlation IDsCannot trace requestsUse Micrometer Tracing + MDC
System.out.println for debuggingNo level, no timestamp, no contextAlways use SLF4J: log.debug(...)

References

This post is licensed under CC BY 4.0 by the author.