Production logging with SLF4J/Logback, structured JSON logs, MDC for request tracing, Micrometer metrics, and health indicators for Spring Boot applications.
Production-grade logging, metrics, and health monitoring for Spring Boot 3.x applications.
System.out.println debugging (use java-fundamentals)<dependencies>
<!-- Logging (included via spring-boot-starter) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<!-- Structured JSON logging -->
<dependency>
<groupId>net.logstash.logback</groupId>
<artifactId>logstash-logback-encoder</artifactId>
<version>7.4</version>
</dependency>
<!-- Actuator for health/metrics endpoints -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- Micrometer Prometheus registry (optional, for /prometheus endpoint) -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
<!-- AOP for logging aspects -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
</dependencies>
| Level | When to Use | Example |
|---|---|---|
ERROR | System is broken, requires immediate attention. Failed operations that impact users. | Database connection lost, payment processing failed, unrecoverable state |
WARN | Unexpected situation, system can recover. Potential issues that may need investigation. | Retry succeeded after failure, deprecated API called, cache miss on expected hit |
INFO | Key business events and application lifecycle. What happened at a high level. | User registered, order placed, application started, scheduled job completed |
DEBUG | Detailed flow for troubleshooting. Only enabled in development or when diagnosing issues. | Method entry/exit, query parameters, intermediate computation results |
TRACE | Very fine-grained diagnostic info. Rarely enabled even in development. | Full request/response bodies, loop iterations, byte-level data |
// Option 1: SLF4J direct (preferred — no extra dependency)
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class OrderService {
private static final Logger log = LoggerFactory.getLogger(OrderService.class);
}
// Option 2: Lombok @Slf4j (if Lombok is in the project)
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class OrderService {
// log field is auto-generated
}
Always use parameterized messages. Never concatenate strings.
// CORRECT: parameterized — no string concatenation cost if level is disabled
log.info("Order {} placed by user {} for amount {}", orderId, userId, amount);
// WRONG: string concatenation — always evaluated even if INFO is disabled
log.info("Order " + orderId + " placed by user " + userId);
// Logging exceptions — exception is ALWAYS the last argument, no placeholder
log.error("Failed to process order {}", orderId, exception);
// Guard expensive operations
if (log.isDebugEnabled()) {
log.debug("Full order details: {}", order.toDetailedString());
}
Use logstash-logback-encoder to produce JSON logs consumable by ELK, Datadog, CloudWatch.
import static net.logstash.logback.argument.StructuredArguments.*;
// Adds key-value pairs to the JSON log entry
log.info("Order placed", kv("orderId", orderId), kv("userId", userId), kv("amount", amount));
// Output: {"message":"Order placed","orderId":"ORD-123","userId":"USR-456","amount":99.99,...}
// keyValue() — includes in message AND JSON fields
log.info("Processing {}", keyValue("orderId", orderId));
// Output message: "Processing orderId=ORD-123" + JSON field "orderId":"ORD-123"
// value() — includes in message only, no JSON field
log.info("Processing order {}", value("orderId", orderId));
// Output message: "Processing order ORD-123"
MDC attaches key-value pairs to the current thread. Every log statement on that thread automatically includes the MDC values.
import org.slf4j.MDC;
// Set at request entry point (filter or interceptor)
MDC.put("correlationId", UUID.randomUUID().toString());
MDC.put("userId", authenticatedUser.getId());
try {
// All log statements in this thread now include correlationId and userId
log.info("Processing request"); // correlationId and userId are in the log output
orderService.placeOrder(request);
} finally {
MDC.clear(); // ALWAYS clear in a finally block to prevent thread pool leaks
}
Every HTTP request gets a unique correlation ID. Pass it through all service calls.
// In a servlet filter (see examples/RequestLoggingFilter.java):
String correlationId = request.getHeader("X-Correlation-ID");
if (correlationId == null || correlationId.isBlank()) {
correlationId = UUID.randomUUID().toString();
}
MDC.put("correlationId", correlationId);
// In Logback config, include it in every log line:
// Pattern: %d{ISO8601} [%X{correlationId}] %-5level %logger{36} - %msg%n
// JSON: automatically included by logstash-logback-encoder when in MDC
Place logback-spring.xml in src/main/resources/. Spring Boot profiles control which appenders are active.
<!-- Dev profile: colorized console output -->
<springProfile name="dev">
<root level="DEBUG">
<appender-ref ref="CONSOLE" />
</root>
</springProfile>
<!-- Prod profile: JSON to stdout + rolling file -->
<springProfile name="prod">
<root level="INFO">
<appender-ref ref="JSON_CONSOLE" />
<appender-ref ref="ASYNC_FILE" />
</root>
</springProfile>
See examples/logback-spring.xml for the full configuration.
Micrometer is the metrics facade for Spring Boot (like SLF4J is for logging).
private final Counter orderCounter;
public OrderService(MeterRegistry registry) {
this.orderCounter = Counter.builder("orders.placed.total")
.description("Total orders placed")
.tag("type", "standard")
.register(registry);
}
public void placeOrder(Order order) {
orderCounter.increment();
}
private final Timer orderTimer;
public OrderService(MeterRegistry registry) {
this.orderTimer = Timer.builder("orders.processing.duration")
.description("Order processing time")
.publishPercentiles(0.5, 0.95, 0.99)
.register(registry);
}
public void placeOrder(Order order) {
orderTimer.record(() -> {
// processing logic
});
}
private final AtomicInteger activeOrders = new AtomicInteger(0);
public OrderService(MeterRegistry registry) {
Gauge.builder("orders.active.count", activeOrders, AtomicInteger::get)
.description("Currently active orders")
.register(registry);
}
@Component
public class DatabaseHealthIndicator extends AbstractHealthIndicator {
@Override
protected void doHealthCheck(Health.Builder builder) {
// Check external dependency
if (isDatabaseReachable()) {
builder.up()
.withDetail("database", "PostgreSQL")
.withDetail("responseTime", "12ms");
} else {
builder.down()
.withDetail("error", "Cannot connect to database");
}
}
}
Shows at GET /actuator/health:
{
"status": "UP",
"components": {
"database": {
"status": "UP",
"details": { "database": "PostgreSQL", "responseTime": "12ms" }
}
}
}