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.
# logging.properties (JUL)
.level = INFO
com.shop.db.level = FINE
java.util.logging.ConsoleHandler.level = FINEA 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.Logger | JUL | Use it for |
|---|---|---|
ERROR | SEVERE | A request or job failed and someone may need to act: a database down, a payment provider erroring. |
WARNING | WARNING | Something odd that the service handled: a retry that succeeded, a deprecated endpoint called. |
INFO | INFO | Business events and lifecycle: started on port 8080, order placed, migration applied. |
DEBUG | FINE | Detail for diagnosing a problem: the SQL built, the cache key, the branch taken. |
TRACE | FINER | Very 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
Supplieror 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
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.
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 2Picking 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.
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 503Checking 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.
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