Logging: The SLF4J Facade and Logback in Practice

bee2026-10-0861 min read0 views
Why facades matter, how to use levels, how to write logback-spring.xml, and how MDC carries a traceId across a request — turning logging from println into a searchable engineering capability.
1 / 174
Section
0. The 30-second version
2 / 174

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.

3 / 174

Six words, one line each (used throughout):

4 / 174
  • 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.jar on 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
5 / 174
类比|Analogy

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.

6 / 174
Diagram
Figure · The pipeline of one log line: how many pairs of hands from log.info to disk
Figure · The pipeline of one log line: how many pairs of hands from log.info to disk
7 / 174

After this article you should be able to answer three questions:

8 / 174
  • Why did I add log.debug(...) and get nothing printed? (The two real answers are not the two you suspect.)
  • logback.xml versus logback-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?
9 / 174
Section
1. The logging facade: why business code must not depend on Logback
10 / 174

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.

11 / 174

The root cause is binding "what you need" to "who provides it". A logging stack has two kinds of players:

12 / 174
  • 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.
13 / 174

Sometimes a bridge sits in between, redirecting the output of a legacy implementation into the unified pipeline.

14 / 174
Table
RoleExamplesWhat it owns
Facade APISLF4J, JCLThe only thing business code depends on; the call shape never changes
ImplementationLogback, Log4j2, JULLevel checks, formatting and writing to disk
Bridgejul-to-slf4j, log4j-over-slf4jFunnels third-party library logs into one pipeline
15 / 174

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.

16 / 174
Diagram
Figure 1 · Layers of a logging stack
Figure 1 · Layers of a logging stack
17 / 174

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:

18 / 174
Animation
Animation · Swap the plug, or rewire the whole building
Animation · Swap the plug, or rewire the whole building
19 / 174
Note

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.

20 / 174
Section
2. Log levels: how to use the five gates
21 / 174
Animation
Animation · The journey of one log line
Animation · The journey of one log line
22 / 174

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.

23 / 174
Table
LevelTest (ask yourself)Production guidance
ERRORUsers or business are affected; a human must actAlways on, and should trigger an alert
WARNRecoverable but noteworthy; may worsenAlways on, review periodically
INFOKey business facts: startup, switches, state changesAlways on, but do not spam
DEBUGDetail only useful while troubleshootingOff by default; enable per package
TRACEFiner than DEBUG, almost frame by framePractically never on in production
24 / 174
Section
2.1 How levels take effect: root and inheritance
25 / 174

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:

26 / 174
Code
Codeyaml
logging:  level:    root: INFO                 # fallback: everything unconfigured stays at INFO    com.example.order: DEBUG   # only the order package goes to DEBUG
Notes
  • com.example.order.OrderService matches the DEBUG logger on com.example.order
  • com.example.user.UserService matches nothing, walks up to root, and stays at INFO
  • A log.debug call 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.

27 / 174

"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:

28 / 174
Diagram
LayersWho decides the level of one log.debug call1 / 5
Click ① to ⑤. Boxes ②③ explain 'I only switched DEBUG on one package, why is everything noisy'; box ⑤ explains 'the level is clearly on but nothing prints'
→
→
→
→
① Look up the logger by fully qualified name
Logback keeps a slot per logger name: com.example.order.OrderService first asks 'did anyone configure me explicitly', and if not it splits the name on dots and walks upward. Pure string matching — nothing is guessed.
All clearTo change a level, ask who is nearest to the class. To find a missing line, ask whether the second gate blocked it.
29 / 174
Section
3. Using SLF4J correctly
30 / 174
Section
3.1 Use `{}` placeholders, never string concatenation
31 / 174
Code
Codejava
// 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);
Notes
  • In the bad version the + concatenation is an argument, evaluated before debug() — 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
32 / 174
Section
3.2 The exception must be the last argument
33 / 174
Code
Codejava
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);}
Notes

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.

34 / 174
Section
3.3 Do you still need `isDebugEnabled`?
35 / 174

You see it often in legacy code:

36 / 174
java
if (logger.isDebugEnabled()) {    logger.debug("state={}", expensiveToString(obj));}// In the vast majority of cases this is enoughlogger.debug("state={}", obj);
37 / 174

The conclusion is firm: in the vast majority of cases you do not need isDebugEnabled.

38 / 174
  • 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, expensiveToString runs 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
39 / 174
Section
4. Spring Boot's three-knob setup
40 / 174

Three settings in application.yml cover 80% of daily needs without changing any code:

41 / 174
Code
Codeyaml
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"
Notes
  • logging.level: package name as key, level as value. Change a line and restart — the most common switch
  • logging.file.name: once set, logs go to both console and file; logging.file.path names only a directory and the file becomes spring.log
  • logging.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.

42 / 174

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:

43 / 174
Generator
GeneratorThe three-knob setup, plus the line that makes levels changeable liveapplication.yml2 / 4
Tick Logging alone for the minimum three lines (level, file, pattern); add Profile and compare it with the <springProfile> split in Section 5.1; finally tick Actuator — the management.endpoints.web.exposure.include it emits is exactly the endpoint Section 4.1 uses to rewrite levels, so think about whether it is reachable from the public internet
Output
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: true
Why each choice matters
loggingLevels work per package; root=DEBUG floods you with third-party output — never in production.
profiles + 分档配置Multi-document blocks are split by --- and activated with spring.config.activate.on-profile.
44 / 174
Trap

logging.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.

45 / 174
Section
4.1 Changing levels without a restart via Actuator
46 / 174

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:

47 / 174
Code
Codebash
# 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.order
Notes
  • GET reads, POST writes, and the change applies immediately, with no restart
  • Remember to expose loggers in management.endpoints.web.exposure.include (or use *)
  • Change it back afterwards — the topic of the decision card in Section 12
48 / 174
Section
5. Deep Logback configuration: logback-spring.xml
49 / 174

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:

50 / 174
Code
Codexml
<?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>
Notes
  • <property> defines reusable variables; ${LOG_HOME} appears throughout, so moving the directory is a one-line change
  • ConsoleAppender: %highlight only emits ANSI codes on capable terminals; when written to a file it degrades to plain text
  • RollingFileAppender: the policy is Section 6; %i is the index of the file within a single day
  • AsyncAppender: wraps the real appender; appender-ref pointing to FILE means file writes are asynchronous
  • LevelFilter: onMatch=ACCEPT passes ERROR, onMismatch=DENY blocks everything else, so ERROR_FILE holds only errors
  • root carries the fallback level plus the sink list; logger entries 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.

51 / 174

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.

52 / 174
Match
MatchWhat every % token in the pattern really doesMatched 0/6 · Missed 0
Six hard mappings. Do not use positions — and one of these six is quietly expensive
Pick a card on the left first
53 / 174
Section
5.1 Switching environments with profiles
54 / 174

The killer feature of logback-spring.xml is per-environment behavior:

55 / 174
xml
<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>
56 / 174

Development wants DEBUG on the console; production wants INFO written asynchronously — one file, two behaviors, which logback.xml cannot do.

57 / 174
Section
6. Rolling policies and the disk budget
58 / 174

Log files cannot grow forever; they must roll. Logback offers two practical policies:

59 / 174
Table
PolicyTriggerBest for
TimeBasedRollingPolicyTime only (daily by default)Simple daily archiving
SizeAndTimeBasedRollingPolicyTime + per-file sizeHigh volume, hundreds of MB per day
60 / 174

SizeAndTimeBasedFNATP (SizeAndTimeBasedFileNamingAndTriggeringPolicy) is the component behind "size plus time" rolling; today you usually use the SizeAndTimeBasedRollingPolicy shorthand:

61 / 174
Code
Codexml
<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>
Notes
  • %d{yyyy-MM-dd} names by day; %i is the shard index (bee.2026-10-07.0.log.gz, .1.log.gz, …)
  • maxFileSize keeps a single file manageable — huge single files are painful to tail and to ship
  • maxHistory controls retention along the time axis
  • totalSizeCap is 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.

62 / 174
Section
7. MDC tracing: carrying a traceId across a request
63 / 174

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}.

64 / 174

Step one — a filter puts the traceId in place at the entry point:

65 / 174
Code
Codejava
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        }    }}
Notes
  • Ordered.HIGHEST_PRECEDENCE runs first, so every later log line carries the traceId
  • Reading X-Trace-Id first 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
66 / 174

Step two — reference it in the pattern (the [%X{traceId}] from Section 4), and the line becomes:

67 / 174
text
2026-10-07 10:12:33.482 INFO  [http-nio-8080-exec-3] [9f2c1a7b3e5d8401] c.e.order.OrderService - created order orderId=10023
68 / 174

Both 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:

69 / 174
Kernel lab
TeaVMMDC put and get: who writes, who reads, where it livesidle
Run 'Put and get' and watch which map put lands in and which one %X{} reads. Once that is clear, losing the traceId across threads stops feeling like an accident and starts feeling like arithmetic.
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
70 / 174

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":

71 / 174
Kernel lab
TeaVMWhich cell resolves %X{traceId}idle
Pick 'The pattern' to see when %thread, %-5level and %X{traceId} each take their value; run it once with an empty MDC and you get those empty brackets for real
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
72 / 174
Section
7.1 Async threads lose the MDC
73 / 174

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:

74 / 174
Code
Codejava
@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            }        };    }}
Notes
  • getCopyOfContextMap() snapshots on the submitting thread
  • setContextMap() restores on the executing thread
  • MDC.clear() in finally: 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.

75 / 174

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:

76 / 174
Diagram
Figure · Carrying the MDC across threads
Figure · Carrying the MDC across threads
77 / 174

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?

78 / 174
Stepper
StepperStep by step: how those empty brackets get printed1 / 7
Seven beats. Beat ④ decides whether the snapshot can be taken at all; beat ⑥ decides whether the traceId survives
Code under debug
1MDC.put("traceId", "9f2c1a7b"); // TraceIdFilter, the very first thing that runs
2log.info("order placed userId={}", 7); // this line still prints the id
3notifier.sendAsync(order); // @Async: hand the work to the pool
4Map<String,String> snap = MDC.getCopyOfContextMap(); // snapshot taken on the submitting thread
5executor.setTaskDecorator(new MdcTaskDecorator()); // omit this line and everything below prints []
6MDC.setContextMap(snap); task.run(); // replay on async-2, then run
7log.info("sms sent orderId={}", 10023); // whether this has an id depends on beat 6
Variables now
threadhttp-nio-8080-exec-3
this thread's MDC{traceId=9f2c1a7b}
underlying storageThreadLocal<Map<String,String>>
Call stack
1TraceIdFilter.doFilterInternal
2MDC.put
1put does not write a global table; it writes this Tomcat worker's own map. @Order(HIGHEST_PRECEDENCE) is what guarantees it runs before anything that logs — otherwise the first few lines of every request print empty brackets.
79 / 174

The same chain in the kernel, with the async branch selected — you can watch the traceId disappear at beat ⑤:

80 / 174
Kernel lab
TeaVMHow the traceId vanishes on the async branchidle
Run the timeline without the decorator first and locate the beat where the id disappears; then enable the TaskDecorator and re-run, comparing the map contents at beat ⑥
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
81 / 174
Section
7.2 Across services: the traceId must travel in a header
82 / 174

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:

83 / 174
Code
Codejava
@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");        }    }}
Notes
  • 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 TraceIdFilter reuses the id from the header and only generates one if it is absent; that is the purpose of the request.getHeader("X-Trace-Id") line in Section 7
  • Both ends must agree on the format: traceparent is version-traceId-spanId-flags, and a hand-rolled field name just looks like two unrelated requests to any tracing system
84 / 174

This animation walks one id across two network hops — frame 4 is the empty-brackets moment from above:

85 / 174
Animation
Animation · One traceId's journey across services
Animation · One traceId's journey across services
86 / 174
Kernel lab
TeaVMAcross services: MDC to traceparent and back to MDCidle
Step through in order: the outbound header is empty, the interceptor writes it, the downstream filter reads it and puts it into its own MDC, and the downstream log line carries the very same id
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
87 / 174
Note

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.

88 / 174
Section
8. JSON structured logs: paving the road to ELK
89 / 174

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:

90 / 174
xml
<dependency>    <groupId>net.logstash.logback</groupId>    <artifactId>logstash-logback-encoder</artifactId>    <version>7.4</version></dependency>
91 / 174
xml
<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>
92 / 174

The output is one line of standard JSON:

93 / 174
Code
Codejson
{"@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..."}
Notes
  • Fields like @timestamp, level and logger_name are filled in by the encoder, not by hand
  • includeMdcKeyName promotes traceId to a first-class field you can filter on in ELK
  • In production, keep the console human-readable and the file JSON — people read the terminal, machines read the file
94 / 174
Section
9. Ten logging rules
95 / 174
Table
#RuleWhyDo this
1No System.out.printlnBypasses the framework: no level, no formatAlways log.info(...)
2No e.printStackTrace()Goes to stderr, no timestamp, no traceIdlog.error("msg", e)
3Always log the full stackA message alone is no clueException as the last argument
4Never log secretsPasswords/tokens/IDs end up in the log platformMask them, or log a hash
5No INFO inside loops100k iterations means 100k linesAggregate: "processed N, failed M"
6Placeholders, not concatenationConcatenation runs before the level checklog.debug("id={}", id)
7Make logs searchableA log you cannot locate is noiseInclude business keys: orderId, userId
8Keep ERROR rareIf everything is ERROR, alerts stop workingERROR only when a human must act
9Do not use logs as control flowDeciding logic from logs is fragileLogic for logic, logs for logs
10Externalize paths and levelsHard-coded values cannot adaptLeave it to yml or a config service
96 / 174
Key point

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.

97 / 174
Section
10. Three real traps
98 / 174
Section
10.1 Why async logging drops messages
99 / 174

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.

100 / 174
Trap

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.

101 / 174

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:

102 / 174
Tuner
TunerHow deep is the async queue: drop logs, or slow the business
logback AsyncAppender queueSize
512queued eventsNow 64 – 8192
Normal range: absorbs one write stall
  • 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
Event drop rate12%
Business threads blocked6%
Queue length buys how much write latency you tolerate. Want zero loss? Then accept that the business thread may block — you can only pick one of the two.
103 / 174
Section
10.2 A loop that fills the disk
104 / 174

This snippet is a recurring incident:

105 / 174
java
// 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);}
106 / 174

Aggregate instead:

107 / 174
Code
Codejava
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);
Notes
  • Do not log per successful row; log failures and the summary only
  • Now one import produces "failures + 1" lines instead of 50,000
108 / 174
Section
10.3 Logging a large object kills performance
109 / 174

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.

110 / 174
Warning

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.

111 / 174
Section
11. Interactive demo: which callback should logging hook into?
112 / 174

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.

113 / 174
Kernel lab
TeaVMObserve the bean lifecycle and the right moment to logidle
Think: to log bean creation, which callback should you use?
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
114 / 174
  • 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
115 / 174
Section
12. Production judgment: should you turn DEBUG on?
116 / 174
Decision
Decisionovernight your service starts throwing occasional timeouts, and the suspect path only logs at INFO. Your first instinct is to flip the whole app to DEBUG. Does that work?
117 / 174
Section
13. Take the pipeline apart yourself: five arguments, five segments
118 / 174

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:

119 / 174
Kernel lab
TeaVMWhich box connects the facade to the implementationidle
Pick 'Facade binding' to see how org.slf4j.Logger finds logback-classic at startup — that is the moment the adapter plugs in
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
120 / 174

Level filtering happens before formatting, which makes the key point of Section 2 worth verifying live:

121 / 174
Kernel lab
TeaVMWhere exactly does the level gate closeidle
Pick 'Level filtering' and watch a rejected event never build a string; then switch to 'Pattern layout' to see where %X{traceId} and %-5level get substituted
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
122 / 174

Async and rolling come last because they decide the fate of an event after it was let through:

123 / 174
Kernel lab
TeaVMThe async queue and the rolling archiveidle
First 'Async appender' to see the business thread return after writing only to a queue; then 'Rolling & archiving' to see when a file is renamed, compressed and finally deleted
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
124 / 174
  • 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; totalSizeCap is the real fuse protecting the disk
125 / 174
Section
14. Where config comes from, who may change levels, why async stalls
126 / 174

Three peripheral mechanisms sit outside the pipeline yet decide its behaviour, one lab each.

127 / 174

First: how does your logging.level.* actually reach Logback? Through Spring's property system — meaning it obeys the precedence and profile rules entirely:

128 / 174
Kernel lab
TeaVMlogging.level is also subject to precedenceidle
Pick 'Who wins' and express logging.level.com.example three ways — yml, environment variable, command line — then predict the winner; use 'Profile activation' to see when application-prod.yml overrides the default
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
129 / 174

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:

130 / 174
Kernel lab
TeaVM/actuator/loggers changes levels — and is itself a riskidle
Pick 'Exposure' to decide which endpoints to publish; then 'Everything open' to understand why /actuator/loggers belongs behind authentication — one POST from anyone can flood your production disk
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
131 / 174

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:

132 / 174
Kernel lab
TeaVMAsync has two faces: losing traceId and losing logsidle
Use 'Submit' and 'Queue grows' against Section 10.1: once the queue fills, neverBlock and discardingThreshold decide who gets dropped; then consider the exception path next to the MDC TaskDecorator
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
133 / 174

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:

134 / 174
Console
135 / 174
Note

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.

136 / 174
Section
15. Sandbox: root=INFO plus one package at DEBUG — what actually differs?
137 / 174

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:

138 / 174
Sandbox
SandboxLog level picker: DEBUG for one package only
Result
10:12:33.482 INFO c.e.o.OrderController - order request userId=7
10:12:33.490 DEBUG c.e.o.OrderService - stock check sku=A1 need=2 stock=5
10:12:33.498 DEBUG c.e.o.PriceCalculator - gross=99.0 coupon=10.0 due=89.0
10:12:33.510 INFO c.e.o.OrderService - order created orderId=10023
# only the order package gets chatty; user and pay packages are untouched
Total: 4 lines per request
The recommended posture: blast radius limited to one package, bounded disk growth, every clue still inside.
139 / 174
说明|Note

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.

140 / 174
Section
16. Check yourself
141 / 174

A warm-up question, straight from the disaster that opens Section 1:

142 / 174
Quiz
Check yourselfThe team wants to move its logging implementation from Log4j2 to Logback. Which current state of the code makes that migration most expensive?
Pick one — you get feedback right away
143 / 174

Now a combined question threading Section 2's level inheritance, Section 4's configuration sources and the logback.xml trap of Section 5:

144 / 174
Quiz
Check yourselfapplication.yml sets logging.level.root=INFO and logging.level.com.example.order=DEBUG, yet log.debug("order={}", order) in OrderService prints nothing. Which explanation CANNOT hold?
Pick one — you get feedback right away
145 / 174
Section
17. Common errors, quick reference
146 / 174
Table
Error fragmentReal cause30-second self-rescueDig deeper in
Classpath collision detected: ... both import ... org.slf4j.impl.StaticLoggerBinderMore than one binding on the classpath (say both logback-classic and log4j-slf4j-impl); SLF4J must pick one and warns you that it picked arbitrarilymvn dependency:tree -Dincludes=org.slf4j,ch.qos.logback,org.apache.logging.log4j, then <exclusions> until exactly one remainsThis 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 culpritThis article, Section 5
The app starts but not a single log line appears, or only the bannerThe level is suppressed: logging.level.root=ERROR, some third-party file redefined root, or a logback.xml loaded firstcurl localhost:8080/actuator/loggers/ROOT and read effectiveLevel; grep the startup output for a competing config warningThis 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 MDCInstall a TaskDecorator on the pool (Section 7.1) and MDC.clear() in a finallyThis article, Section 7
After a traffic spike, everything except ERROR has vanished in whole stretchesAsyncAppender's discardingThreshold drops TRACE/DEBUG once the queue is 80% full; with neverBlock=true a full queue drops outrightFor zero loss use discardingThreshold=0 + neverBlock=false and accept blocking; for throughput raise queueSizeThis article, Section 10.1
Chinese characters in the log file show as ??? or mojibakeThe encoder declares no charset so it inherits the JVM's file.encoding; writer and reader (editor, tail, log platform) disagreeWrite <encoder><charset>UTF-8</charset></encoder> explicitly; add -Dfile.encoding=UTF-8 if needed and standardise the reading sideThis article, Section 5
Editing logback-spring.xml appears to do nothingYou edited the copy under target/classes, or scan="true" is off and nothing restartedEdit the source file and rebuild; or rely on <configuration scan="true" scanPeriod="60 seconds"> and wait a minuteThis 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 versionAlign versions (Boot 3.x needs encoder 7.4+); verify with mvn dependency:tree that only one logback-classic is presentThis article, Section 8
147 / 174
Tip

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.

148 / 174

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:

149 / 174
Triage
Error triageno applicable action for [springProfile]
One renamed file, and the prod block silently stops existing

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.

10:12:31,482 |-INFO in ch.qos.logback.classic.LoggerContext[default] - Could NOT find resource [logback-test.xml]
10:12:31,483 |-INFO in ch.qos.logback.classic.LoggerContext[default] - Found resource [logback.xml] at [file:/D:/bee/target/classes/logback.xml]
10:12:31,552 |-ERROR in ch.qos.logback.core.joran.spi.Interpreter@14:82 - no applicable action for [springProfile], current ElementPath is [[configuration][springProfile]]
10:12:31,553 |-ERROR in ch.qos.logback.core.joran.spi.Interpreter@15:30 - no applicable action for [springProperty], current ElementPath is [[configuration][springProfile][springProperty]]
10:12:31,560 |-WARN in ch.qos.logback.core.rolling.RollingFileAppender[FILE] - Attempted to append to non started appender [FILE]
10:12:31,561 |-INFO in ch.qos.logback.classic.joran.JoranConfigurator@3f1a2b - Registering current configuration as safe fallback point
Click the frame you blame — guessing is allowed
No pressure: guess the exception first, then which line actually made the call.
150 / 174
Section
18. Hands-on practice
151 / 174
Section
Tier 1 · Follow along
152 / 174

Goal: assemble a minimal setup that shows colour on the console in development, writes JSON files in production, and carries a traceId.

153 / 174

Step one — dependencies stay at the default (spring-boot-starter-web already brings SLF4J + Logback); add structured output:

154 / 174
xml
<dependency>    <groupId>net.logstash.logback</groupId>    <artifactId>logstash-logback-encoder</artifactId>    <version>7.4</version></dependency>
155 / 174

Step two — src/main/resources/logback-spring.xml:

156 / 174
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>
157 / 174

Step three — reuse the TraceIdFilter from Section 7 to populate the MDC, then write one line in any service:

158 / 174
java
log.info("order created orderId={}", order.getId());
159 / 174

Step four — the command and the expected output:

160 / 174
bash
$ 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 only
161 / 174

You pass when the console line carries the bracketed traceId and logs/ is still empty.

162 / 174
Section
Tier 2 · Variants
163 / 174
  1. Switch to -Dspring-boot.run.profiles=prod and hit the endpoint again. You will observe: logs/bee-json.log appears, containing one standard JSON object with a "traceId":"9f2c1a7b3e5d8401" field.
  2. Set maxFileSize to 1KB and log 5000 lines in a loop. You will observe: the directory fills with bee-json.2026-10-07.0.log.gz, .1.log.gz… — that is %i at work. Now set totalSizeCap to 1MB and you will observe: the oldest shards start disappearing.
  3. 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 to logback.xml (dropping -spring) and restart. You will observe: startup fails with Logging system failed to initialize, because <springProfile> is not understood in that file.
164 / 174
Section
Tier 3 · Build one
165 / 174

Deliver an "operable logging baseline" — a configuration someone else could debug an incident with. Acceptance checklist:

166 / 174
  • [ ] Three profiles (dev / staging / prod) driven by one logback-spring.xml via <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 onMatch and onMismatch of a LevelFilter put to use)
  • [ ] Files constrained simultaneously by maxFileSize, maxHistory and totalSizeCap, and you can explain what breaks without totalSizeCap
  • [ ] 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 curl command to run when production misbehaves
167 / 174
Section
19. Key points, self-checked
168 / 174
自检|Self-check

can I map facade / implementation / bridge onto specific boxes of the pipeline diagram? If not, go back to Section 0.

169 / 174
自检|Self-check

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>?

170 / 174
自检|Self-check

does every one of my log.error calls pass the exception as the final argument instead of e.getMessage()?

171 / 174
自检|Self-check

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?

172 / 174
自检|Self-check

for any combination of queueSize, discardingThreshold and neverBlock, can I say what gets dropped and who gets blocked?

173 / 174
口诀|Mnemonic

code knows only the plug, sockets may change freely; levels change without a restart, and the MDC always gets returned.

174 / 174
Summary

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.