Skip to content

SLF4JLogger.atFatal() returns atLevel(Level.TRACE) instead of Level.FATAL — silent loss of FATAL log events #4068

@cookiejack15

Description

@cookiejack15

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)SLF4JLogBuilderlogMessage() 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    apiAffects the public APIbugIncorrect, unexpected, or unintended behavior of existing codeslf4jAffects SLF4J integration

    Type

    No type
    No fields configured for issues without a type.

    Projects

    Status

    Done

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions