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:87User 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:
- Structured logging — JSON thay vì plain text, logs có thể query được
- MDC (Mapped Diagnostic Context) — trace mọi dòng log về đúng request của nó
- Log levels và best practices — log gì, không log gì
- Centralized log aggregation — gửi logs đến ELK Stack hoặc Grafana Loki
- 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 failedOK cho local development. Trên production, nó sẽ phụ lòng bạn:
Vấn đề của plain-text logs:
| Vấn đề | Plain Text | Structured JSON |
|---|---|---|
| Search | grep "ERROR" — dễ vỡ | Query theo field: level:ERROR AND userId:42 |
| Correlation | Scan timestamp thủ công | Field requestId liên kết toàn bộ logs |
| Parsing | Regex — fragile, hỏng khi đổi format | Native JSON parsing |
| Aggregation | Khó đếm errors theo endpoint | Faceted search trên bất kỳ field nào |
| Alerting | Complex log parsing rules | SQL-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
DEBUGtrê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
| Level | Dùng Cho | Production Setting |
|---|---|---|
TRACE | Method entry/exit, parameter dumps | Không bao giờ |
DEBUG | Decision points, giá trị trung gian, SQL | Chỉ khi điều tra incident |
INFO | Business events (user tạo, order đặt, payment xử lý) | ✅ Luôn luôn |
WARN | Operation degraded — vẫn chạy được, cần kiểm tra sớm (cache miss storm, slow query) | ✅ Luôn luôn |
ERROR | Operation 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: WARNLog 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:5601Trong 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: RFC3339NanoLoki 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-authorizedThay Đổ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
proddùng JSON Logstash encoder - Root level là
INFO(không phảiDEBUG) - 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.xmldùng<springProfile>để tách biệt môi trường
MDC & tracing:
-
LoggingFilterđã đăng ký và populaterequestId,userId -
MDC.clear()được gọi trongfinallyblock - Async operations propagate MDC (xem bên dưới)
Security:
- Password và token KHÔNG BAO GIỜ được log
-
RequestResponseLoggingFilterở levelDEBUGtrê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:
- Monitoring với Actuator, Prometheus & Grafana — metrics, dashboards, và SLO tracking song song với logs của bạn (SB-18)
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.