When something breaks in production at three in the morning, the only thing that helps you understand the cause is the logs. If they are written well, the investigation takes minutes. If not — hours. Let's look at how proper logging works in Java/Spring.
Why ordinary logs don't work in production
A beginner usually writes something like this:
System.out.println("Order created: " + order.getId());
or this:
Logger log = LoggerFactory.getLogger(OrderService.class);
log.info("Order created: " + order.getId() + " for customer " + order.getCustomerId());
It looks reasonable. But in real production it doesn't work:
- If the service handles 1000 requests per second, the logs turn into a million lines. Finding the one you need without filtering is impossible.
- There is no context: who made the request? Within which trace? Which requestId?
- String concatenation via
+runs always, even when the level is disabled — wasted CPU. System.outdoesn't reach the unified log pipeline: no level, no format, no metadata.
The industrial solution is structured logging: every log line is a JSON object with fixed fields. Loki, ELK and Datadog index and filter such logs without regular expressions.
JSON in production, text in development
Logback is the standard logger in Spring Boot. It supports profiles: in development a readable text format is more convenient, in production you need JSON.
<configuration>
<springProfile name="dev,test">
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} %-5level [%thread] %X{traceId:-} %logger{30} - %msg%n</pattern>
</encoder>
</appender>
</springProfile>
<springProfile name="prod,staging">
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<includeMdcKeyName>traceId</includeMdcKeyName>
<includeMdcKeyName>requestId</includeMdcKeyName>
<includeMdcKeyName>userId</includeMdcKeyName>
</encoder>
</appender>
</springProfile>
<root level="INFO">
<appender-ref ref="STDOUT"/>
</root>
</configuration>
For JSON encoding you add the logstash-logback-encoder library. In production every log line looks like this:
{
"@timestamp": "2026-05-25T22:30:00.123Z",
"level": "INFO",
"logger_name": "ru.vikulinva.order.OrderService",
"message": "Order confirmed: orderId=12345",
"mdc": {
"traceId": "5e92c8a3b1f4d2e6a7c8e9f0a1b2c3d4",
"requestId": "0193a8f3-7c21-7e3f-9b4a-...",
"userId": "user-42"
}
}
@Slf4j — how to declare a logger
Each class used to start with a line like this:
private static final Logger log = LoggerFactory.getLogger(OrderService.class);
This is boilerplate that is easy to get wrong (for example, copying it from another class and forgetting to change the name). Lombok solves the problem with the @Slf4j annotation:
@Component
@RequiredArgsConstructor
@Slf4j
public class OrderService {
private final OrderRepository orderRepository;
public Order confirm(Long orderId) {
log.info("Confirming order: orderId={}", orderId);
var order = orderRepository.findById(orderId).orElseThrow();
order.confirm();
return orderRepository.save(order);
}
}
Lombok generates private static final Logger log with the correct class name. The log field appears in the compiled code, not in the source.
Parameters via {}, not via +
Slf4j supports lazy placeholders:
// correct
log.info("Order created: orderId={} customerId={}", order.id(), order.customerId());
// wrong
log.info("Order created: orderId=" + order.id() + " customerId=" + order.customerId());
The difference is when toString() is called. With concatenation via + — always, even if the level is disabled. With {} — only if the level is active. For INFO the difference is small. For DEBUG in production it is critical: if an object has a heavy toString, it runs millions of times for nothing.
The rule is simple: always {}, never +.
Log levels and their meaning
Five levels, and each has its own semantics:
| Level | When to use |
|---|---|
ERROR | An unrecoverable failure that requires action: a failed transaction, an unavailable external service, an unhandled exception. Always with a stack trace. |
WARN | A problem the service recovered from: retry, fallback, circuit breaker opened. |
INFO | An important business event: "order confirmed", "user registered", start/stop of a batch job with a count. |
DEBUG | Details for debugging. Disabled in production, enabled temporarily during an investigation. |
TRACE | Maximum detail. Local development only. |
Examples:
log.error("Failed to charge payment: paymentId={}", paymentId, ex);
log.warn("Circuit breaker OPEN for payment-provider, falling back to queue");
log.info("Order confirmed: orderId={} customerId={} amount={}",
order.id(), order.customerId(), order.amount());
log.debug("Order aggregate state after confirm: {}", order);
A common beginner mistake is to write INFO for every HTTP request ("Handling GET /orders/123"). That is exactly the access log, and it exists separately. Without this discipline, 80% of the volume of production logs is noise in which you can't find anything.
MDC — context in every message
MDC (Mapped Diagnostic Context) is a dictionary that Logback automatically adds to every log message in the current thread. This is exactly how traceId and requestId end up in the JSON without being specified explicitly in every log.info.
Three key fields:
traceIdandspanId— added automatically via OpenTelemetry. They let you find all the logs of a specific request even in a distributed system.requestId— a filter on the incoming HTTP request takes theX-Request-Idheader or generates a UUID and puts it into MDC.userId— added after JWT validation in the Spring Security filter chain.
Thanks to MDC you can search in Loki: {traceId="5e92c8a3..."} — and get all the log lines of that request from every service in chronological order. Without MDC every investigation starts from scratch.
For more on how to configure the filters — Context propagation.
What and where to log
Logs are useful at the boundaries — where the service talks to the outside world:
- Incoming REST request — the access log is configured separately (
spring.mvc.log-request-details). Inside the handler itself,INFOis written only for critical commands (payments). - Outgoing HTTP —
INFOon the call ("Calling payment-provider"),WARNon 4xx/5xx,ERRORon a network error. - Domain events —
INFOon publication: "Published OrderCreated: orderId=...". - Schedulers —
INFOon start and finish with a count: "Outbox relay published 100 events in 50ms".
Inside the business logic, log only important decisions or degradations. "Entering method", "Loaded N rows" — that's noise.
Common mistakes
Personal data in logs
Email, phone, full name, passport, card number, JWT tokens, passwords — none of this may be written to logs in plain form. Access to logs is usually broader than access to the database; they are kept longer; they are indexed everywhere.
// bad — personal data in plain form
log.info("User registered: email={} phone={}", user.email(), user.phone());
// good — internal identifier only
log.info("User registered: userId={}", user.id());
// good — mask it if you need it for an investigation
log.info("Email verification sent: userId={} emailMask={}",
user.id(), maskEmail(user.email())); // u***@example.com
System.out.println and printStackTrace
Both methods write to stdout without a level, without MDC, without a format. They don't reach the JSON pipeline.
// bad
System.out.println("Order: " + order);
e.printStackTrace();
// good
log.info("Order: {}", order);
log.error("Unexpected error", e);
log.error without an exception
// bad — the stack trace is lost, you can't find the cause
log.error("Failed to charge: " + e.getMessage());
// good — Slf4j sees the last Throwable argument and adds stack_trace to the JSON
log.error("Failed to charge: orderId={}", orderId, e);
Full request body in logs for money and personal data
// bad — the request body may contain card details
log.info("Charge request: {}", chargeRequest);
// good — identifiers only
log.info("Charge request: orderId={} amount={}",
chargeRequest.orderId(), chargeRequest.amount());
In short
- In production — JSON via
logstash-logback-encoder, in development — a readable text pattern. Configured through Logback profiles. - Declare the logger via
@Slf4j(Lombok), not manually viaLoggerFactory.getLogger. - Parameters always via
{}, never via+— Slf4j is lazy and doesn't calltoStringon disabled levels. ERROR— requires action and always comes with a stack trace.WARN— a degradation the service recovered from.INFO— an important business event.DEBUGandTRACE— development only.- MDC automatically adds
traceId,requestId,userIdto every log message via OpenTelemetry and filters. - Personal data (email, phone, passport, tokens) in logs is a serious violation. Only identifiers or masked values.
System.out.printlnande.printStackTrace()don't work in a structured pipeline — replace them withlog.*.
What to read next
- Context propagation (MDC) — how traceId and requestId get into MDC.
- Metrics — Micrometer and Prometheus: what to measure and how.
- Tracing — OpenTelemetry and distributed tracing.