← Back to the section

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.out doesn'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:

LevelWhen to use
ERRORAn unrecoverable failure that requires action: a failed transaction, an unavailable external service, an unhandled exception. Always with a stack trace.
WARNA problem the service recovered from: retry, fallback, circuit breaker opened.
INFOAn important business event: "order confirmed", "user registered", start/stop of a batch job with a count.
DEBUGDetails for debugging. Disabled in production, enabled temporarily during an investigation.
TRACEMaximum 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:

  • traceId and spanId — 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 the X-Request-Id header 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, INFO is written only for critical commands (payments).
  • Outgoing HTTPINFO on the call ("Calling payment-provider"), WARN on 4xx/5xx, ERROR on a network error.
  • Domain eventsINFO on publication: "Published OrderCreated: orderId=...".
  • SchedulersINFO on 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 via LoggerFactory.getLogger.
  • Parameters always via {}, never via + — Slf4j is lazy and doesn't call toString on disabled levels.
  • ERROR — requires action and always comes with a stack trace. WARN — a degradation the service recovered from. INFO — an important business event. DEBUG and TRACE — development only.
  • MDC automatically adds traceId, requestId, userId to 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.println and e.printStackTrace() don't work in a structured pipeline — replace them with log.*.
  • Context propagation (MDC) — how traceId and requestId get into MDC.
  • Metrics — Micrometer and Prometheus: what to measure and how.
  • Tracing — OpenTelemetry and distributed tracing.