Ghi log có cấu trúc với Seq cho ứng dụng Java Spring Boot

Trong hệ sinh thái .NET, Serilog đã trở thành công cụ ghi nhật ký (logging) được nhiều nhà phát triển ưa chuộng nhờ khả năng ghi log cấu trúc mạnh mẽ và tích hợp mượt mà với Seq. Seq, một công cụ tìm kiếm và bảng điều khiển log, biến các giá trị nội suy trong log thành dữ liệu có cấu trúc, giúp nhà phát triển nhanh chóng tìm kiếm, xác định vấn đề và thực hiện phân tích thống kê đơn giản. Sự tiện lợi này khiến nhiều người mong muốn tìm kiếm một giải pháp tương tự trong môi trường Java.

Khi chuyển sang phát triển ứng dụng Java, đặc biệt là với framework Spring Boot, nhiều nhà phát triển nhận thấy rằng framework ghi log mặc định như Logback không mang lại trải nghiệm ghi log cấu trúc ấn tượng như Serilog. May mắn thay, Seq hỗ trợ nhận log qua định dạng GELF (GrayLog Extended Log Format), mở ra khả năng ghi log cấu trúc cho Java. Bài viết này sẽ hướng dẫn cách tích hợp Seq vào ứng dụng Spring Boot 3.x sử dụng thư viện logback-gelf và một bộ mã hóa (encoder) tùy chỉnh để đạt được khả năng ghi log cấu trúc tương tự Serilog.

Thiết lập môi trường Seq với GELF Input

Để bắt đầu, chúng ta cần triển khai Seq cùng với một dịch vụ nhận GELF input. Docker Compose là một cách tiện lợi để thiết lập các dịch vụ này:

version: '3.8'
services:
  seq-gelf-receiver:
    image: datalust/seq-input-gelf:latest
    depends_on:
      - seq-server
    ports:
      - "12201:12201/udp" # Cổng UDP mặc định cho GELF
    environment:
      SEQ_ADDRESS: "http://seq-server:5341"
    restart: unless-stopped

  seq-server:
    image: datalust/seq:latest
    ports:
      - "5341:80" # Cổng HTTP cho giao diện người dùng Seq
    environment:
      ACCEPT_EULA: Y
    volumes:
      - ./seq-data:/data # Lưu trữ dữ liệu Seq vào thư mục cục bộ
    restart: unless-stopped

Lưu đoạn mã trên vào file docker-compose.yml và chạy docker-compose up -d để khởi động các dịch vụ. Dịch vụ seq-gelf-receiver sẽ lắng nghe log GELF qua cổng UDP 12201 và chuyển tiếp chúng đến máy chủ Seq.

Cấu hình Spring Boot 3

Thêm Dependency

Trong file build.gradle của dự án Spring Boot, thêm các thư viện sau:

dependencies {
    // Logback GELF Appender để gửi log qua GELF
    implementation 'de.siegmar:logback-gelf:6.0.0'
    // Jackson để chuyển đổi đối tượng Java thành JSON khi ghi log cấu trúc
    implementation 'com.fasterxml.jackson.core:jackson-databind:2.15.2' 
    // ... các dependency khác của bạn
}

Cấu hình Logback với GELF Appender

Tạo một file logback-spring.xml trong thư mục src/main/resources với nội dung sau:

<configuration>

    <!-- Appender cho console -->
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
            <Pattern>%white(%d{HH:mm:ss.SSS}) %highlight(%-5level) [%blue(%t)] %yellow(%C{1.}): %msg%n%throwable</Pattern>
        </encoder>
    </appender>

    <!-- Appender gửi log GELF đến Seq (chạy không đồng bộ) -->
    <appender name="ASYNC_GELF" class="ch.qos.logback.classic.AsyncAppender">
        <queueSize>512</queueSize> <!-- Kích thước hàng đợi -->
        <discardingThreshold>0</discardingThreshold> <!-- Không loại bỏ log khi đầy hàng đợi -->
        <appender-ref ref="GELF" />
    </appender>

    <!-- Appender GELF chính -->
    <appender name="GELF" class="de.siegmar.logbackgelf.GelfUdpAppender">
        <!-- Thay thế bằng IP hoặc hostname của dịch vụ seq-gelf-receiver -->
        <graylogHost>localhost</graylogHost> 
        <graylogPort>12201</graylogPort>
        <maxChunkSize>508</maxChunkSize>
        <compressionMethod>GZIP</compressionMethod>
        <messageIdSupplier class="de.siegmar.logbackgelf.MessageIdSupplier"/>
        
        <!-- Sử dụng bộ mã hóa tùy chỉnh để phân tích log có cấu trúc -->
        <encoder class="com.example.app.logging.CustomGelfEncoder"> <!-- Thay thế bằng tên lớp encoder của bạn -->
            <includeRawMessage>false</includeRawMessage>
            <includeKeyValues>true</includeKeyValues>
            <includeMdcData>true</includeMdcData>
            <shortMessageLayout class="ch.qos.logback.classic.PatternLayout">
                <pattern>%msg%n</pattern>
            </shortMessageLayout>
            <fullMessageLayout class="ch.qos.logback.classic.PatternLayout">
                <pattern>%msg%n</pattern>
            </fullMessageLayout>
            <numbersAsString>false</numbersAsString>
            <!-- Thêm trường tĩnh để phân biệt dịch vụ trong Seq -->
            <staticField>application_name:java-spring-demo</staticField>
        </encoder>
    </appender>

    <!-- Cấu hình Root Logger -->
    <root level="info">
        <appender-ref ref="CONSOLE" />
        <appender-ref ref="ASYNC_GELF" />
    </root>

</configuration>

Lưu ý thay thế localhost trong <graylogHost> bằng IP hoặc hostname của dịch vụ seq-gelf-receiver nếu bạn đang chạy chúng trên các máy chủ khác nhau. Quan trọng nhất là chúng ta sẽ sử dụng một encoder tùy chỉnh có tên com.example.app.logging.CustomGelfEncoder để xử lý các tham số log có cấu trúc.

Xây dựng Bộ Mã hóa Log có cấu trúc tùy chỉnh

Mặc định, Logback sử dụng placeholder {} nhưng không tự động chuyển đổi các giá trị này thành cặp khóa-giá trị (key-value) có cấu trúc trong GELF. Để đạt được điều này, chúng ta cần tạo một bộ mã hóa (encoder) tùy chỉnh bằng cách kế thừa de.siegmar.logbackgelf.GelfEncoder.

Bộ mã hóa tùy chỉnh này sẽ phân tích cú pháp chuỗi thông báo log gốc và các đối số để trích xuất các cặp khóa-giá trị. Giả sử chúng ta áp dụng một quy ước đơn giản cho các thông báo log của mình: sử dụng cú pháp {key} hoặc key={} cho các tham số có cấu trúc.

Ví dụ, thay vì chỉ logger.info("Hello {}", "World");, chúng ta có thể dùng logger.info("Hello {target}", "World"); hoặc logger.info("Hello target={}", "World");.

Dưới đây là một ví dụ về CustomGelfEncoder. Lớp này sẽ phân tích cú pháp thông báo log gốc (pattern message) để tìm các khóa tiềm năng trước các placeholder {} và gán các đối số tương ứng làm giá trị. Nếu đối số là một đối tượng phức tạp (như Map hoặc POJO), nó sẽ cố gắng chuyển đổi thành JSON.

package com.example.app.logging; // Đặt vào package phù hợp với dự án của bạn

import ch.qos.logback.classic.spi.ILoggingEvent;
import de.siegmar.logbackgelf.GelfEncoder;
import de.siegmar.logbackgelf.GelfMessage;
import com.fasterxml.jackson.databind.ObjectMapper; // Cần dependency jackson-databind

import java.io.IOException;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class CustomGelfEncoder extends GelfEncoder {

    // Pattern để tìm các placeholder như "{key}" hoặc "key={}"
    // Chúng ta sẽ hỗ trợ cả hai, nhưng tập trung vào "key={}" như trong ví dụ gốc của bài viết.
    private static final Pattern KEY_VALUE_PLACEHOLDER_PATTERN = Pattern.compile("(\\w+)=(?:\\{\\})");
    private final ObjectMapper objectMapper = new ObjectMapper(); // Dùng để serialize các đối tượng phức tạp

    @Override
    protected void appendAdditionalFields(ILoggingEvent event, GelfMessage gelfMessage) {
        super.appendAdditionalFields(event, gelfMessage); // Gọi phương thức của lớp cha để thêm các trường cơ bản

        String messagePattern = event.getMessage(); // Chuỗi mẫu log gốc, ví dụ: "User {id} logged in, config={}"
        Object[] arguments = event.getArgumentArray();

        if (messagePattern == null || arguments == null || arguments.length == 0) {
            return; // Không có đối số hoặc mẫu thông báo để phân tích
        }

        // Tạm thời loại bỏ các placeholder trong messagePattern để đơn giản hóa việc tìm kiếm key
        // Logic này phức tạp và có thể cần tinh chỉnh dựa trên quy ước log cụ thể.
        // Đây là cách tiếp cận đơn giản hóa để minh họa.

        Matcher matcher = KEY_VALUE_PLACEHOLDER_PATTERN.matcher(messagePattern);
        int argIndex = 0;

        // Duyệt qua các khớp tìm được và gán giá trị từ argumentArray
        while (matcher.find() && argIndex < arguments.length) {
            String key = matcher.group(1); // Lấy tên khóa, ví dụ: "id", "config"
            Object value = arguments[argIndex];

            try {
                // Chuyển đổi đối tượng thành chuỗi hoặc JSON
                String serializedValue;
                if (value instanceof String || value instanceof Number || value instanceof Boolean) {
                    serializedValue = value.toString();
                } else {
                    // Cố gắng serialize đối tượng phức tạp thành JSON
                    serializedValue = objectMapper.writeValueAsString(value);
                }
                gelfMessage.addField(key, serializedValue);
            } catch (IOException e) {
                // Nếu không thể serialize, dùng toString() làm giá trị dự phòng
                gelfMessage.addField(key, value.toString());
            }
            argIndex++;
        }
    }
}

Sau khi tạo lớp CustomGelfEncoder và thay thế trong logback-spring.xml, bạn có thể thử ghi log trong ứng dụng Spring Boot của mình:

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;

import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;

@Component
public class LogTester implements CommandLineRunner {

    private static final Logger logger = LoggerFactory.getLogger(LogTester.class);

    @Override
    public void run(String... args) throws Exception {
        logger.info("Starting application: {appName}", "MySpringBootApp");
        logger.info("User id={} logged in from ip={}", 123, "192.168.1.100");
        logger.warn("Processing failed for order={orderId}, reason={errorCode}", 456, "ITEM_NOT_FOUND");

        Map<String, Object> paymentDetails = new HashMap<>();
        paymentDetails.put("amount", 99.99);
        paymentDetails.put("currency", "USD");
        paymentDetails.put("method", "CreditCard");
        logger.info("Payment processed. details={}", paymentDetails);

        logger.error("An error occurred: {}", new RuntimeException("Test exception!"));
    }
}

Khi bạn khởi động lại ứng dụng Spring Boot, các log sẽ được gửi đến Seq thông qua GELF. Trong giao diện Seq, bạn sẽ thấy các thông báo log với các trường tùy chỉnh như id, ip, orderId, errorCode, details được phân tách thành các thuộc tính riêng biệt, cho phép tìm kiếm và lọc dễ dàng.

Cách tiếp cận này mang lại khả năng ghi log cấu trúc tương tự như Serilog trong môi trường Java, giúp các nhà phát triển tận dụng tối đa sức mạnh của Seq để quản lý và phân tích log một cách hiệu quả.

Thẻ: Java Spring Boot logging Seq GELF

Đăng vào ngày 17 tháng 6 lúc 21:54