Appearance
Logging Appenders: Console, File, Rolling and Custom
Chapter 16 ended with logs reaching the console only because the ROOT logger has a console appender. This chapter configures appenders yourself. The examples use Logback, Spring Boot's default; Log4j2 has the same concepts with a different syntax.
logback-spring.xml
Put the configuration in src/main/resources/logback-spring.xml. Spring Boot picks it up automatically (the -spring suffix also enables Spring features such as profile-specific sections). It has three parts:
xml
<configuration>
<!-- 1. Appenders: where logs go (one or more) -->
<appender name="APPENDER_NAME" class="fully.qualified.AppenderClass">
<encoder>
<pattern>%d %-5level %logger - %msg%n</pattern>
</encoder>
</appender>
<!-- 2. Loggers: level, appenders, additivity -->
<logger name="logger.name" level="INFO" additivity="true">
<appender-ref ref="APPENDER_NAME"/>
</logger>
<!-- 3. ROOT: the ancestor and fallback of every logger -->
<root level="INFO">
<appender-ref ref="APPENDER_NAME"/>
</root>
</configuration><appender>: a destination, with anameto refer to it and theclassthat implements it.<encoder>: the format of each line. An appender decides where, the encoder how; without an encoder, a console or file appender cannot write anything. In the pattern,%dis the date,%levelthe level (%-5levelpads it to 5 characters),%loggerthe logger name,%msgthe message,%na newline. Chapter 18 covers patterns fully.<logger>: configures one logger by name:level: its level. If omitted, it is inherited from the nearest ancestor that has one, up to ROOT.<appender-ref>: the appenders attached to it.additivity: whether accepted events continue up to the ancestors' appenders (defaulttrue).
<root>: the ROOT logger's level and appenders.
Providing your own logback-spring.xml replaces Spring Boot's default configuration, including its colored console format. Anything you do not define is simply not there.
Step 1: a console appender on ROOT
xml
<configuration>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d %-5level %logger - %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>
</configuration>PaymentController logs log.info("info log"). No <logger> element mentions it, so:
- level: inherited. Its ancestors have none configured, so it uses ROOT's INFO, and the event is accepted.
- appenders: the event goes up through
com.example.payment,com.exampleandcom(no appenders) to ROOT, whoseCONSOLEprints it once.
Step 2: an appender on the logger itself, and duplicate lines
Attach CONSOLE to the controller's logger as well:
xml
<logger name="com.example.payment.PaymentController" level="INFO" additivity="true">
<appender-ref ref="CONSOLE"/>
</logger>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>Every line now appears twice:
text
INFO c.e.p.PaymentController - info log
INFO c.e.p.PaymentController - info logThe logger's own CONSOLE prints it; then, because additivity="true", the event continues to ROOT, whose CONSOLE prints it again. This is the most common cause of duplicated log lines.
additivity="false" stops the climb: only the logger's own appenders run:
xml
<logger name="com.example.payment.PaymentController" level="INFO" additivity="false">
<appender-ref ref="CONSOLE"/>
</logger>Now each line prints once.
The silent loss
With additivity="false", the logger must have at least one appender of its own:
xml
<logger name="com.example.payment.PaymentController" level="INFO" additivity="false"/>This logger's events are accepted, then go nowhere: no appender of its own, and the way up is closed. Nothing is printed and no error appears. Logback records a warning only in its internal status, which you see with <configuration debug="true">:
text
WARN in Logger[com.example.payment.PaymentController] - No appenders present in context [default] for logger [com.example.payment.PaymentController].Step 3: level inheritance with additivity="false"
additivity is about appenders only. Levels are still inherited:
xml
<logger name="com.example.payment" level="WARN">
<appender-ref ref="CONSOLE"/>
</logger>
<!-- no level: inherited from com.example.payment, i.e. WARN -->
<logger name="com.example.payment.PaymentController" additivity="false">
<appender-ref ref="CONSOLE"/>
</logger>Logging all five levels from the controller prints only error log and warning log: the level came from the parent, even though the parent's appender is not used.
Parent levels do not filter
The reverse case, from chapter 16: the parent com.example has level WARN and a file appender; the controller's logger is at INFO with no appender:
xml
<appender name="FILE" class="ch.qos.logback.core.FileAppender">
<file>logs/app.log</file>
<encoder><pattern>%-5level %msg%n</pattern></encoder>
</appender>
<logger name="com.example" level="WARN">
<appender-ref ref="FILE"/>
</logger>
<logger name="com.example.payment.PaymentController" level="INFO"/>The file receives ERROR error log, WARN warning log and INFO info log. The INFO event was accepted at the controller's logger, and once accepted, it reaches every ancestor's appender regardless of the ancestor's level.
The built-in appenders
1. Console appender
ch.qos.logback.core.ConsoleAppender writes to the process's standard output (STDOUT). That is more useful than it sounds:
- locally, the terminal shows it;
- in Docker, the container runtime's logging driver collects STDOUT;
- on cloud platforms (Azure Monitor, AWS CloudWatch, Kubernetes...), the platform collects STDOUT from each container.
The platform takes care of collecting and storing the logs. The application stores nothing, so it can never fill its disk, and writing to STDOUT is fast. For containerized services, the console appender is usually the right choice.
2. File appender
ch.qos.logback.core.FileAppender writes to one file:
xml
<appender name="FILE" class="ch.qos.logback.core.FileAppender">
<file>logs/app.log</file>
<!-- true: keep existing content across restarts; false: start empty at each startup -->
<append>true</append>
<encoder>
<pattern>%d %-5level %logger - %msg%n</pattern>
</encoder>
</appender>
<logger name="com.example.payment.PaymentController" level="INFO" additivity="false">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="FILE"/>
</logger>Logs persist, but the file grows without limit until the disk is full. Avoid it in production.
3. Rolling file appender (log rotation)
ch.qos.logback.core.rolling.RollingFileAppender writes to an active file and, according to a rolling policy, archives it and starts a new one. The policy decides when to roll and how to name and keep archives.
Time-based: TimeBasedRollingPolicy rolls by period. The date pattern in fileNamePattern sets the period:
%d{...} | Rolls |
|---|---|
%d{yyyy-MM} | monthly |
%d{yyyy-MM-dd} | daily |
%d{yyyy-MM-dd-HH} | hourly |
%d{yyyy-MM-dd-HH-mm} | every minute (handy for testing) |
xml
<appender name="ROLLING_FILE_TIME_BASED" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>logs/app.log</file> <!-- the active file -->
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>logs/app-%d{yyyy-MM-dd-HH-mm}.log</fileNamePattern>
<maxHistory>30</maxHistory> <!-- keep 30 periods -->
</rollingPolicy>
<encoder>
<pattern>%d %-5level %logger - %msg%n</pattern>
</encoder>
</appender>maxHistory counts periods: with a per-minute pattern it keeps the last 30 minutes of archives, with an hourly pattern 30 hours, with a daily pattern 30 days. Older archives are deleted.
Size and time: SizeAndTimeBasedRollingPolicy also caps the size of each file, and of all archives together. This is the usual production choice:
xml
<appender name="ROLLING_FILE_SIZE_TIME_BASED" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>logs/app.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>logs/app-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxFileSize>100MB</maxFileSize> <!-- one file never exceeds 100 MB -->
<maxHistory>30</maxHistory> <!-- keep 30 days -->
<totalSizeCap>2GB</totalSizeCap> <!-- all archives together stay under 2 GB -->
</rollingPolicy>
<encoder>
<pattern>%d %-5level %logger - %msg%n</pattern>
</encoder>
</appender>%i is an index. If a day's log passes 100 MB, Logback closes the file and starts the next index:
text
app-2025-12-16.0.log first 100 MB
app-2025-12-16.1.log next 100 MB
...totalSizeCap bounds the total: when the archives exceed 2 GB, the oldest are deleted, even within the 30 days.
A custom appender
To send logs somewhere Logback does not support (your database, a Kafka topic), write an appender. Extend AppenderBase<ILoggingEvent>; ILoggingEvent is Logback's representation of one log event:
java
public class DbAppender extends AppenderBase<ILoggingEvent> {
@Override
protected void append(ILoggingEvent event) {
String message = event.getFormattedMessage();
String loggerName = event.getLoggerName();
long timestamp = event.getTimeStamp();
// your destination logic: here, pretend to insert into a database
System.out.println("Saving log to DB: " + event.getLevel() + " - "
+ loggerName + " - " + message);
}
}xml
<!-- no encoder: the Java code decides what to write -->
<appender name="DB_APPENDER" class="com.example.logging.DbAppender"/>
<logger name="com.example.payment.PaymentController" level="INFO" additivity="false">
<appender-ref ref="DB_APPENDER"/>
</logger>Each accepted event now calls append:
text
Saving log to DB: ERROR - com.example.payment.PaymentController - error log
Saving log to DB: WARN - com.example.payment.PaymentController - warning log
Saving log to DB: INFO - com.example.payment.PaymentController - info logappend runs on the thread that logged. A slow destination, such as a database insert, slows down every request that logs. Chapter 18 solves that with the async appender.
Next: Chapter 18 covers asynchronous logging, appender filters, log patterns and placeholders, structured JSON logs, and keeping personal data out of logs.
Check yourself
- What are the three parts of
logback-spring.xml? - What is the difference between an appender and an encoder?
- A logger element has no
level. Which level does it use? - Why does a line appear twice when both a logger and ROOT have the console appender?
- What does
additivity="false"change, and what does it not change? - What happens to a logger with
additivity="false"and no appenders? - The parent logger is at WARN with a file appender; the child is at INFO with none. Does a child INFO event reach the file?
- Why is the console appender a good fit for containers?
- Why should a plain
FileAppenderbe avoided in production? - With
%d{yyyy-MM-dd-HH}andmaxHistory=30, how much history is kept? - What do
maxFileSize,%iandtotalSizeCapdo? - What do you extend to write a custom appender, and what is the risk of a slow one?
Answers
- Appenders, loggers, and the root logger.
- The appender decides where a log goes; the encoder decides its format (pattern).
- The level of its nearest ancestor that has one, ultimately ROOT's.
- The logger's own appender prints it; with additivity on, the event also reaches ROOT's appender, which prints it again.
- It stops events from reaching ancestors' appenders. It does not stop level inheritance.
- Its accepted events are silently lost; Logback only records a status warning, visible with
debug="true". - Yes. The level is checked only at the child; once accepted, the event reaches every ancestor's appender regardless of their levels.
- It writes to STDOUT, which the container platform collects and stores; it is fast and the app keeps no files.
- The file grows without limit and can fill the disk.
- The last 30 hours of archives.
maxFileSizecaps one file;%inumbers the files within a period when a file reaches the cap;totalSizeCapcaps all archives together, deleting the oldest.AppenderBase<ILoggingEvent>, implementingappend. It runs on the logging thread, so a slow destination slows down the application.