logging
System.Logger, java.util.logging, SLF4J. levels and where logs go.
- chapter
- chapter 04.11
- reading time
- 7 min read
every log call has a level. the logger throws away anything below its level (that's the cheap gate), then each handler (an "appender" elsewhere) decides whether to write it, and where.
code logs through a facade (System.Logger in the JDK, SLF4J everywhere else) and the application picks the backend. build messages lazily, so a disabled log line costs about nothing.
the picture
a newsroom. reporters file stories all day, each marked with how urgent it is (the level). the editor (the logger) spikes everything below today's bar before anyone reads it. what survives goes to the channels (handlers): the website takes everything, the evening news only the big stories. each channel has its own bar, and a story the editor spiked never reaches any of them.
two gates
| log call | logger | console | file |
|---|---|---|---|
| TRACEentering checkout(42) | dropped | · | · |
| DEBUGcart: 3 items, 1999 cents | passes | · | written |
| INFOorder 42 placed | passes | printed | written |
| WARNINGstock low for sku-7 | passes | printed | written |
| ERRORpayment failed for order 42 | passes | printed | written |
1 of 5 calls are stopped by the logger itself, the cheap gate: no record object, and with a supplier, no message string either. every record the logger builds lands somewhere.
the level names differ between libraries, the idea doesn't:
| meaning | System.Logger | java.util.logging | SLF4J, logback, log4j2 |
|---|---|---|---|
| every step, very noisy | TRACE | FINER, FINEST | TRACE |
| detail for debugging | DEBUG | FINE | DEBUG |
| setup and config | CONFIG | ||
| normal, noteworthy events | INFO | INFO | INFO |
| something odd, still working | WARNING | WARNING | WARN |
| something failed | ERROR | SEVERE | ERROR |
here's java.util.logging, which ships in the JDK, with a handler pointed at stdout and a one-line format:
import java.util.logging.*;
void main() {
System.setProperty("java.util.logging.SimpleFormatter.format", "%4$-7s %3$s: %5$s%n");
Logger log = Logger.getLogger("orders");
log.setUseParentHandlers(false); // don't also print via the root's handler
Handler out = new StreamHandler(System.out, new SimpleFormatter()) {
@Override public synchronized void publish(LogRecord r) { super.publish(r); flush(); }
};
out.setLevel(Level.ALL); // gate 2: this handler takes everything
log.addHandler(out);
log.setLevel(Level.INFO); // gate 1
log.fine("cart has 3 items"); // below INFO: dropped
log.info("order 42 placed");
log.warning("stock low for sku-7");
log.setLevel(Level.FINE);
log.fine(() -> "cart has " + 3 + " items"); // the lambda runs only because FINE is on now
}INFO orders: order 42 placed WARNING orders: stock low for sku-7 FINE orders: cart has 3 items
this one needs a real JVM (logging), so it doesn't run in the browser. copy it into java Main.java to try it.
out of the box (no code at all), JUL reads $JAVA_HOME/conf/logging.properties: the root logger at INFO, one ConsoleHandler at INFO, writing two lines per record to stderr. loggers form a tree by name (orders.checkout is a child of orders), and a logger without its own level or handlers uses its parent's.
who's who
hover a stage.
the rule that keeps this sane: libraries log through a facade and never configure anything. applications pick one backend. if your library calls logback directly, every app that uses it inherits logback, whether they wanted it or not.
System.Logger is the JDK's own facade. by default it sends everything to java.util.logging, but a backend can plug in through the System.LoggerFinder service (SLF4J ships a bridge for it), so even the JDK's internal logging ends up in your app's backend.
lazy messages
a log line below the level should cost nothing. the trap is that java evaluates arguments before calling the method, so the message is built even when the logger is about to drop it.
fine(...) call, best of 5 after warm-up, temurin 25 on an M4 Pro. expensive(i) joins 20 numbers into a string. a supplier (fine(() -> ...)) or an isLoggable guard makes a disabled call as cheap as an empty loop; string concatenation in the argument does not.log.debug("cart: " + cart.dump()); // dump() runs, string is built, then droppedlog.debug("cart: {}", cart); // SLF4J: toString() only if enabled
log.atDebug().log(() -> "cart: " + cart.dump()); // SLF4J 2 fluent API, a supplier
julLogger.fine(() -> "cart: " + cart.dump()); // java.util.logging// SLF4J, with the logger at INFO
log.debug("state: {}", dump()); // dump() prints "dump ran" and returns a Stringstructured logging
a log line like order 42 placed for ada in 31 ms is easy to read and annoying to query. structured logging keeps the values as fields, usually one JSON object per line, so a log store can filter order=42 or chart ms:
{"ts":"2026-10-09T08:02:11Z","level":"INFO","logger":"orders","msg":"order placed","order":42,"user":"ada","ms":31,"requestId":"7f3c"}in SLF4J 2 that's the fluent API plus a JSON encoder in the backend:
log.atInfo().addKeyValue("order", 42).addKeyValue("ms", 31).log("order placed");for values that belong to a whole request (a request id, a user), the MDC (mapped diagnostic context) attaches them to every line logged on that thread until you clear it. (with virtual threads and thread pools, clear it in a finally.)
- two gates: the logger's level, then each handler's. the logger is the cheap one.
- libraries log to a facade (
System.Logger, SLF4J). applications pick the backend. - arguments are evaluated before the call. suppliers or level checks make disabled logs free.
- pass the exception object, not its message. log it once, where it's handled.
- structured logs (key-values, JSON) are for machines. ids in the MDC.