skip to content

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

logger =
console =
file =
log callloggerconsolefile
TRACEentering checkout(42)dropped··
DEBUGcart: 3 items, 1999 centspasses·written
INFOorder 42 placedpassesprintedwritten
WARNINGstock low for sku-7passesprintedwritten
ERRORpayment failed for order 42passesprintedwritten

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.

set the three levels. a call has to clear the logger first, then each handler separately. the useful setting is usually a quiet console and a chattier file. notice what happens when the logger is stricter than a handler: the handler never sees a thing.

the level names differ between libraries, the idea doesn't:

meaningSystem.Loggerjava.util.loggingSLF4J, logback, log4j2
every step, very noisyTRACEFINER, FINESTTRACE
detail for debuggingDEBUGFINEDEBUG
setup and configCONFIG
normal, noteworthy eventsINFOINFOINFO
something odd, still workingWARNINGWARNINGWARN
something failedERRORSEVEREERROR

here's java.util.logging, which ships in the JDK, with a handler pointed at stdout and a one-line format:

Main.java
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
}
output, from jdk 25
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.

hover a stage. the first two are what your code sees, the rest is configuration. swap the backend and not a line of the code changes.

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.

empty loop1.8 ns
supplier1.8 ns
isLoggable guard1.8 ns
"item " + i10 ns
".." + expensive(i)69 ns
log scale
nanoseconds per disabled 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.
don'teager
java
log.debug("cart: " + cart.dump());   // dump() runs, string is built, then dropped
dolazy
java
log.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
predict the output
java
// SLF4J, with the logger at INFO
log.debug("state: {}", dump());   // dump() prints "dump ran" and returns a String

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

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

java
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.)

keylines
  • 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.