Logs you can search
A ship’s logbook records what happened and when: “14:02, engine 2 temperature high”. When something goes wrong, the crew reads back through it. Application logs are the same: lines your code writes as it runs, kept so you can find out later what happened.
In production, there are millions of lines from dozens of servers, so the important thing is being able to search them: “all errors for order 1042” or “everything that happened during this one request”.
Logs are the highest-cardinality, highest-cost signal: great for the details of a specific event, poor for trends. Make them structured (JSON with stable field names, so the log platform indexes them), correlated (trace and span ids in every line via MDC, which Spring Boot adds automatically when tracing is on), leveled (INFO for business events, WARN for degraded behavior, ERROR for failures that need attention, DEBUG off in production) and safe (no tokens, passwords, card data or unnecessary personal data).
@Service
class CheckoutService(private val payments: PaymentClient) {
private val log = LoggerFactory.getLogger(CheckoutService::class.java)
fun checkout(customerId: String, cart: Cart): OrderConfirmation {
log.atInfo()
.setMessage("Checkout started")
.addKeyValue("customerId", customerId)
.addKeyValue("items", cart.items.size)
.log()
try {
val confirmation = payments.charge(customerId, cart.totalCents)
log.atInfo().setMessage("Order placed").addKeyValue("orderId", confirmation.orderId).log()
return confirmation
} catch (e: PaymentDeclinedException) {
log.warn("Payment declined for customer {}", customerId, e) // exception last: stack trace kept
throw e
}
}
}@Service
class CheckoutService {
private static final Logger log = LoggerFactory.getLogger(CheckoutService.class);
OrderConfirmation checkout(String customerId, Cart cart) {
log.atInfo()
.setMessage("Checkout started")
.addKeyValue("customerId", customerId)
.addKeyValue("items", cart.items().size())
.log();
try {
var confirmation = payments.charge(customerId, cart.totalCents());
log.atInfo().setMessage("Order placed")
.addKeyValue("orderId", confirmation.orderId()).log();
return confirmation;
} catch (PaymentDeclinedException e) {
log.warn("Payment declined for customer {}", customerId, e); // exception last: stack trace kept
throw e;
}
}
}
// application.yml (Spring Boot 3.4+): JSON logs on the console
// logging.structured.format.console: ecs{"@timestamp":"2026-11-09T14:02:11.417Z","log.level":"INFO","message":"Order placed",
"orderId":"o-1042","trace.id":"4bf92f3577b34da6a3ce929d0e0e4736","span.id":"00f067aa0ba902b7",
"service.name":"checkout"}- Fields, not sentences:
orderIdis a field the log system can filter on, instead of text you have to search inside. - The trace id in every line connects all logs from one request, even across services.
- Levels:
INFOfor normal business events,WARNwhen something went wrong but was handled,ERRORwhen someone needs to look,DEBUGfor details that are off in production. - Never log passwords, tokens, full card numbers, or more personal data than you need.
Tap the line with the bug.
try {
inventory.reserve(order)
} catch (e: Exception) {
log.error("Reservation failed: " + e.message)
throw CheckoutException("Could not reserve stock")
}try {
inventory.reserve(order);
} catch (Exception e) {
log.error("Reservation failed: " + e.getMessage());
throw new CheckoutException("Could not reserve stock");
}Hint: In production the log says 'Reservation failed: null'. Where did the error happen, and what was it?
Two pieces of information are thrown away. The log line keeps only getMessage(), which is often null or vague (a NullPointerException, a timeout), and drops the stack trace, the list of calls that shows exactly where it failed. Then the new CheckoutException doesn’t carry the original exception as its cause, so whoever catches it upstream loses it too. And the message is built with + even when ERROR logging is off.
Pass the exception itself (as the last argument, SLF4J prints its stack trace) and chain it:
} catch (e: InventoryException) {
log.error("Reservation failed for order {}", order.id, e)
throw CheckoutException("Could not reserve stock", e) // keep the cause
}} catch (InventoryException e) {
log.error("Reservation failed for order {}", order.id(), e);
throw new CheckoutException("Could not reserve stock", e); // keep the cause
}Also: log an exception once, where it’s handled. Logging it at every level it passes through produces five copies of the same stack trace for one failure.
Predict the output
The production log level is INFO. During one request, the code calls log.debug('Cart loaded'), log.info('Checkout started'), log.warn('Coupon expired') and log.error('Payment failed', e). Which lines appear in the logs?
A level shows that level and everything more severe: INFO shows INFO, WARN and ERROR, and hides DEBUG (and TRACE). So Checkout started, Coupon expired and Payment failed appear, and 'Cart loaded' doesn't. To see DEBUG for one package while investigating, change just that logger: logging.level.com.shop.cart=DEBUG, or at runtime through the Actuator loggers endpoint.
Logging at scale
- Cost: log volume is usually the largest observability bill. Sample noisy INFO, drop health-check access logs, and keep DEBUG off; use metrics for counts instead of logging every event.
- Context propagation: MDC is thread-local; with coroutines use
MDCContext(), with reactive code Micrometer’s context propagation, with virtual threads it works as usual. Spring Boot fillstraceId/spanIdautomatically when Micrometer Tracing is present. - Audit vs diagnostic logs: “who changed the payout account” is an audit record with retention and integrity requirements; store it in a database or an append-only store, not just in application logs that rotate.
- Structured JSON (
logging.structured.format.console: ecs|logstash|gelfin Boot 3.4+), stable field names, SLF4J key-value API; trace/span ids in MDC. - Log exceptions as the last argument (stack trace), chain causes, log once where handled; parameterized messages, not concatenation.
- Levels with intent; DEBUG off in production, per-logger overrides via Actuator
loggers. - No secrets/PII; volume is cost: sample, drop health checks, prefer metrics for counts. MDC across coroutines (
MDCContext) and reactive (context propagation).
Check this part
How should an exception be logged?
Why log orderId as a field instead of inside the message text?
What connects log lines from one request across several services?