Logging: The SLF4J Facade and Logback in Practice
System.out.println also prints. But all it can do is type on the screen. Real logging has to deliver five things at once: switchable per level (production must not stream DEBUG), written to files that roll automatically (the disk must not fill), carrying context (who is this request, what is its traceId), searchable by machines (ELK, one object per line), and swappable implementations without touching code. Those five together are what "a logging system" means — and Spring Boot already installs the whole set for you by default.
Six words, one line each (used throughout):
- Facade: the interface layer that only defines how you call and does no work itself —
org.slf4j.Logger - Implementation: the library that actually checks levels, formats lines and writes bytes — Logback, Log4j2
- Binding: the wire connecting facade to implementation; physically it is the
logback-classic.jaron your classpath - Bridge: a jar that quietly redirects a legacy implementation's output into the facade, e.g.
jul-to-slf4j - Appender: an outlet for log events — console, file and async queue are each one appender
- MDC: a small scratchpad bound to the current thread; whatever you write into it shows up in every subsequent log line
the logging facade is a travel power adapter. Your laptop's plug never changes shape; in Japan you fit one socket, in the UK a square-pin one — same device, different adapter. Writing import org.apache.log4j.Logger everywhere is like cutting the laptop's cord and soldering it permanently into a British wall socket: the day you move countries (swap logging implementations), you must rewire the entire building.

After this article you should be able to answer three questions:
- Why did I add
log.debug(...)and get nothing printed? (The two real answers are not the two you suspect.) logback.xmlversuslogback-spring.xml: one word apart, how much functionality does that cost?- One request crosses five services and writes twenty lines — how do I stitch them together in ten seconds?
Start with a real disaster. A project, to save a little effort, imported org.apache.log4j.Logger directly in every class, hard-wiring itself to one implementation. Years later Log4j shipped a critical security advisory and the whole company was told to move to Log4j2. Every other service changed one dependency version and moved on; this one had to edit dozens of classes and re-run the entire test suite, because log calls were scattered everywhere and coupled to the implementation.
The root cause is binding "what you need" to "who provides it". A logging stack has two kinds of players:
- Facade: defines the interface only, e.g.
Logger.info(...). Business code knows nothing else. - Implementation: decides where logs actually go and in what format — Logback, Log4j2, or the JDK's JUL.
Sometimes a bridge sits in between, redirecting the output of a legacy implementation into the unified pipeline.
| Role | Examples | What it owns |
|---|---|---|
| Facade API | SLF4J, JCL | The only thing business code depends on; the call shape never changes |
| Implementation | Logback, Log4j2, JUL | Level checks, formatting and writing to disk |
| Bridge | jul-to-slf4j, log4j-over-slf4j | Funnels third-party library logs into one pipeline |
The history goes roughly: JCL (2002) → a wild mix of implementations → SLF4J (2006) unified the facade. JCL failed because it looked up implementations dynamically at runtime; one messy classpath and you got bizarre NoClassDefFoundErrors. SLF4J switched to static binding at build time: with no implementation on the classpath it merely prints a warning instead of crashing. That is why every modern framework — Spring, Hibernate, MyBatis — talks to SLF4J internally.

That opening disaster deserves an animation to nail it down — on the left, the cost of soldering the wire into the socket; on the right, the cost of swapping an adapter. One security advisory, two endings:

Spring Boot's default pairing is SLF4J (facade) + Logback (implementation), pulled in by spring-boot-starter-logging. If you use spring-boot-starter-web, the pair is already there and needs no configuration.

A level is not an "importance ranking" but a filter threshold: only messages at or above the configured level get through. Confuse this and you get the classic outage — DEBUG enabled in production until the disk fills up.
| Level | Test (ask yourself) | Production guidance |
|---|---|---|
| ERROR | Users or business are affected; a human must act | Always on, and should trigger an alert |
| WARN | Recoverable but noteworthy; may worsen | Always on, review periodically |
| INFO | Key business facts: startup, switches, state changes | Always on, but do not spam |
| DEBUG | Detail only useful while troubleshooting | Off by default; enable per package |
| TRACE | Finer than DEBUG, almost frame by frame | Practically never on in production |
Logback's levels form a tree. root is the trunk, and every package or class is a branch. A logger with no explicit configuration inherits the nearest configured ancestor. So:
logging: level: root: INFO # fallback: everything unconfigured stays at INFO com.example.order: DEBUG # only the order package goes to DEBUGcom.example.order.OrderServicematches the DEBUG logger oncom.example.ordercom.example.user.UserServicematches nothing, walks up to root, and stays at INFO- A
log.debugcall with root at INFO is dropped before any string formatting happens
Key point: the level check happens before formatting — that is the heart of SLF4J's performance model. It also explains the frequently misunderstood isDebugEnabled in Section 3.
"Whoever sits closest to the class wins" is easier to click through than to memorise. Step through the stack below, one box at a time; boxes ③ and ⑤ are the ones that answer real tickets:
// Bad: the concatenation runs before log(), even if the level is disabledlogger.debug("load user id=" + userId + ", name=" + name + ", cost=" + cost + "ms");// Good: formatting only happens when the level lets the event throughlogger.debug("load user id={}, name={}, cost={}ms", userId, name, cost);- In the bad version the
+concatenation is an argument, evaluated beforedebug()— even when production filters DEBUG out - In the good version the format and arguments are passed separately; if filtered, not a single string is built
- In a loop this difference is multiplied thousands of times
try { orderService.create(order);} catch (Exception e) { // Passing the exception last makes SLF4J print the full stack trace log.error("Failed to create order orderId={}, userId={}", order.getId(), order.getUserId(), e);}Trap: many people write log.error("Failed to create order: " + e.getMessage()). The stack trace is lost entirely, leaving one bald line in production with no clue whether it was an NPE or a timeout. Worse still is e.printStackTrace(): it writes to stderr, bypassing the logging framework, so there is no timestamp, no traceId, and ELK cannot find it.
You see it often in legacy code:
if (logger.isDebugEnabled()) { logger.debug("state={}", expensiveToString(obj));}// In the vast majority of cases this is enoughlogger.debug("state={}", obj);The conclusion is firm: in the vast majority of cases you do not need isDebugEnabled.
- When the arguments are plain variables or references, placeholders already avoid the formatting cost; the outer check is pure noise
- A guard is only worth it when building the argument is itself expensive — iterating a large collection, serializing a snapshot. In the first snippet above,
expensiveToStringruns even if the log is filtered, so the check earns its keep - The cost is longer, less readable code. Default to not writing it; add it only when you have a real performance problem — do not optimize from imagination
Three settings in application.yml cover 80% of daily needs without changing any code:
logging: level: # (1) levels, per package, no code change root: INFO com.example: DEBUG org.springframework.jdbc.core: DEBUG # enable to see SQL parameters com.zaxxer.hikari: INFO # keep pool logs from flooding file: name: ./logs/bee.log # (2) also write to a file (console stays) pattern: console: "%d{HH:mm:ss.SSS} %-5level [%X{traceId}] %logger{36} - %msg%n" file: "%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%X{traceId}] %logger{36} - %msg%n"logging.level: package name as key, level as value. Change a line and restart — the most common switchlogging.file.name: once set, logs go to both console and file;logging.file.pathnames only a directory and the file becomesspring.loglogging.pattern: controls the format.%X{traceId}is a reserved slot for the MDC of Section 7
Tip: logging.file.name and logging.file.path are mutually exclusive; name wins. In production, "console only" is often preferable — let the container or the log collector handle files, as explained in Section 8.
Those three lines are the floor, not the ceiling: a real service also needs the profile split and the Actuator endpoint. Instead of borrowing someone's yml, tick the boxes and watch what comes out — the comments explaining why each line exists are the point:
server:
port: 8080
spring:
application:
name: demo-service
logging:
level:
root: INFO
com.example.demoservice: DEBUG
org.springframework.jdbc.core.JdbcTemplate: DEBUG # 打 SQL 与参数
file:
name: logs/app.log
logback:
rollingpolicy: { max-file-size: 50MB, max-history: 14 }
---
spring:
config:
activate:
on-profile: prod
logging:
level: { root: WARN }
---
spring:
config:
activate:
on-profile: dev
spring:
jpa:
show-sql: truelogging.file.name and the logback-spring.xml of Section 5 are two competing sink configurations. Write both and Boot switches its own appenders off in favour of your XML — which is how "I set a file path and there is no file" happens. Pick one source of truth: either the three knobs, or the XML.
The worst part of production troubleshooting is that changing config means restarting. Since Spring Boot 2.0 the actuator exposes a loggers endpoint for hot level changes:
# Turn only the order package to DEBUG; nothing else changescurl -X POST http://localhost:8080/actuator/loggers/com.example.order \ -H 'Content-Type: application/json' \ -d '{"configuredLevel":"DEBUG"}'# Inspect the effective levelcurl http://localhost:8080/actuator/loggers/com.example.orderGETreads,POSTwrites, and the change applies immediately, with no restart- Remember to expose
loggersinmanagement.endpoints.web.exposure.include(or use*) - Change it back afterwards — the topic of the decision card in Section 12
When needs outgrow the three knobs — per-level routing, async, size-plus-time rolling — write a full config file, src/main/resources/logback-spring.xml:
<?xml version="1.0" encoding="UTF-8"?><configuration scan="true" scanPeriod="60 seconds"> <!-- 1. Variables: log directory and file prefix, one place to change --> <property name="LOG_HOME" value="./logs"/> <property name="APP_NAME" value="bee"/> <!-- 2. Console: for development, with color --> <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> <!-- 3. File: rolling by time AND size, gzip history --> <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>${LOG_HOME}/${APP_NAME}.log</file> <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy"> <fileNamePattern>${LOG_HOME}/${APP_NAME}.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern> <maxFileSize>100MB</maxFileSize> <maxHistory>30</maxHistory> <totalSizeCap>5GB</totalSizeCap> </rollingPolicy> <encoder> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] [%X{traceId}] %logger{36} - %msg%n</pattern> </encoder> </appender> <!-- 4. Async wrapper: business thread writes to a queue only --> <appender name="ASYNC_FILE" class="ch.qos.logback.classic.AsyncAppender"> <queueSize>1024</queueSize> <discardingThreshold>0</discardingThreshold> <neverBlock>true</neverBlock> <appender-ref ref="FILE"/> </appender> <!-- 5. Route by level: ERROR goes to its own file for alerting --> <appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>${LOG_HOME}/${APP_NAME}-error.log</file> <filter class="ch.qos.logback.classic.filter.LevelFilter"> <level>ERROR</level> <onMatch>ACCEPT</onMatch> <onMismatch>DENY</onMismatch> </filter> <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy"> <fileNamePattern>${LOG_HOME}/${APP_NAME}-error.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern> <maxFileSize>50MB</maxFileSize> <maxHistory>60</maxHistory> </rollingPolicy> <encoder><pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%X{traceId}] %msg%n</pattern></encoder> </appender> <!-- 6. Package levels: our code verbose, frameworks quiet --> <logger name="com.example" level="DEBUG"/> <logger name="org.apache.ibatis" level="WARN"/> <logger name="com.zaxxer.hikari" level="INFO"/> <root level="INFO"> <appender-ref ref="CONSOLE"/> <appender-ref ref="ASYNC_FILE"/> <appender-ref ref="ERROR_FILE"/> </root></configuration><property>defines reusable variables;${LOG_HOME}appears throughout, so moving the directory is a one-line changeConsoleAppender:%highlightonly emits ANSI codes on capable terminals; when written to a file it degrades to plain textRollingFileAppender: the policy is Section 6;%iis the index of the file within a single dayAsyncAppender: wraps the real appender;appender-refpointing toFILEmeans file writes are asynchronousLevelFilter:onMatch=ACCEPTpasses ERROR,onMismatch=DENYblocks everything else, so ERROR_FILE holds only errorsrootcarries the fallback level plus the sink list;loggerentries are finer overrides, and the closest wins
Trap: logback-spring.xml and logback.xml are not the same. Only logback-spring.xml supports Spring extensions like <springProfile> and <springProperty>, and only it can read ${spring.application.name}. Name it logback.xml and Logback loads it before the Spring environment exists, the tags fail, and profile support is gone.
The % tokens in those patterns are the only part of the whole file that fails silently — a wrong one simply prints nothing. Play a round: token on the left, what it actually does on the right; one of the six is a performance trap, see if you find it.
The killer feature of logback-spring.xml is per-environment behavior:
<springProfile name="dev"> <root level="DEBUG"><appender-ref ref="CONSOLE"/></root></springProfile><springProfile name="prod"> <root level="INFO"> <appender-ref ref="ASYNC_FILE"/> <appender-ref ref="ERROR_FILE"/> </root></springProfile>Development wants DEBUG on the console; production wants INFO written asynchronously — one file, two behaviors, which logback.xml cannot do.
Log files cannot grow forever; they must roll. Logback offers two practical policies:
| Policy | Trigger | Best for |
|---|---|---|
TimeBasedRollingPolicy | Time only (daily by default) | Simple daily archiving |
SizeAndTimeBasedRollingPolicy | Time + per-file size | High volume, hundreds of MB per day |
SizeAndTimeBasedFNATP (SizeAndTimeBasedFileNamingAndTriggeringPolicy) is the component behind "size plus time" rolling; today you usually use the SizeAndTimeBasedRollingPolicy shorthand:
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy"> <fileNamePattern>./logs/bee.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern> <maxFileSize>100MB</maxFileSize> <!-- roll once a single file passes 100MB --> <maxHistory>30</maxHistory> <!-- keep the last 30 days --> <totalSizeCap>5GB</totalSizeCap> <!-- cap the total archive size --></rollingPolicy>%d{yyyy-MM-dd}names by day;%iis the shard index (bee.2026-10-07.0.log.gz,.1.log.gz, …)maxFileSizekeeps a single file manageable — huge single files are painful to tail and to shipmaxHistorycontrols retention along the time axistotalSizeCapis the hard disk ceiling: once exceeded, the oldest archives are deleted
Note: why is totalSizeCap necessary? Because maxHistory only bounds days, not volume per day. If one day spikes to 2GB, 30 days is 60GB — totalSizeCap=5GB caps it by deleting the oldest archives. In production configure both, or a single traffic spike fills the disk.
The most painful problem in microservices: one request crosses five services and writes twenty log lines — how do you stitch them together? The answer is the MDC (Mapped Diagnostic Context), a thread-bound key-value store referenced in the pattern via %X{key}.
Step one — a filter puts the traceId in place at the entry point:
package com.example.web.filter;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;import java.io.IOException;import java.util.UUID;@Component@Order(Ordered.HIGHEST_PRECEDENCE)public class TraceIdFilter extends OncePerRequestFilter { public static final String TRACE_ID = "traceId"; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { // Reuse the gateway's value if present so the whole chain shares one id String traceId = request.getHeader("X-Trace-Id"); if (traceId == null || traceId.isBlank()) { traceId = UUID.randomUUID().toString().replace("-", "").substring(0, 16); } MDC.put(TRACE_ID, traceId); response.setHeader("X-Trace-Id", traceId); try { chain.doFilter(request, response); } finally { MDC.remove(TRACE_ID); // must clean up or thread reuse leaks it } }}Ordered.HIGHEST_PRECEDENCEruns first, so every later log line carries the traceId- Reading
X-Trace-Idfirst lets a gateway- or client-generated id flow end to end - After
MDC.put,%X{traceId}in the pattern renders it automatically — no business code changes
Step two — reference it in the pattern (the [%X{traceId}] from Section 4), and the line becomes:
2026-10-07 10:12:33.482 INFO [http-nio-8080-exec-3] [9f2c1a7b3e5d8401] c.e.order.OrderService - created order orderId=10023Both steps can be run in the kernel. The first is "put and get": underneath, the MDC is a ThreadLocal<Map<String,String>>, so put writes into the current thread's map and %X{} reads that same one:
The second lab is that pattern line: why does %X{traceId} sometimes print []? Because substitution happens inside the encoder, which asks whatever thread is doing the encoding, not "where this request came from":
The MDC is backed by a ThreadLocal, so a newly spawned thread does not see the parent's MDC. With @Async or a custom pool, the traceId turns up empty. The fix is a TaskDecorator that copies the context when the task is submitted:
@EnableAsync@Configurationpublic class AsyncConfig implements AsyncConfigurer { @Override public Executor getAsyncExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(8); executor.setMaxPoolSize(16); executor.setQueueCapacity(200); executor.setThreadNamePrefix("async-"); executor.setTaskDecorator(new MdcTaskDecorator()); // key: propagate the MDC executor.initialize(); return executor; }}class MdcTaskDecorator implements TaskDecorator { @Override public Runnable decorate(Runnable runnable) { Map<String, String> context = MDC.getCopyOfContextMap(); // snapshot on the submitter return () -> { try { if (context != null) MDC.setContextMap(context); // restore on the worker runnable.run(); } finally { MDC.clear(); // clear before returning the thread to the pool } }; }}getCopyOfContextMap()snapshots on the submitting threadsetContextMap()restores on the executing threadMDC.clear()infinally: pooled threads are reused, and without clearing, the next request inherits the previous traceId and your investigation goes badly wrong
Warning: missing a single cleanup is extremely deceptive — two unrelated requests share a traceId and you will believe they are the same transaction. Every MDC.put must have a matching MDC.remove in a finally. This is a rule, not a suggestion.
With and without a TaskDecorator differ in exactly one line, and the consequence fits on one page. Note the last row of the left column — crossed wires are worse than blanks, because the log still looks healthy:

The decorator is easy to copy; the ordering is not. Spread the chain out as a single-step run and click through: watch the moment the thread name changes from http-nio-8080-exec-3 to async-2, and beat ④'s question — whose thread runs the snapshot?
MDC.put("traceId", "9f2c1a7b"); // TraceIdFilter, the very first thing that runslog.info("order placed userId={}", 7); // this line still prints the idnotifier.sendAsync(order); // @Async: hand the work to the poolMap<String,String> snap = MDC.getCopyOfContextMap(); // snapshot taken on the submitting threadexecutor.setTaskDecorator(new MdcTaskDecorator()); // omit this line and everything below prints []MDC.setContextMap(snap); task.run(); // replay on async-2, then runlog.info("sms sent orderId={}", 10023); // whether this has an id depends on beat 6| thread | http-nio-8080-exec-3 |
| this thread's MDC | {traceId=9f2c1a7b} |
| underlying storage | ThreadLocal<Map<String,String>> |
TraceIdFilter.doFilterInternalMDC.putThe same chain in the kernel, with the async branch selected — you can watch the traceId disappear at beat ⑤:
Carrying it to the pool thread only solves one process. Once the call crosses the network, the MDC cannot follow — it is a ThreadLocal, and there is no thread on the other side. It has to be written into an HTTP header and put back into the downstream MDC by the downstream filter. The industry format is the W3C traceparent header:
@Componentpublic class TraceFeignInterceptor implements RequestInterceptor { @Override public void apply(RequestTemplate template) { String traceId = MDC.get("traceId"); if (traceId != null) { template.header("traceparent", "00-" + traceId + "-" + spanId() + "-01"); } }}- On the way out, the interceptor reads the current thread's MDC — which is why Section 7.1's handoff is a prerequisite, not a nicety
- On the way in, the downstream
TraceIdFilterreuses the id from the header and only generates one if it is absent; that is the purpose of therequest.getHeader("X-Trace-Id")line in Section 7 - Both ends must agree on the format:
traceparentisversion-traceId-spanId-flags, and a hand-rolled field name just looks like two unrelated requests to any tracing system
This animation walks one id across two network hops — frame 4 is the empty-brackets moment from above:

you would not hand-roll this in production. micrometer-tracing-bridge-brave together with spring-boot-starter-actuator makes Spring Boot 3 generate the id, publish it into the MDC under traceId/spanId and propagate traceparent automatically. The reason to write it yourself once is that only then can you see which beats the framework was doing for you.
The logs above are human-friendly but machine-hostile: to feed ELK you write a pile of regexes to extract fields. The more engineered option is to emit JSON directly, one object per line with its own fields:
<dependency> <groupId>net.logstash.logback</groupId> <artifactId>logstash-logback-encoder</artifactId> <version>7.4</version></dependency><appender name="JSON_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>${LOG_HOME}/${APP_NAME}-json.log</file> <encoder class="net.logstash.logback.encoder.LogstashEncoder"> <includeMdcKeyName>traceId</includeMdcKeyName> <!-- emit the MDC traceId too --> </encoder> <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy"> <fileNamePattern>${LOG_HOME}/${APP_NAME}-json.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern> <maxFileSize>100MB</maxFileSize> <maxHistory>15</maxHistory> </rollingPolicy></appender>The output is one line of standard JSON:
{"@timestamp":"2026-10-07T10:12:33.482Z","level":"ERROR","logger_name":"com.example.order.OrderService","thread_name":"http-nio-8080-exec-3","traceId":"9f2c1a7b3e5d8401","message":"Failed to create order orderId=10023","stack_trace":"java.lang.RuntimeException: out of stock..."}- Fields like
@timestamp,levelandlogger_nameare filled in by the encoder, not by hand includeMdcKeyNamepromotes traceId to a first-class field you canfilteron in ELK- In production, keep the console human-readable and the file JSON — people read the terminal, machines read the file
| # | Rule | Why | Do this |
|---|---|---|---|
| 1 | No System.out.println | Bypasses the framework: no level, no format | Always log.info(...) |
| 2 | No e.printStackTrace() | Goes to stderr, no timestamp, no traceId | log.error("msg", e) |
| 3 | Always log the full stack | A message alone is no clue | Exception as the last argument |
| 4 | Never log secrets | Passwords/tokens/IDs end up in the log platform | Mask them, or log a hash |
| 5 | No INFO inside loops | 100k iterations means 100k lines | Aggregate: "processed N, failed M" |
| 6 | Placeholders, not concatenation | Concatenation runs before the level check | log.debug("id={}", id) |
| 7 | Make logs searchable | A log you cannot locate is noise | Include business keys: orderId, userId |
| 8 | Keep ERROR rare | If everything is ERROR, alerts stop working | ERROR only when a human must act |
| 9 | Do not use logs as control flow | Deciding logic from logs is fragile | Logic for logic, logs for logs |
| 10 | Externalize paths and levels | Hard-coded values cannot adapt | Leave it to yml or a config service |
of these ten, rules 1–3 are red lines — they decide whether you can investigate an incident at all. The rest are efficiency and hygiene you can improve over time, but the red lines must never be broken.
AsyncAppender defaults to discarding TRACE/DEBUG events once the queue is nearly full (20% remaining) — that is discardingThreshold. Set neverBlock=true (do not block business threads when full) with a small queueSize, and a traffic spike drops logs — precisely the ones you needed.
the fundamental cause is that "logging must not slow business" and "not a single line may be lost" are in natural conflict. For throughput, raise queueSize (say 4096) and accept dropping low levels at the extreme; for zero loss, use neverBlock=false + discardingThreshold=0 and accept blocking when the queue fills. There is no free lunch, only a trade-off.
That trade-off has exactly one number you can drag, so drag it. From 64 to 8192, watch who gets dropped and who gets blocked:
- 512-1024 suits most services: it swallows a second-level hiccup on disk
- Queue depth buys tolerance to flush latency, it does not buy 'nothing is ever lost'
- If you still drop here, the disk or the collector genuinely cannot keep up and resizing the queue will not help
- Pair it with leaving includeCallerData off — otherwise every event walks the stack
This snippet is a recurring incident:
// Bad: importing 50k rows, one INFO per row, can blow past 500MB in secondsfor (Order order : orders) { log.info("processing order {}", order.getId()); importService.handle(order);}Aggregate instead:
int failed = 0;for (Order order : orders) { try { importService.handle(order); } catch (Exception e) { failed++; log.warn("import failed orderId={}", order.getId(), e); }}log.info("bulk import done total={}, failed={}", orders.size(), failed);- Do not log per successful row; log failures and the summary only
- Now one import produces "failures + 1" lines instead of 50,000
log.debug("resp={}", hugeResponse) looks harmless, but if hugeResponse.toString() recursively serializes thousands of fields, every call does heavy work. Worse, some people pack a full payload into toString, and one log line costs tens of milliseconds.
log the "identifier plus key fields", not log.debug("obj={}", obj) on an entire aggregate. If you truly need everything, guard it with if (log.isTraceEnabled()) and serialize deliberately rather than relying on toString.
Logging and the bean lifecycle share a natural connection: to log "this object was created", which of the eight lifecycle phases should you hook into? The demo below runs a bean from instantiation to destruction — watch it and think this through.
- To record "who was created", the best spot is post-initialization (
BeanPostProcessor#postProcessAfterInitialization), once dependencies are injected and proxies are ready - To record "resources released", use the destruction callback (
@PreDestroy) - Logging inside the constructor is usually too early — dependencies are not injected yet and the message is incomplete
The sections above gave you the conclusions; this one shows why they hold. The pipeline figure from Section 0 — business call → facade → binding → filter → layout → appender → disk — is exactly the spine of the lab below:
Level filtering happens before formatting, which makes the key point of Section 2 worth verifying live:
Async and rolling come last because they decide the fate of an event after it was let through:
- Binding happens very early in startup, which is why two bindings on the classpath warn immediately (row 1 of Section 17)
- Level checks belong to the Logger while filters belong to the Appender — they are not the same thing, and confusing them produces "the level is clearly on yet nothing prints"
- Layout/Encoder is the only place that can see the MDC;
%X{key}is resolved there - AsyncAppender changes who performs the I/O, not whether the event should be emitted
- Rolling policies trigger only on size or time;
totalSizeCapis the real fuse protecting the disk
Three peripheral mechanisms sit outside the pipeline yet decide its behaviour, one lab each.
First: how does your logging.level.* actually reach Logback? Through Spring's property system — meaning it obeys the precedence and profile rules entirely:
Second: who powers the hot level change of Section 4.1? Actuator exposes a writable endpoint that calls Logback's API to mutate the Logger objects directly:
Third: Section 7.1 says the MDC vanishes on async threads, Section 10.1 says AsyncAppender drops events. Those two "asyncs" are one problem seen from two sides — one loses context, the other loses events:
Enough buttons — type it yourself. This console is wired to the same in-browser Java kernel, and every line of output is computed there: logs shows what this container is printing right now, then work down the lab lines:
run logs and then lab logtrace async back to back and compare the two outputs — same business code, one set of brackets filled, one empty, and the only difference is which thread executed the line. That is Section 7.1 in thirty seconds.
What beginners really want to know is not "what levels exist" but "with my configuration, what will the screen show?". This sandbox pins root: INFO and switches only which extra package gets DEBUG, comparing output lines side by side:
10:12:33.482 INFO c.e.o.OrderController - order request userId=710:12:33.490 DEBUG c.e.o.OrderService - stock check sku=A1 need=2 stock=510:12:33.498 DEBUG c.e.o.PriceCalculator - gross=99.0 coupon=10.0 due=89.010:12:33.510 INFO c.e.o.OrderService - order created orderId=10023# only the order package gets chatty; user and pay packages are untouchedTotal: 4 lines per request
the counts and volumes are illustrative but the rule is real — DEBUG cost scales as requests × lines-per-request. Four lines in your hand become eight hundred per second on an endpoint at QPS 200. That is precisely why Section 4.1 insists on reverting immediately.
A warm-up question, straight from the disaster that opens Section 1:
Now a combined question threading Section 2's level inheritance, Section 4's configuration sources and the logback.xml trap of Section 5:
| Error fragment | Real cause | 30-second self-rescue | Dig deeper in |
|---|---|---|---|
Classpath collision detected: ... both import ... org.slf4j.impl.StaticLoggerBinder | More than one binding on the classpath (say both logback-classic and log4j-slf4j-impl); SLF4J must pick one and warns you that it picked arbitrarily | mvn dependency:tree -Dincludes=org.slf4j,ch.qos.logback,org.apache.logging.log4j, then <exclusions> until exactly one remains | This article, Section 3 |
Logging system failed to initialize using configuration from 'null' / Could not initialize Logback logging from class path resource [logback-spring.xml] | The XML is malformed, an appender class name is wrong, or you used <springProfile> inside logback.xml (that file loads too early to know Spring tags) | Read the first caused-by line to locate the element; confirm the file is named logback-spring.xml; temporarily rename it to fall back to defaults and prove the file is the culprit | This article, Section 5 |
| The app starts but not a single log line appears, or only the banner | The level is suppressed: logging.level.root=ERROR, some third-party file redefined root, or a logback.xml loaded first | curl localhost:8080/actuator/loggers/ROOT and read effectiveLevel; grep the startup output for a competing config warning | This article, Sections 2 and 4 |
[traceId] in the pattern is always the empty [] | Execution moved to another thread (@Async, a pool, CompletableFuture) and the new thread has no parent MDC | Install a TaskDecorator on the pool (Section 7.1) and MDC.clear() in a finally | This article, Section 7 |
| After a traffic spike, everything except ERROR has vanished in whole stretches | AsyncAppender's discardingThreshold drops TRACE/DEBUG once the queue is 80% full; with neverBlock=true a full queue drops outright | For zero loss use discardingThreshold=0 + neverBlock=false and accept blocking; for throughput raise queueSize | This article, Section 10.1 |
Chinese characters in the log file show as ??? or mojibake | The encoder declares no charset so it inherits the JVM's file.encoding; writer and reader (editor, tail, log platform) disagree | Write <encoder><charset>UTF-8</charset></encoder> explicitly; add -Dfile.encoding=UTF-8 if needed and standardise the reading side | This article, Section 5 |
Editing logback-spring.xml appears to do nothing | You edited the copy under target/classes, or scan="true" is off and nothing restarted | Edit the source file and rebuild; or rely on <configuration scan="true" scanPeriod="60 seconds"> and wait a minute | This article, Section 5 |
java.lang.IllegalStateException: Logback configuration error detected: ... Failed to parse mapping (Spring Boot 3.x) | logstash-logback-encoder version does not match the Logback major version | Align versions (Boot 3.x needs encoder 7.4+); verify with mvn dependency:tree that only one logback-classic is present | This article, Section 8 |
the most overlooked row here is "not a single log line". Nine times out of ten the framework is fine — somebody placed a second config file where you cannot see it. First check the effective level via /actuator/loggers/ROOT; second search the classpath for more than one logback*.xml.
That "second config file" clue is best practised live. The scene below raises no exception at all, yet it deletes the production environment's entire level decision. Do not read the analysis — click the line you think is guilty:
A colleague renames logback-spring.xml to logback.xml because 'the IDE template was called that'. Startup succeeds normally. It is only later that nobody can explain why prod never looks like it runs at INFO.
Goal: assemble a minimal setup that shows colour on the console in development, writes JSON files in production, and carries a traceId.
Step one — dependencies stay at the default (spring-boot-starter-web already brings SLF4J + Logback); add structured output:
<dependency> <groupId>net.logstash.logback</groupId> <artifactId>logstash-logback-encoder</artifactId> <version>7.4</version></dependency>Step two — src/main/resources/logback-spring.xml:
<?xml version="1.0" encoding="UTF-8"?><configuration scan="true" scanPeriod="60 seconds"> <property name="LOG_HOME" value="./logs"/> <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender"> <encoder> <pattern>%d{HH:mm:ss.SSS} %highlight(%-5level) [%X{traceId}] %cyan(%logger{36}) - %msg%n</pattern> <charset>UTF-8</charset> </encoder> </appender> <appender name="JSON_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>${LOG_HOME}/bee-json.log</file> <encoder class="net.logstash.logback.encoder.LogstashEncoder"> <includeMdcKeyName>traceId</includeMdcKeyName> </encoder> <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy"> <fileNamePattern>${LOG_HOME}/bee-json.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern> <maxFileSize>50MB</maxFileSize> <maxHistory>7</maxHistory> <totalSizeCap>1GB</totalSizeCap> </rollingPolicy> </appender> <springProfile name="dev"> <root level="INFO"><appender-ref ref="CONSOLE"/></root> </springProfile> <springProfile name="prod"> <root level="INFO"> <appender-ref ref="CONSOLE"/> <appender-ref ref="JSON_FILE"/> </root> </springProfile></configuration>Step three — reuse the TraceIdFilter from Section 7 to populate the MDC, then write one line in any service:
log.info("order created orderId={}", order.getId());Step four — the command and the expected output:
$ mvn spring-boot:run -Dspring-boot.run.profiles=dev... Started BeeApplication in 3.42 seconds# after hitting /orders in a browser:10:12:33.510 INFO [9f2c1a7b3e5d8401] c.e.o.OrderService - order created orderId=10023$ ls logs/# under the dev profile there is no bee-json.log — JSON_FILE is attached to prod onlyYou pass when the console line carries the bracketed traceId and logs/ is still empty.
- Switch to
-Dspring-boot.run.profiles=prodand hit the endpoint again. You will observe:logs/bee-json.logappears, containing one standard JSON object with a"traceId":"9f2c1a7b3e5d8401"field. - Set
maxFileSizeto1KBand log 5000 lines in a loop. You will observe: the directory fills withbee-json.2026-10-07.0.log.gz,.1.log.gz… — that is%iat work. Now settotalSizeCapto1MBand you will observe: the oldest shards start disappearing. - Delete the
<charset>UTF-8</charset>line and run on a machine whose default encoding is not UTF-8. You will observe: non-ASCII text turns into mojibake; adding the line back fixes it. Then rename the file tologback.xml(dropping-spring) and restart. You will observe: startup fails withLogging system failed to initialize, because<springProfile>is not understood in that file.
Deliver an "operable logging baseline" — a configuration someone else could debug an incident with. Acceptance checklist:
- [ ] Three profiles (dev / staging / prod) driven by one
logback-spring.xmlvia<springProfile>, with no duplicate config files - [ ] Business packages at INFO, the SQL package at DEBUG on demand, third-party frameworks (Hibernate, Hikari) pushed down to WARN or INFO
- [ ] An unbroken traceId chain: entry filter writes it → pattern prints it → async threads keep it via
TaskDecorator→ response header echoes it - [ ] ERROR routed to its own file and receiving only ERROR (both
onMatchandonMismatchof aLevelFilterput to use) - [ ] Files constrained simultaneously by
maxFileSize,maxHistoryandtotalSizeCap, and you can explain what breaks withouttotalSizeCap - [ ] One incident reproduced by promoting a package to DEBUG through Actuator and reverted to INFO afterwards, with zero restarts
- [ ] A README stating the exact first
curlcommand to run when production misbehaves
can I map facade / implementation / bridge onto specific boxes of the pipeline diagram? If not, go back to Section 0.
do I understand that logback.xml versus logback-spring.xml differ by load timing, not merely by name, which is why only the latter supports <springProfile>?
does every one of my log.error calls pass the exception as the final argument instead of e.getMessage()?
does my async pool carry a TaskDecorator, and can every MDC.put be matched with a remove/clear inside a finally in the same method?
for any combination of queueSize, discardingThreshold and neverBlock, can I say what gets dropped and who gets blocked?
code knows only the plug, sockets may change freely; levels change without a restart, and the MDC always gets returned.
burn three things into muscle memory — business code depends only on the SLF4J facade while Logback handles the implementation; a level is a filter threshold, not a ranking, so change it dynamically instead of restarting; every MDC.put needs a matching remove in a finally. The value of logging is not how much you write, but whether you can locate a problem in ten seconds. Treat it as a searchable engineering capability, not a stray println.