跪拜 Guibai
← Back to the summary

LightLog Replaces the ELK Stack with PostgreSQL and Adds an AI Troubleshooting Agent

Foreword

I've been thinking about how to build a lightweight system that is non-intrusive to business logic yet allows developers/ops personnel to easily troubleshoot problems. There are many solutions on the market, but none seem to fit my needs.

Originally, some of my services used ELK for log collection. A single ELK setup requires at least 3 nodes + 4-8GB of memory, and with Kibana, the resource consumption is heavier than the business processes combined. The operational cost is not low either: ES version upgrades, index templates, ILM policies, disk watermarking, cluster state synchronization—there's a lot to handle.

I tried Loki for a while. It's genuinely lightweight, but full-text search is basically crippled. It only indexes labels, not content. If you want to grep a keyword inside a message, it actually scans all chunks matching the label. When log volume increases, it gets so slow you want to smash your keyboard.

SaaS solutions like Datadog are hassle-free, but expensive. For small to medium projects, it costs tens to hundreds of thousands of yuan a year, which is unaffordable.

The Nasty Status Quo

image.png

What's more of a headache is the integration. My own product lines and project lines have distinct characteristics:

To get logs from these different sources into the same storage, you need to configure three sets of agents: Logstash + Filebeat + OTel Collector, each with its own configuration pitfalls.

So I decided to write my own. Design goals:

  • No dependency on ES; use PG for storage (and incidentally verify PG's capability to handle log scenarios)
  • A single Java integration method that works across Java 8/17 + SpringBoot 2.x/3.x
  • Resource usage ~1GB single process
  • Support OTLP standard endpoints, zero-change integration for non-Java applications
  • Built-in AI assistant, natural language log querying—in the AI era, this is a must-have—automatically analyzes your service logs to discover potential issues

Take a Look First

image.png

Dashboard overview page: KPI numbers + 24h trends + service health table + recent ERROR scrolling.

Logger logs directly input by users in the service, as well as service startup logs and exception stack traces.

image.png

image.png

Deep Dive into the Tech

Let's expand on a few core technical points.

Storage Layer: Why I Replaced ES with PG

Multi-dimensional filtering by time/service/POD/level/keyword, 32,764 log entries returned in 34ms.

The Cost of ES

ES is powerful, but the cost is high:

For small to medium projects, setting up an ES cluster has a terrible ROI.

How PG Handles Log Storage

PG wasn't designed for logs, but several features are sufficient for this scenario:

1. Daily Partitioning

CREATE TABLE lightlog (
    ts          TIMESTAMPTZ NOT NULL,
    service     TEXT NOT NULL,
    instance    TEXT,
    level       TEXT NOT NULL,
    logger      TEXT,
    message     TEXT NOT NULL,
    stack       TEXT,
    mdc         JSONB,
    log_fp      TEXT,
    parsed_meta JSONB
) PARTITION BY RANGE (ts);

CREATE TABLE lightlog_20260805 PARTITION OF lightlog
    FOR VALUES FROM ('2026-08-05') TO ('2026-08-06');

Benefits:

2. BRIN Index

Logs are written sequentially (monotonically increasing timestamps). Using a B-tree index on such data is wasteful—the index itself takes up space, roughly 1:1 with the data volume.

BRIN (Block Range Index) only stores the min/max of each data block, making the index size 1/100th of a B-tree:

CREATE INDEX idx_lightlog_ts ON lightlog USING BRIN (ts);

The trade-off is that queries need to do "block scanning," reading some blocks outside the range. But log queries almost always include a time range, partition pruning already skips most partitions, and BRIN scans within the remaining partitions with sufficient precision.

3. JSONB + GIN Index

MDC and parsed_meta are semi-structured fields, stored as JSONB, with a GIN index added:

CREATE INDEX idx_lightlog_mdc ON lightlog USING GIN (mdc jsonb_path_ops);

-- Query
SELECT * FROM lightlog WHERE mdc @> '{"traceId": "abc123"}';

Actual test: 30 million log entries, queried by time + service + keyword, results in 34ms. Good enough.

Pluggable storage: SQLite / PG / H2, switchable via environment variables. SQLite for local development (zero dependencies), PG for production, H2 for testing. Spring abstraction + factory pattern, SchemaInitRunner selects the implementation at startup based on LIGHTLOG_STORAGE.

Async Collection + WAL to Prevent Loss: Zero Wait for Business Threads

Async collection is standard for log systems, but implementation details determine reliability.

Data Flow

log.info("xxx")
    ↓ (synchronous, nanosecond-level)
Appender.append()
    ↓ (ConcurrentLinkedQueue.offer, nanosecond-level)
Memory Queue (default 10000)
    ↓ (background thread batch fetch, 1000 entries per batch or 100ms timeout)
WAL File (local disk)
    ↓ (HTTP POST)
Center (HTTP API → insert into PG)
    ↓ (200 OK)
Delete WAL File

The business thread never waits for the network. If the center goes down or the network jitters, the business is unaffected.

WAL Implementation

The core of WAL is "write to disk first, then push." Simplified version (actual business logic is not exactly this):

class WalStore {
    private final Path baseDir;
    private final AtomicInteger counter = new AtomicInteger();
    
    Path append(List<LogEvent> batch) throws IOException {
        String name = "wal-" + System.currentTimeMillis() + "-" + counter.getAndIncrement();
        Path file = baseDir.resolve(name);
        try (OutputStream os = Files.newOutputStream(file)) {
            for (LogEvent e : batch) {
                os.write(serialize(e));
            }
        }
        return file;
    }
    
    void ack(Path file) throws IOException {
        Files.delete(file);
    }
}

// Background push thread
while (!stopped) {
    List<LogEvent> batch = drain(queue, 1000, Duration.ofMillis(100));
    if (batch.isEmpty()) continue;
    
    Path walFile = wal.append(batch);   // Write to disk first
    try {
        centerClient.post(batch);       // Push
        wal.ack(walFile);               // Delete on success
    } catch (Exception e) {
        log.warn("push failed, will retry next round: {}", e.getMessage());
        // Don't delete, retry this file in the next loop
    }
}

The code is under 200 lines. I later wondered if this was over-engineering—for lightweight scenarios, direct synchronous push might be fine. But one detail convinced me: once a user discovers lost logs, trust is broken. A log system can be slow, but it cannot lose data. WAL is not over-engineering; it's the minimum promise of a log system.

Replay on Startup After a business process restarts, the WAL directory may still have unpushed files. The Starter scans the directory on startup, sorts by filename timestamp, pushes them one by one to the center, and only allows new logs into the queue after pushing is complete.

Detail: Startup replay cannot block Spring startup for too long (otherwise the business won't start). So replay is asynchronous, running in a separate thread after the ApplicationReadyEvent.

AI Assistant: Why LangGraph + litellm

image.png

Asking "What's the error log situation for ths-api today?" pulled 246 ERROR entries, identified that 95% shared the same fingerprint from ZwUploadServiceImpl, and pinpointed "continuous failure on the same logical path."

Why LangGraph Instead of a Raw Prompt

LangGraph is, after all, LangChain's next-generation agent framework, centered on an explicit state machine + ReAct loop.

Problems with raw prompts:

LangGraph solutions:

Rough agent structure:

from langgraph.graph import StateGraph, END

builder = StateGraph(AgentState)
builder.add_node("plan", plan_node)        # Parse user question, decide which tools to call
builder.add_node("execute", execute_node)  # Execute tool calls
builder.add_node("analyze", analyze_node)  # Synthesize tool results, generate response
builder.set_entry_point("plan")
builder.add_edge("plan", "execute")
builder.add_conditional_edges("execute", should_continue, {
    True: "execute",   # Continue calling tools
    False: "analyze",
})
builder.add_edge("analyze", END)

graph = builder.compile()
graph.recursion_limit = 10  # Prevent token explosion

4 Tools, Each with Hard Limits

@tool
def search_logs(service: str, keyword: str = None, level: str = None,
                start: str = None, end: str = None, limit: int = 100) -> list:
    """Search logs, hard limit ≤100 entries returned"""
    if limit > 100:
        limit = 100
    return center_client.search(...)

@tool
def count_logs(service: str, level: str, start: str, end: str) -> dict:
    """Count numbers, returns no content, only numbers"""
    return center_client.count(...)

@tool
def list_services() -> list:
    """All service names currently reporting logs"""
    return center_client.services()

@tool
def recent_snapshot(seconds: int = 30) -> list:
    """Logs from the last N seconds (max 300), to determine if errors are ongoing"""
    if seconds > 300:
        seconds = 300
    return center_client.recent(seconds)

Why does search_logs have a hard limit of 100 entries? This is an economic decision, not a technical one. Stuffing 1000 log entries into an LLM context could cost ¥0.5 per call. Using it 100 times a day would be ¥50. 100 entries are enough for the LLM to understand patterns and provide useful insights, while keeping token consumption under control.

litellm Abstracts Away Multi-LLM Differences

from litellm import completion

response = completion(
    model="qwen-plus",  # One-line switch: openai/gpt-4o, anthropic/claude-3.5, deepseek/deepseek-chat
    messages=[...],
    tools=[...],
)

Comparison with Mainstream Market Options

image.png

How to Use

image.png

After all that talk, how do you actually use it? Very simple, zero intrusion.

<dependency>
  <groupId>io.github.yjn-pj</groupId>
  <artifactId>lightlog-collector-spring-boot-starter</artifactId>
  <version>1.2.5</version>
</dependency>
lightlog:
  enabled: true
  server-url: http://lightlog-center:8088
  service-name: my-service

One pom snippet + 4 lines of yml, without changing a single line of Java code, and it's done. The rest is ➡️Deployment