Log Levels

Choose the right level for each log line, set thresholds per package, and avoid the handler-level trap that hides your DEBUG output.

Syntax# logging.properties (JUL) .level = INFO com.shop.db.level = FINE java.util.logging.ConsoleHandler.level = FINE

A level says how much a line matters. Every logger has a threshold, and a record below it is dropped before any formatting happens. That is what lets one build log in detail on a laptop and stay quiet in production.

The levels and what they are for

System.LoggerJULUse it for
ERRORSEVEREA request or job failed and someone may need to act: a database down, a payment provider erroring.
WARNINGWARNINGSomething odd that the service handled: a retry that succeeded, a deprecated endpoint called.
INFOINFOBusiness events and lifecycle: started on port 8080, order placed, migration applied.
DEBUGFINEDetail for diagnosing a problem: the SQL built, the cache key, the branch taken.
TRACEFINERVery noisy step-by-step detail, almost never on.

JUL also has CONFIG (between INFO and FINE) and FINEST, plus ALL and OFF as thresholds.

Thresholds follow the name hierarchy

Logger names are dotted like packages. A level set on com.shop.db also applies to com.shop.db.pool unless that logger has its own. The root logger, named "", is the fallback; JUL's default root level is INFO. The usual production setup is root at INFO and one package raised to DEBUG while you chase a problem, then lowered again.

Two filters, not one

In JUL a record passes the logger's level and then each handler's level. The default ConsoleHandler has its own level of INFO. Setting a logger to FINE and seeing no DEBUG lines is the most common logging question there is: the handler still drops them. Logback has the same idea with appender filters.

Mistakes people make

  • Logging client errors at ERROR. A 404 or a failed validation is the caller's problem, not the service's. Log 4xx at DEBUG or INFO, 5xx at ERROR, or your alerts fire on every typo in a URL.
  • INFO in hot paths. One INFO line per row in a batch of a million rows costs real time and disk, and drowns the lines that matter.
  • Building messages that are thrown away. String concatenation runs even when the level is off. Use a Supplier or parameters.
  • Logging and rethrowing. The same failure then appears at ERROR two or three times. Log it where it is handled.

Levels are configuration, so they belong outside the code: a logging.properties file for JUL, logback.xml or logging.level.* properties in Spring Boot.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Raise com.shop.db to DEBUG on one running environment to see the SQL a slow endpoint builds, then lower it again.
  • Log 5xx responses at ERROR and 4xx at INFO so on-call alerts fire for service failures, not for clients sending bad input.
  • Keep a nightly import at INFO with one summary line per batch instead of one line per row.

More examples

The handler level hides DEBUG

The first DEBUG record passes the logger but the handler drops it. Only after the handler's level is lowered too does the line appear.

Example · java
import java.util.logging.*;

// Demo setup: print each record to stdout as "LEVEL logger - message".
void logToStdout() {
    Logger root = Logger.getLogger("");
    for (Handler h : root.getHandlers()) root.removeHandler(h);
    Formatter fmt = new SimpleFormatter();
    root.addHandler(new Handler() {
        @Override public void publish(LogRecord r) {
            if (isLoggable(r)) IO.println(r.getLevel() + " " + r.getLoggerName() + " - " + fmt.formatMessage(r));
        }
        @Override public void flush() {}
        @Override public void close() {}
    });
}

void main() {
    logToStdout();
    Logger root = Logger.getLogger("");
    Handler handler = root.getHandlers()[0];
    handler.setLevel(Level.INFO);              // what ConsoleHandler does by default

    Logger.getLogger("com.shop").setLevel(Level.FINE);
    System.Logger log = System.getLogger("com.shop.cart");

    log.log(System.Logger.Level.DEBUG, "attempt 1");
    handler.setLevel(Level.ALL);
    log.log(System.Logger.Level.DEBUG, "attempt 2");
}
// Output:
// FINE com.shop.cart - attempt 2

Picking the level from the response status

Successful requests are DEBUG and stay silent at the default INFO threshold, client errors are INFO, and only server errors reach ERROR, which is what an alert should watch.

Example · java
import java.util.logging.*;

// Demo setup: print each record to stdout as "LEVEL logger - message".
void logToStdout() {
    Logger root = Logger.getLogger("");
    for (Handler h : root.getHandlers()) root.removeHandler(h);
    Formatter fmt = new SimpleFormatter();
    root.addHandler(new Handler() {
        @Override public void publish(LogRecord r) {
            if (isLoggable(r)) IO.println(r.getLevel() + " " + r.getLoggerName() + " - " + fmt.formatMessage(r));
        }
        @Override public void flush() {}
        @Override public void close() {}
    });
}

System.Logger.Level levelFor(int status) {
    if (status >= 500) return System.Logger.Level.ERROR;
    if (status >= 400) return System.Logger.Level.INFO;
    return System.Logger.Level.DEBUG;
}

void main() {
    logToStdout();
    System.Logger access = System.getLogger("access");
    record Hit(String method, String path, int status) {}
    List<Hit> hits = List.of(
            new Hit("GET", "/orders", 200),
            new Hit("GET", "/orders/999", 404),
            new Hit("POST", "/payments", 503));
    for (Hit h : hits) {
        access.log(levelFor(h.status()), h.method() + " " + h.path() + " " + h.status());
    }
}
// Output:
// INFO access - GET /orders/999 404
// SEVERE access - POST /payments 503

Checking a level before expensive work

isLoggable guards a block of work, not just one message, so the dump loop never runs at INFO. The summary line at INFO gives operators what they need without one line per row.

Example · java
import java.util.logging.*;

// Demo setup: print each record to stdout as "LEVEL logger - message".
void logToStdout() {
    Logger root = Logger.getLogger("");
    for (Handler h : root.getHandlers()) root.removeHandler(h);
    Formatter fmt = new SimpleFormatter();
    root.addHandler(new Handler() {
        @Override public void publish(LogRecord r) {
            if (isLoggable(r)) IO.println(r.getLevel() + " " + r.getLoggerName() + " - " + fmt.formatMessage(r));
        }
        @Override public void flush() {}
        @Override public void close() {}
    });
}

record Row(int id, String status) {}

void main() {
    logToStdout();
    System.Logger log = System.getLogger("import");
    List<Row> rows = List.of(new Row(1, "ok"), new Row(2, "ok"), new Row(3, "bad"));

    if (log.isLoggable(System.Logger.Level.DEBUG)) {
        // A whole dump of the batch: only worth building when someone will read it.
        StringBuilder dump = new StringBuilder();
        for (Row r : rows) dump.append(r).append('\n');
        log.log(System.Logger.Level.DEBUG, dump.toString());
    }
    long bad = rows.stream().filter(r -> r.status().equals("bad")).count();
    log.log(System.Logger.Level.INFO, "Imported " + rows.size() + " rows, " + bad + " rejected");
}
// Output:
// INFO import - Imported 3 rows, 1 rejected

Discussion

  • Be the first to comment on this lesson.