Bug Description
SLF4JLogger.atFatal() returns atLevel(Level.TRACE) instead of atLevel(Level.FATAL). This is a copy-paste error introduced in commit 113a8e85f4 (LOG4J2-3647, 2023-01-13). Every call to logger.atFatal().log(...) through the log4j-to-slf4j bridge emits the message at TRACE level instead of ERROR (SLF4J's equivalent of FATAL). In any environment where TRACE is disabled — virtually every production deployment — the message is silently discarded.
Affected file: log4j-to-slf4j/src/main/java/org/apache/logging/slf4j/SLF4JLogger.java (line 366)
Introduced in: 2.20.0 (commit 113a8e8, LOG4J2-3647)
Affected versions: 2.20.0 through 2.24.3 (current latest stable)
Not affected: Traditional API logger.fatal() works correctly — only the fluent LogBuilder API is affected.
Root Cause
All at*() methods delegate to atLevel() with the corresponding level constant. atFatal() was copied from atTrace() and the level was never updated:
public LogBuilder atTrace() {
return atLevel(Level.TRACE); // correct
}
public LogBuilder atDebug() {
return atLevel(Level.DEBUG); // correct
}
public LogBuilder atInfo() {
return atLevel(Level.INFO); // correct
}
public LogBuilder atWarn() {
return atLevel(Level.WARN); // correct
}
public LogBuilder atError() {
return atLevel(Level.ERROR); // correct
}
@Override
public LogBuilder atFatal() {
return atLevel(Level.TRACE); // BUG: should be Level.FATAL
}
What Happens at Runtime
With Level.TRACE (the bug), convertLevel() returns TRACE_INT. Two paths lead to message loss:
Path A — Logback backend (LAZY_LEVEL_CHECK = true):
atFatal() → atLevel(Level.TRACE) → SLF4JLogBuilder → logMessage() passes Level.TRACE to SLF4J → Logback checks if TRACE is enabled → disabled in production → message silently dropped.
Path B — Non-Logback SLF4J backend (LAZY_LEVEL_CHECK = false):
atFatal() → atLevel(Level.TRACE) → AbstractLogger.atLevel() calls isEnabled(Level.TRACE) → returns false → returns LogBuilder.NOOP → message never constructed.
Proof of Concept
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
// Requires log4j-to-slf4j bridge with SLF4J/Logback backend
// Logger level set to INFO (standard production configuration)
public class AtFatalLogLoss {
private static final Logger logger = LogManager.getLogger(AtFatalLogLoss.class);
public static void main(String[] args) {
// Traditional API: works correctly
logger.fatal("TRADITIONAL: System crash detected");
// Output: logged at ERROR level in SLF4J ✓
// Fluent API: message silently lost
logger.atFatal().log("FLUENT: System crash detected");
// Output: NOTHING — logged at TRACE, filtered out ✗
}
}
Unit Test
import ch.qos.logback.classic.LoggerContext;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.junit.jupiter.api.Test;
import org.slf4j.LoggerFactory;
import static org.junit.jupiter.api.Assertions.*;
class SLF4JAtFatalLogLossTest {
@Test
void atFatal_message_is_silently_lost_in_production_config() {
LoggerContext logbackContext = (LoggerContext) LoggerFactory.getILoggerFactory();
ch.qos.logback.classic.Logger logbackLogger = logbackContext.getLogger("test.AtFatal");
logbackLogger.setLevel(ch.qos.logback.classic.Level.INFO);
ListAppender<ILoggingEvent> appender = new ListAppender<>();
appender.start();
logbackLogger.addAppender(appender);
Logger log4jLogger = LogManager.getLogger("test.AtFatal");
// Traditional API: message logged correctly as ERROR
log4jLogger.fatal("traditional fatal");
assertEquals(1, appender.list.size());
assertEquals(ch.qos.logback.classic.Level.ERROR, appender.list.get(0).getLevel());
appender.list.clear();
// Fluent API: message SILENTLY LOST
log4jLogger.atFatal().log("fluent fatal");
assertEquals(1, appender.list.size(),
"atFatal().log() should produce a log event, but the message is silently lost");
}
@Test
void atFatal_logs_at_trace_level_instead_of_error() {
LoggerContext logbackContext = (LoggerContext) LoggerFactory.getILoggerFactory();
ch.qos.logback.classic.Logger logbackLogger = logbackContext.getLogger("test.AtFatalLevel");
logbackLogger.setLevel(ch.qos.logback.classic.Level.TRACE);
ListAppender<ILoggingEvent> appender = new ListAppender<>();
appender.start();
logbackLogger.addAppender(appender);
Logger log4jLogger = LogManager.getLogger("test.AtFatalLevel");
log4jLogger.atFatal().log("this should be ERROR level");
assertEquals(1, appender.list.size());
// FAILS: actual level is TRACE, not ERROR
assertEquals(ch.qos.logback.classic.Level.ERROR, appender.list.get(0).getLevel(),
"atFatal() should map to SLF4J ERROR, but actual level is: " + appender.list.get(0).getLevel());
}
}
Proposed Fix
One-word fix — change TRACE to FATAL:
@Override
public LogBuilder atFatal() {
- return atLevel(Level.TRACE);
+ return atLevel(Level.FATAL);
}
Affected Versions
| Version |
Status |
| < 2.20.0 |
Not affected |
| 2.20.0 – 2.24.3 |
Affected |
The fluent atFatal() method in SLF4JLogger has been non-functional since its introduction in 2.20.0 (7 releases, 3+ years).
Bug Description
SLF4JLogger.atFatal()returnsatLevel(Level.TRACE)instead ofatLevel(Level.FATAL). This is a copy-paste error introduced in commit113a8e85f4(LOG4J2-3647, 2023-01-13). Every call tologger.atFatal().log(...)through thelog4j-to-slf4jbridge emits the message at TRACE level instead of ERROR (SLF4J's equivalent of FATAL). In any environment where TRACE is disabled — virtually every production deployment — the message is silently discarded.Affected file:
log4j-to-slf4j/src/main/java/org/apache/logging/slf4j/SLF4JLogger.java(line 366)Introduced in: 2.20.0 (commit 113a8e8, LOG4J2-3647)
Affected versions: 2.20.0 through 2.24.3 (current latest stable)
Not affected: Traditional API
logger.fatal()works correctly — only the fluentLogBuilderAPI is affected.Root Cause
All
at*()methods delegate toatLevel()with the corresponding level constant.atFatal()was copied fromatTrace()and the level was never updated:What Happens at Runtime
With
Level.TRACE(the bug),convertLevel()returnsTRACE_INT. Two paths lead to message loss:Path A — Logback backend (
LAZY_LEVEL_CHECK = true):atFatal()→atLevel(Level.TRACE)→SLF4JLogBuilder→logMessage()passesLevel.TRACEto SLF4J → Logback checks if TRACE is enabled → disabled in production → message silently dropped.Path B — Non-Logback SLF4J backend (
LAZY_LEVEL_CHECK = false):atFatal()→atLevel(Level.TRACE)→AbstractLogger.atLevel()callsisEnabled(Level.TRACE)→ returns false → returnsLogBuilder.NOOP→ message never constructed.Proof of Concept
Unit Test
Proposed Fix
One-word fix — change
TRACEtoFATAL:@Override public LogBuilder atFatal() { - return atLevel(Level.TRACE); + return atLevel(Level.FATAL); }Affected Versions
The fluent
atFatal()method inSLF4JLoggerhas been non-functional since its introduction in 2.20.0 (7 releases, 3+ years).