Back to blog

Structured Logging & Centralized Logging trong Spring Boot

javaspring-bootloggingobservabilityelk-stackbackend
Structured Logging & Centralized Logging trong Spring Boot

Giới Thiệu

App Spring Boot của bạn đang chạy trên production. Có gì đó lỗi lúc 2 giờ sáng. Bạn SSH vào, tail logs, và thấy:

ERROR 2026-03-11 02:14:33 - NullPointerException at UserService.java:87
ERROR 2026-03-11 02:14:33 - NullPointerException at UserService.java:87
ERROR 2026-03-11 02:14:33 - NullPointerException at UserService.java:87

User nào? Request nào? Tenant nào? Không biết gì cả. Logs vô dụng hoàn toàn.

Logging tốt không phải là in vài dòng debug — mà là xây dựng observability vào trong app để khi production hỏng, bạn tìm ra vấn đề trong vài giây, không phải vài giờ.

Bài này cover toàn bộ logging stack:

  1. Structured logging — JSON thay vì plain text, logs có thể query được
  2. MDC (Mapped Diagnostic Context) — trace mọi dòng log về đúng request của nó
  3. Log levels và best practices — log gì, không log gì
  4. Centralized log aggregation — gửi logs đến ELK Stack hoặc Grafana Loki
  5. Alerting on log patterns — được notify ngay khi ERROR rate tăng đột biến

Bạn Sẽ Học Được Gì

✅ Cấu hình Logback cho production-grade structured logging
✅ Output JSON logs với Logstash encoder
✅ Dùng MDC để thêm request ID, user ID, và tenant ID vào mọi dòng log
✅ Viết LoggingFilter tự động inject trace context
✅ Implement request/response logging với sensitive field masking
✅ Cấu hình log levels theo package qua application.yml
✅ Ship logs đến Elasticsearch với Logstash hoặc Filebeat
✅ Query logs với Kibana Discover và build dashboards
✅ Setup Grafana Loki như là lightweight alternative
✅ Alert khi error rate tăng đột biến

Yêu Cầu

  • Java 17+ và Spring Boot 3.x
  • Spring Boot cơ bản (Getting Started guide)
  • Docker & Docker Compose để chạy ELK ở local

1. Vấn Đề Với Plain-Text Logs

Spring Boot đi kèm Logback và các default settings hợp lý. Mặc định, logs trông như này:

2026-03-11T02:14:33.421+00:00  INFO 12345 --- [main] c.e.demo.UserService : User created: [email protected]
2026-03-11T02:14:33.425+00:00 ERROR 12345 --- [http-nio-8080-exec-1] c.e.demo.OrderService : Order failed

OK cho local development. Trên production, nó sẽ phụ lòng bạn:

Vấn đề của plain-text logs:

Vấn đềPlain TextStructured JSON
Searchgrep "ERROR" — dễ vỡQuery theo field: level:ERROR AND userId:42
CorrelationScan timestamp thủ côngField requestId liên kết toàn bộ logs
ParsingRegex — fragile, hỏng khi đổi formatNative JSON parsing
AggregationKhó đếm errors theo endpointFaceted search trên bất kỳ field nào
AlertingComplex log parsing rulesSQL-like queries trên các fields

Fix: Emit logs dưới dạng JSON ngay từ đầu.


2. Structured Logging với Logback

Dependencies

Thêm Logstash encoder cho JSON output:

<dependencies>
    <!-- Spring Boot Web -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
 
    <!-- Structured JSON logging -->
    <dependency>
        <groupId>net.logstash.logback</groupId>
        <artifactId>logstash-logback-encoder</artifactId>
        <version>8.0</version>
    </dependency>
 
    <!-- Lombok for @Slf4j -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>

Cấu Hình Logback

Tạo src/main/resources/logback-spring.xml. Suffix -spring cho phép Spring Boot xử lý các tag <springProfile>:

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
 
    <!-- ======================== -->
    <!-- Development: Human-readable colored output -->
    <!-- ======================== -->
    <springProfile name="dev,default">
        <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
            <encoder>
                <pattern>
                    %clr(%d{yyyy-MM-dd HH:mm:ss.SSS}){faint} %clr(%5p){highlight} %clr(${PID:-}){magenta} --- [%clr(%15.15t){faint}] %clr(%-40.40logger{39}){cyan} : %m%n%throwable
                </pattern>
            </encoder>
        </appender>
 
        <root level="INFO">
            <appender-ref ref="CONSOLE"/>
        </root>
    </springProfile>
 
    <!-- ======================== -->
    <!-- Production: JSON structured output -->
    <!-- ======================== -->
    <springProfile name="prod,staging">
        <appender name="JSON_CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
            <encoder class="net.logstash.logback.encoder.LogstashEncoder">
                <!-- App metadata có mặt trong mọi dòng log -->
                <customFields>{"app":"my-spring-app","env":"${SPRING_PROFILES_ACTIVE:-prod}"}</customFields>
                <!-- Include MDC fields (requestId, userId, v.v.) -->
                <includeMdcKeyName>requestId</includeMdcKeyName>
                <includeMdcKeyName>userId</includeMdcKeyName>
                <includeMdcKeyName>tenantId</includeMdcKeyName>
                <includeMdcKeyName>traceId</includeMdcKeyName>
                <!-- Rút gọn stack trace verbose -->
                <throwableConverter class="net.logstash.logback.stacktrace.ShortenedThrowableConverter">
                    <maxDepthPerCause>10</maxDepthPerCause>
                    <shortenedClassNameLength>20</shortenedClassNameLength>
                    <rootCauseFirst>true</rootCauseFirst>
                </throwableConverter>
            </encoder>
        </appender>
 
        <!-- Rolling file để backup / local disk -->
        <appender name="JSON_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
            <file>/var/log/app/application.log</file>
            <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
                <fileNamePattern>/var/log/app/application.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
                <maxHistory>7</maxHistory>
                <timeBasedFileNamingAndTriggeringPolicy
                    class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
                    <maxFileSize>100MB</maxFileSize>
                </timeBasedFileNamingAndTriggeringPolicy>
            </rollingPolicy>
            <encoder class="net.logstash.logback.encoder.LogstashEncoder">
                <customFields>{"app":"my-spring-app","env":"prod"}</customFields>
            </encoder>
        </appender>
 
        <root level="INFO">
            <appender-ref ref="JSON_CONSOLE"/>
            <appender-ref ref="JSON_FILE"/>
        </root>
 
        <!-- Giảm noise từ các framework -->
        <logger name="org.hibernate.SQL" level="WARN"/>
        <logger name="org.springframework.web" level="WARN"/>
        <logger name="com.zaxxer.hikari" level="WARN"/>
    </springProfile>
 
</configuration>

JSON Output Trông Như Thế Nào

Một câu log.info("User created", ...) đơn giản giờ emit ra:

{
  "@timestamp": "2026-03-11T02:14:33.421Z",
  "@version": "1",
  "message": "User created successfully",
  "logger_name": "com.example.service.UserService",
  "thread_name": "http-nio-8080-exec-1",
  "level": "INFO",
  "level_value": 20000,
  "app": "my-spring-app",
  "env": "prod",
  "requestId": "a3f7c928-1234-4abc-8def-0987654321ab",
  "userId": "42",
  "tenantId": "acme-corp"
}

Mọi field đều được index và có thể query. Tìm toàn bộ logs của một request cụ thể: requestId:"a3f7c928-1234-4abc-8def-0987654321ab".


3. MDC: Trace Mọi Dòng Log Về Request Của Nó

Mapped Diagnostic Context (MDC) là một thread-local key-value store. Những gì bạn đặt vào MDC sẽ tự động xuất hiện trong mọi log statement được thực hiện trên thread đó — kể cả code của library bạn không kiểm soát.

Ý Tưởng Của MDC

Implement LoggingFilter

package com.example.filter;
 
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;
import org.springframework.core.annotation.Order;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
 
import java.io.IOException;
import java.util.UUID;
 
@Component
@Order(1)
@Slf4j
public class LoggingFilter extends OncePerRequestFilter {
 
    private static final String REQUEST_ID_HEADER = "X-Request-ID";
 
    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        long startTime = System.currentTimeMillis();
        String requestId = getOrGenerateRequestId(request);
 
        try {
            // Populate MDC — tất cả logs tiếp theo trên thread này đều có các fields này
            MDC.put("requestId", requestId);
            MDC.put("method", request.getMethod());
            MDC.put("path", request.getRequestURI());
            MDC.put("clientIp", getClientIp(request));
 
            // Thêm user info (nếu đã authenticate)
            Authentication auth = SecurityContextHolder.getContext().getAuthentication();
            if (auth != null && auth.isAuthenticated() && !"anonymousUser".equals(auth.getPrincipal())) {
                MDC.put("userId", auth.getName());
            }
 
            // Echo request ID về client để họ có thể correlate logs với bạn
            response.setHeader(REQUEST_ID_HEADER, requestId);
 
            log.info("Request started: {} {}", request.getMethod(), request.getRequestURI());
 
            filterChain.doFilter(request, response);
 
        } finally {
            long duration = System.currentTimeMillis() - startTime;
            MDC.put("durationMs", String.valueOf(duration));
            MDC.put("statusCode", String.valueOf(response.getStatus()));
 
            log.info("Request completed: {} {} → {} ({}ms)",
                    request.getMethod(), request.getRequestURI(),
                    response.getStatus(), duration);
 
            // QUAN TRỌNG: Luôn clear MDC để tránh leak sang thread được pool tái sử dụng
            MDC.clear();
        }
    }
 
    private String getOrGenerateRequestId(HttpServletRequest request) {
        String existingId = request.getHeader("X-Request-ID");
        return (existingId != null && !existingId.isBlank()) ? existingId : UUID.randomUUID().toString();
    }
 
    private String getClientIp(HttpServletRequest request) {
        String forwardedFor = request.getHeader("X-Forwarded-For");
        if (forwardedFor != null && !forwardedFor.isBlank()) {
            return forwardedFor.split(",")[0].trim();
        }
        return request.getRemoteAddr();
    }
}

Dùng MDC Trong Services

Các MDC values đã có sẵn — cứ log bình thường:

@Service
@Slf4j
@RequiredArgsConstructor
public class OrderService {
 
    private final OrderRepository orderRepository;
    private final PaymentService paymentService;
 
    public Order createOrder(CreateOrderRequest request) {
        // requestId và userId đã có trong MDC từ LoggingFilter
        log.info("Creating order for {} items, total: {}",
                request.getItems().size(), request.getTotalAmount());
 
        Order order = orderRepository.save(Order.from(request));
 
        // Thêm context riêng cho operation này
        MDC.put("orderId", order.getId().toString());
        log.info("Order persisted, initiating payment");
 
        try {
            paymentService.charge(order);
            log.info("Payment successful");
            return order;
        } catch (PaymentException e) {
            // Error log này tự động include requestId, userId, orderId
            log.error("Payment failed for order: {}", e.getMessage());
            throw e;
        } finally {
            MDC.remove("orderId"); // Dọn dẹp context riêng
        }
    }
}

ERROR log kết quả tự động có đủ thông tin:

{
  "@timestamp": "2026-03-11T02:14:33.421Z",
  "level": "ERROR",
  "message": "Payment failed for order: Insufficient funds",
  "requestId": "a3f7c928-...",
  "userId": "42",
  "orderId": "789",
  "method": "POST",
  "path": "/api/orders"
}

Không còn phải điều tra mệt mỏi nữa. Nhìn một cái là biết ngay: user 42, request a3f7c928, order 789.


4. Request/Response Logging Với Sensitive Data Masking

Logging request và response body rất hữu ích khi debug, nhưng nguy hiểm về privacy. Đừng bao giờ log password, token, hay số thẻ tín dụng.

Cách Dùng ContentCachingWrapper

Spring cung cấp ContentCachingRequestWrapper và ContentCachingResponseWrapper để đọc body sau khi nó đã được consumed:

package com.example.filter;
 
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.util.ContentCachingRequestWrapper;
import org.springframework.web.util.ContentCachingResponseWrapper;
 
import java.io.IOException;
import java.util.Set;
 
@Component
@Order(2) // Sau LoggingFilter
@Slf4j
@RequiredArgsConstructor
public class RequestResponseLoggingFilter extends OncePerRequestFilter {
 
    private final ObjectMapper objectMapper;
 
    // Các fields cần mask — không bao giờ log những thứ này
    private static final Set<String> SENSITIVE_FIELDS = Set.of(
            "password", "confirmPassword", "currentPassword",
            "token", "accessToken", "refreshToken", "apiKey",
            "cardNumber", "cvv", "ssn", "creditCard"
    );
 
    private static final Set<String> LOGGABLE_CONTENT_TYPES = Set.of(
            "application/json", "application/xml"
    );
 
    private static final int MAX_BODY_LOG_SIZE = 5000; // ký tự
 
    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
 
        if (!shouldLogBody(request)) {
            filterChain.doFilter(request, response);
            return;
        }
 
        ContentCachingRequestWrapper wrappedRequest = new ContentCachingRequestWrapper(request);
        ContentCachingResponseWrapper wrappedResponse = new ContentCachingResponseWrapper(response);
 
        try {
            filterChain.doFilter(wrappedRequest, wrappedResponse);
        } finally {
            logRequestBody(wrappedRequest);
            logResponseBody(wrappedResponse);
            wrappedResponse.copyBodyToResponse(); // Bắt buộc: gửi body về client
        }
    }
 
    private void logRequestBody(ContentCachingRequestWrapper request) {
        byte[] bodyBytes = request.getContentAsByteArray();
        if (bodyBytes.length == 0) return;
 
        String body = new String(bodyBytes);
        String maskedBody = maskSensitiveFields(truncate(body));
        log.debug("Request body: {}", maskedBody);
    }
 
    private void logResponseBody(ContentCachingResponseWrapper response) {
        byte[] bodyBytes = response.getContentAsByteArray();
        if (bodyBytes.length == 0) return;
 
        String body = new String(bodyBytes);
        String maskedBody = maskSensitiveFields(truncate(body));
        log.debug("Response body [{}]: {}", response.getStatus(), maskedBody);
    }
 
    private String maskSensitiveFields(String json) {
        try {
            JsonNode node = objectMapper.readTree(json);
            maskNode((ObjectNode) node);
            return objectMapper.writeValueAsString(node);
        } catch (Exception e) {
            return "[unparseable body]";
        }
    }
 
    private void maskNode(ObjectNode node) {
        node.fieldNames().forEachRemaining(field -> {
            if (SENSITIVE_FIELDS.stream().anyMatch(s -> field.toLowerCase().contains(s.toLowerCase()))) {
                node.put(field, "***MASKED***");
            } else if (node.get(field).isObject()) {
                maskNode((ObjectNode) node.get(field));
            }
        });
    }
 
    private String truncate(String body) {
        return body.length() > MAX_BODY_LOG_SIZE
                ? body.substring(0, MAX_BODY_LOG_SIZE) + "...[truncated]"
                : body;
    }
 
    private boolean shouldLogBody(HttpServletRequest request) {
        String contentType = request.getContentType();
        return contentType != null && LOGGABLE_CONTENT_TYPES.stream()
                .anyMatch(contentType::startsWith);
    }
}

Security Note: Đặt filter này ở level DEBUG trên production và chỉ bật tạm thời khi điều tra incident. Log toàn bộ request/response body trong production tốn storage và có rủi ro privacy.


5. Log Levels: Log Gì, Không Log Gì

Năm Levels

LevelDùng ChoProduction Setting
TRACEMethod entry/exit, parameter dumpsKhông bao giờ
DEBUGDecision points, giá trị trung gian, SQLChỉ khi điều tra incident
INFOBusiness events (user tạo, order đặt, payment xử lý)✅ Luôn luôn
WARNOperation degraded — vẫn chạy được, cần kiểm tra sớm (cache miss storm, slow query)✅ Luôn luôn
ERROROperation failed — cần điều tra ngay (payment thất bại, DB mất kết nối)✅ Luôn luôn

Cấu Hình Qua application.yml

logging:
  level:
    root: INFO
 
    # App của bạn
    com.example: INFO
 
    # SQL queries — chỉ trong dev
    org.hibernate.SQL: DEBUG
    org.hibernate.orm.jdbc.bind: TRACE  # Giá trị parameter
 
    # Spring framework — thường quá noisy
    org.springframework.web: WARN
    org.springframework.security: INFO
    org.springframework.data: WARN
 
    # Connection pool
    com.zaxxer.hikari: WARN
    com.zaxxer.hikari.HikariConfig: INFO  # Log pool config khi startup
 
    # Redis
    io.lettuce: WARN
 
    # Actuator
    org.springframework.boot.actuator.endpoint: WARN

Log Level Best Practices

Log INFO cho business events:

// ✅ Tốt — business event, dễ query
log.info("Order placed: orderId={}, userId={}, amount={}, items={}",
        order.getId(), order.getUserId(), order.getTotalAmount(), order.getItemCount());
 
// ✅ Tốt — audit trail
log.info("User password changed: userId={}, ip={}", userId, clientIp);
 
// ❌ Tệ — không có ích trong production, làm ô nhiễm logs
log.info("Entering getUser method");
log.info("userId is: {}", userId);
log.info("Exiting getUser method");

Log WARN cho degraded states:

// ✅ Tốt — operation thành công nhưng có vấn đề
log.warn("Cache miss for userId={} — DB fallback used. Cân nhắc warm cache.", userId);
log.warn("Slow query detected: {}ms for orderId={}", duration, orderId);
log.warn("Rate limit approaching for userId={}: {}/100 requests used", userId, count);

Log ERROR cho actual failures:

// ✅ Tốt — luôn include context và exception
log.error("Failed to process payment for orderId={}, userId={}: {}",
        order.getId(), order.getUserId(), e.getMessage(), e);
 
// ❌ Tệ — không có context
log.error("Error occurred");
 
// ❌ Tệ — exception bị nuốt, không có stack trace
log.error("Payment failed: " + e.getMessage());

6. Application Logging Patterns

Service Layer: Structured Event Logging

@Service
@Slf4j
@RequiredArgsConstructor
public class UserService {
 
    private final UserRepository userRepository;
    private final EmailService emailService;
 
    public User registerUser(RegisterRequest request) {
        log.info("User registration started: email={}", request.getEmail());
 
        if (userRepository.existsByEmail(request.getEmail())) {
            log.warn("Registration rejected — email already exists: {}", request.getEmail());
            throw new EmailAlreadyExistsException(request.getEmail());
        }
 
        User user = createUser(request);
        userRepository.save(user);
 
        log.info("User registered: userId={}, email={}", user.getId(), user.getEmail());
 
        try {
            emailService.sendWelcomeEmail(user);
            log.info("Welcome email queued: userId={}", user.getId());
        } catch (EmailException e) {
            // Email failure không phải fatal — warn, không error
            log.warn("Welcome email failed for userId={}: {}", user.getId(), e.getMessage());
        }
 
        return user;
    }
}

Global Exception Handler: Consistent Error Logging

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
 
    // Business errors được mong đợi — log ở WARN
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(ResourceNotFoundException ex) {
        log.warn("Resource not found: {}", ex.getMessage());
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
                .body(new ErrorResponse("NOT_FOUND", ex.getMessage()));
    }
 
    @ExceptionHandler(ValidationException.class)
    public ResponseEntity<ErrorResponse> handleValidation(ValidationException ex) {
        log.warn("Validation failed: {}", ex.getMessage());
        return ResponseEntity.badRequest()
                .body(new ErrorResponse("VALIDATION_ERROR", ex.getMessage()));
    }
 
    // Unexpected errors — log ở ERROR với full stack trace
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGeneral(Exception ex) {
        log.error("Unexpected error: {} — {}", ex.getClass().getSimpleName(), ex.getMessage(), ex);
        return ResponseEntity.internalServerError()
                .body(new ErrorResponse("INTERNAL_ERROR", "An unexpected error occurred"));
    }
}

Scheduled Job Logging

@Component
@Slf4j
public class OrderCleanupJob {
 
    @Scheduled(cron = "0 0 2 * * *") // 2 giờ sáng hàng ngày
    public void cleanupExpiredOrders() {
        String jobId = UUID.randomUUID().toString().substring(0, 8);
        MDC.put("jobId", jobId);
        MDC.put("jobName", "order-cleanup");
 
        log.info("Job started");
        long start = System.currentTimeMillis();
 
        try {
            int deleted = orderRepository.deleteExpiredOrders();
            long duration = System.currentTimeMillis() - start;
 
            log.info("Job completed: deleted={}, durationMs={}", deleted, duration);
        } catch (Exception e) {
            log.error("Job failed after {}ms: {}", System.currentTimeMillis() - start, e.getMessage(), e);
        } finally {
            MDC.remove("jobId");
            MDC.remove("jobName");
        }
    }
}

7. Centralized Logging với ELK Stack

Khi bạn có nhiều instances hoặc microservices, mỗi cái viết log file riêng, bạn cần một nơi trung tâm để search và analyze. ELK Stack (Elasticsearch + Logstash + Kibana) là solution được dùng phổ biến nhất.

Architecture

Docker Compose cho Local ELK

Tạo docker-compose.elk.yml:

version: "3.8"
 
services:
  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:8.12.0
    container_name: elasticsearch
    environment:
      - discovery.type=single-node
      - xpack.security.enabled=false   # Chỉ tắt ở local dev
      - ES_JAVA_OPTS=-Xms512m -Xmx512m
    volumes:
      - esdata:/usr/share/elasticsearch/data
    ports:
      - "9200:9200"
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:9200"]
      interval: 10s
      timeout: 5s
      retries: 10
 
  logstash:
    image: docker.elastic.co/logstash/logstash:8.12.0
    container_name: logstash
    volumes:
      - ./logstash/pipeline:/usr/share/logstash/pipeline:ro
    ports:
      - "5044:5044"
      - "5000:5000/udp"
    depends_on:
      elasticsearch:
        condition: service_healthy
 
  kibana:
    image: docker.elastic.co/kibana/kibana:8.12.0
    container_name: kibana
    environment:
      - ELASTICSEARCH_HOSTS=http://elasticsearch:9200
    ports:
      - "5601:5601"
    depends_on:
      elasticsearch:
        condition: service_healthy
 
  filebeat:
    image: docker.elastic.co/beats/filebeat:8.12.0
    container_name: filebeat
    user: root
    volumes:
      - ./filebeat/filebeat.yml:/usr/share/filebeat/filebeat.yml:ro
      - /var/log/app:/var/log/app:ro
      - /var/lib/docker/containers:/var/lib/docker/containers:ro
    depends_on:
      - logstash
    command: filebeat -e -strict.perms=false
 
volumes:
  esdata:

Logstash Pipeline

Tạo logstash/pipeline/spring-boot.conf:

input {
  beats {
    port => 5044
  }
}
 
filter {
  # Parse JSON output từ Spring Boot / Logstash encoder
  json {
    source => "message"
    target => "log"
  }
 
  # Đưa các fields quan trọng lên top-level để Kibana dễ facet hơn
  if [log][level] {
    mutate {
      add_field => {
        "level"     => "%{[log][level]}"
        "requestId" => "%{[log][requestId]}"
        "userId"    => "%{[log][userId]}"
        "app"       => "%{[log][app]}"
        "path"      => "%{[log][path]}"
      }
    }
  }
 
  # Parse @timestamp từ log
  date {
    match => ["[log][@timestamp]", "ISO8601"]
    target => "@timestamp"
  }
}
 
output {
  elasticsearch {
    hosts => ["http://elasticsearch:9200"]
    index => "spring-boot-logs-%{+YYYY.MM.dd}"
    # Daily indices — dễ rotation và retention management
  }
  stdout { codec => rubydebug }  # Bỏ trong production
}

Filebeat Configuration

Tạo filebeat/filebeat.yml:

filebeat.inputs:
  - type: log
    enabled: true
    paths:
      - /var/log/app/*.log
    json.keys_under_root: true
    json.add_error_key: true
    multiline.pattern: '^\{'
    multiline.negate: true
    multiline.match: after
 
output.logstash:
  hosts: ["logstash:5044"]
 
processors:
  - add_host_metadata:
      when.not.contains.tags: forwarded
  - add_docker_metadata: ~

Khởi Động Stack

# Start ELK
docker compose -f docker-compose.elk.yml up -d
 
# Verify Elasticsearch
curl http://localhost:9200/_cluster/health?pretty
 
# Mở Kibana
open http://localhost:5601

Trong Kibana → Management → Index Patterns → Create pattern spring-boot-logs-*.


8. Alternative: Grafana Loki (Nhẹ Hơn)

ELK mạnh nhưng nặng (4+ GB RAM). Với deployment nhỏ hơn, Grafana Loki là lựa chọn tốt hơn — giống như Prometheus nhưng cho logs.

Loki Architecture

Docker Compose cho Loki

version: "3.8"
 
services:
  loki:
    image: grafana/loki:2.9.4
    container_name: loki
    ports:
      - "3100:3100"
    command: -config.file=/etc/loki/local-config.yaml
    volumes:
      - loki-data:/loki
 
  promtail:
    image: grafana/promtail:2.9.4
    container_name: promtail
    volumes:
      - /var/log/app:/var/log/app:ro
      - ./promtail/config.yml:/etc/promtail/config.yml:ro
    command: -config.file=/etc/promtail/config.yml
 
  grafana:
    image: grafana/grafana:10.3.1
    container_name: grafana
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=admin
    ports:
      - "3000:3000"
    volumes:
      - grafana-data:/var/lib/grafana
    depends_on:
      - loki
 
volumes:
  loki-data:
  grafana-data:

Promtail Configuration

Tạo promtail/config.yml:

server:
  http_listen_port: 9080
 
positions:
  filename: /tmp/positions.yaml
 
clients:
  - url: http://loki:3100/loki/api/v1/push
 
scrape_configs:
  - job_name: spring-boot
    static_configs:
      - targets:
          - localhost
        labels:
          job: spring-boot
          app: my-spring-app
          __path__: /var/log/app/*.log
 
    pipeline_stages:
      - json:
          expressions:
            level: level
            requestId: requestId
            userId: userId
            message: message
 
      - labels:
          level:
          requestId:
          userId:
 
      - timestamp:
          source: '@timestamp'
          format: RFC3339Nano

Loki dùng LogQL để query, tương tự PromQL:

# Tất cả ERROR logs
{app="my-spring-app"} | json | level="ERROR"
 
# Errors của một user cụ thể
{app="my-spring-app"} | json | level="ERROR" | userId="42"
 
# Slow requests (duration > 1000ms)
{app="my-spring-app"} | json | durationMs > 1000
 
# Error rate theo thời gian
rate({app="my-spring-app"} | json | level="ERROR" [5m])

9. Spring Boot Actuator Integration

Spring Boot Actuator expose các operational endpoints — bao gồm quản lý log level tại runtime.

Actuator Setup

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
management:
  endpoints:
    web:
      exposure:
        include: health, info, loggers, metrics, logfile
      base-path: /actuator
  endpoint:
    loggers:
      enabled: true
    health:
      show-details: when-authorized

Thay Đổi Log Level Tại Runtime

Không cần restart — thay đổi log levels qua HTTP:

# Xem level hiện tại của một package
curl http://localhost:8080/actuator/loggers/com.example
 
# Bật DEBUG cho service package (điều tra incident)
curl -X POST http://localhost:8080/actuator/loggers/com.example \
  -H "Content-Type: application/json" \
  -d '{"configuredLevel": "DEBUG"}'
 
# Bật Hibernate SQL logging (xem queries thực tế)
curl -X POST http://localhost:8080/actuator/loggers/org.hibernate.SQL \
  -H "Content-Type: application/json" \
  -d '{"configuredLevel": "DEBUG"}'
 
# Reset về INFO khi xong
curl -X POST http://localhost:8080/actuator/loggers/com.example \
  -H "Content-Type: application/json" \
  -d '{"configuredLevel": "INFO"}'

Security: Bảo vệ Actuator endpoints với Spring Security. Không bao giờ expose chúng publicly.

@Configuration
public class ActuatorSecurityConfig {
 
    @Bean
    public SecurityFilterChain actuatorSecurity(HttpSecurity http) throws Exception {
        http.securityMatcher("/actuator/**")
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/actuator/health").permitAll()
                .anyRequest().hasRole("ADMIN")
            );
        return http.build();
    }
}

10. Alerting Dựa Trên Log Patterns

Logs chỉ có giá trị nếu bạn hành động dựa trên chúng. Setup alerts để errors tự động notify team.

Kibana Alerting (ELK)

Trong Kibana → Stack Management → Rules → Create Rule:

  • Rule type: Elasticsearch query
  • Query: { "query": { "bool": { "filter": [{ "term": { "level": "ERROR" }}, { "term": { "app": "my-spring-app" }}]}}}
  • Condition: Count > 10 trong 5 phút
  • Action: Slack/PagerDuty/Email notification

Grafana Alerting (Loki)

Trong Grafana → Alerting → New Alert Rule:

# Alert query: error rate
sum(rate({app="my-spring-app"} | json | level="ERROR" [5m])) by (app)
  • Condition: IS ABOVE 0.5 (errors/second)
  • Evaluation: Mỗi 1 phút trong 5 phút
  • Notification: Slack channel #on-call

Application-Level Metrics Cho Alerting

Dùng Micrometer (built-in trong Spring Boot) để emit error rate metrics:

@Component
@Slf4j
@RequiredArgsConstructor
public class PaymentService {
 
    private final MeterRegistry meterRegistry;
 
    public void processPayment(Order order) {
        Timer.Sample sample = Timer.start(meterRegistry);
 
        try {
            // ... payment logic ...
            meterRegistry.counter("payment.success",
                    "method", order.getPaymentMethod()).increment();
        } catch (PaymentException e) {
            meterRegistry.counter("payment.failure",
                    "reason", e.getReason(),
                    "method", order.getPaymentMethod()).increment();
            log.error("Payment failed: orderId={}, reason={}", order.getId(), e.getReason(), e);
            throw e;
        } finally {
            sample.stop(Timer.builder("payment.duration")
                    .tag("method", order.getPaymentMethod())
                    .register(meterRegistry));
        }
    }
}

Alert trên payment.failure counter spikes trong Grafana/Prometheus để có operational alerting bổ sung cho log-based alerting.


11. Production Checklist

Trước khi go live, kiểm tra:

Logging configuration:

  • Profile prod dùng JSON Logstash encoder
  • Root level là INFO (không phải DEBUG)
  • Các framework ồn ào (hibernate, hikari, spring.web) đặt ở WARN
  • File appender có rolling policy (tối đa 7 ngày, tối đa 100 MB mỗi file)
  • logback-spring.xml dùng <springProfile> để tách biệt môi trường

MDC & tracing:

  • LoggingFilter đã đăng ký và populate requestId, userId
  • MDC.clear() được gọi trong finally block
  • Async operations propagate MDC (xem bên dưới)

Security:

  • Password và token KHÔNG BAO GIỜ được log
  • RequestResponseLoggingFilter ở level DEBUG trên production
  • Actuator log-level endpoint được bảo vệ sau ROLE_ADMIN
  • Log storage tuân theo data retention policy (GDPR)

Centralized logging:

  • Logs được ship đến Elasticsearch/Loki
  • Index retention policy đã cấu hình (ví dụ: 30 ngày)
  • Error rate alert đã cấu hình và test xong

MDC Trong Async Contexts

MDC là thread-local. Nó không tự động propagate sang @Async threads. Fix bằng task decorator:

@Configuration
public class AsyncConfig implements AsyncConfigurer {
 
    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(4);
        executor.setMaxPoolSize(10);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("async-");
        // Copy MDC từ calling thread sang async thread
        executor.setTaskDecorator(runnable -> {
            Map<String, String> mdcContext = MDC.getCopyOfContextMap();
            return () -> {
                try {
                    if (mdcContext != null) {
                        MDC.setContextMap(mdcContext);
                    }
                    runnable.run();
                } finally {
                    MDC.clear();
                }
            };
        });
        executor.initialize();
        return executor;
    }
}

Tổng Kết

Logging tốt là ranh giới giữa fix production bug trong 2 phút và war room 2 giờ. Đây là những gì bạn đã xây dựng:

Structured JSON logs — mọi dòng log đều machine-parseable với các fields nhất quán.

MDC tracing — requestId, userId, và tenantId tự động gắn vào mọi log, kể cả library code.

Request/response logging với masking — observability đầy đủ mà không leak password hay token.

Log level hygiene — INFO cho business events, WARN cho degraded states, ERROR cho thất bại thực sự. Các framework đặt ở WARN.

Centralized log aggregation — ELK Stack (full-featured) hoặc Loki (lightweight). Cả hai đều support structured queries.

Runtime log level control — Actuator cho phép bật DEBUG cho một package trong khi điều tra incident, rồi reset — không cần restart.

Alerting — error rate spike tự động notify team trước khi users báo bug.

Key Takeaways

✅ Dùng logback-spring.xml với <springProfile> để chuyển đổi giữa colored console (dev) và JSON (prod)
✅ Luôn gọi MDC.clear() trong finally block — đừng để MDC leak giữa các requests
✅ Logstash encoder biến mọi MDC field thành top-level JSON field — hoàn toàn indexable
✅ Sensitive fields (password, token, card number) phải được mask trước khi log
✅ Actuator /loggers endpoint cho phép debug production không cần restart
✅ Propagate MDC thủ công sang @Async threads với task decorator

Bước Tiếp Theo

Với structured logging đã có, bước tự nhiên tiếp theo là full observability:


Một phần của Spring Boot Learning Roadmap — hướng dẫn toàn diện từ cơ bản đến production.

📬 Đăng ký nhận bản tin

Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.

Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.

💬 Comments

Đăng nhập để bình luận

Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.