Files
artex/server/logsink.go
T
dela 0335d572de
ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
detections / detections (push) Canceled after 0s
First Commit
2026-10-09 08:38:16 +08:00

227 lines
5.7 KiB
Go

package server
import (
"context"
"io"
"log"
"os"
"strings"
"sync"
"time"
"github.com/Autumn-27/artex/db"
)
// LogLine is one captured backend log entry exposed by the /api/logs endpoints.
type LogLine struct {
Seq int64 `json:"seq"`
DBID int64 `json:"db_id,omitempty"` // server_logs.id; 0 for pre-persistence entries
TS string `json:"ts"`
Level string `json:"level"` // info | warn | error
Tag string `json:"tag"` // the leading [tag] (pg / planner / activity / …), if any
Text string `json:"text"`
}
// dbWriteReq carries a log line to the async DB writer.
type dbWriteReq struct {
seq int64
ll LogLine
}
// logSinkT tees the standard logger into an in-memory ring buffer and fans new
// lines out to SSE subscribers, so the UI can show a live backend log stream.
// It still passes everything through to stderr (the terminal keeps working).
type logSinkT struct {
mu sync.Mutex
ring []LogLine
cap int
seq int64
subs map[chan LogLine]struct{}
out io.Writer // passthrough (stderr)
dbOnce sync.Once
dbCh chan dbWriteReq // buffered async channel; nil until SetDB is called
}
var logSink = &logSinkT{cap: 3000, subs: map[chan LogLine]struct{}{}, out: os.Stderr}
// StartLogCapture redirects the standard log package through the in-memory sink
// (still writing to stderr). Call once at startup, as early as possible.
func StartLogCapture() { log.SetOutput(logSink) }
// SetDB wires a postgres DB into the sink once (idempotent). It:
// 1. Restores the last 100 log rows from DB into the ring so the /logs page
// shows history immediately after restart.
// 2. Starts an async goroutine that persists every subsequent log line to DB.
func (s *logSinkT) SetDB(ctx context.Context, pg *db.DB) {
if pg == nil {
return
}
s.dbOnce.Do(func() {
ch := make(chan dbWriteReq, 2000)
s.mu.Lock()
s.dbCh = ch
s.mu.Unlock()
// Restore last 100 rows from DB → prepend to ring as history context.
if logs, err := pg.RecentLogs(100); err == nil && len(logs) > 0 {
s.mu.Lock()
restored := make([]LogLine, 0, len(logs))
for _, l := range logs {
s.seq++
restored = append(restored, LogLine{
Seq: s.seq,
DBID: l.ID,
TS: l.CreatedAt.Format(time.RFC3339),
Level: l.Level,
Tag: l.Tag,
Text: l.Text,
})
}
// Prepend history before any in-memory startup logs already in ring.
s.ring = append(restored, s.ring...)
if len(s.ring) > s.cap {
s.ring = s.ring[len(s.ring)-s.cap:]
}
s.mu.Unlock()
}
// Async writer: picks from channel and inserts into server_logs.
// Uses os.Stderr directly to report errors and avoids recursive log calls.
go func() {
for {
select {
case req, ok := <-ch:
if !ok {
return
}
id, err := pg.InsertLog(req.ll.Level, req.ll.Tag, req.ll.Text)
if err != nil {
_, _ = os.Stderr.Write([]byte("[logsink] db write: " + err.Error() + "\n"))
continue
}
// Stamp DBID back into the ring entry so history pagination works.
s.mu.Lock()
for i := range s.ring {
if s.ring[i].Seq == req.seq {
s.ring[i].DBID = id
break
}
}
s.mu.Unlock()
case <-ctx.Done():
return
}
}
}()
})
}
// Write implements io.Writer for the log package: one call per log.Printf line.
func (s *logSinkT) Write(p []byte) (int, error) {
_, _ = s.out.Write(p) // keep the terminal output
line := strings.TrimRight(string(p), "\n")
if strings.TrimSpace(line) != "" {
s.add(parseLog(line))
}
return len(p), nil
}
func (s *logSinkT) add(l LogLine) {
s.mu.Lock()
s.seq++
l.Seq = s.seq
s.ring = append(s.ring, l)
if len(s.ring) > s.cap {
s.ring = s.ring[len(s.ring)-s.cap:]
}
ch := s.dbCh
subs := make([]chan LogLine, 0, len(s.subs))
for sub := range s.subs {
subs = append(subs, sub)
}
s.mu.Unlock()
// Async DB write (non-blocking; drops if channel is full under extreme load).
if ch != nil {
select {
case ch <- dbWriteReq{seq: l.Seq, ll: l}:
default:
}
}
for _, sub := range subs {
select {
case sub <- l:
default:
}
}
}
// recent returns ring lines with Seq > since (capped to limit), and the latest seq.
func (s *logSinkT) recent(since int64, limit int) (lines []LogLine, cursor int64) {
s.mu.Lock()
defer s.mu.Unlock()
cursor = s.seq
if limit <= 0 || limit > s.cap {
limit = s.cap
}
for _, l := range s.ring {
if l.Seq > since {
lines = append(lines, l)
}
}
if len(lines) > limit {
lines = lines[len(lines)-limit:]
}
return lines, cursor
}
func (s *logSinkT) subscribe() (<-chan LogLine, func()) {
ch := make(chan LogLine, 256)
s.mu.Lock()
s.subs[ch] = struct{}{}
s.mu.Unlock()
var once sync.Once
return ch, func() {
once.Do(func() {
s.mu.Lock()
delete(s.subs, ch)
s.mu.Unlock()
close(ch)
})
}
}
// parseLog pulls a level + [tag] out of a standard-logger line; the timestamp is
// the capture time (RFC3339).
func parseLog(line string) LogLine {
msg := line
// strip the "2006/01/02 15:04:05" LstdFlags prefix if present
if len(msg) >= 20 && msg[4] == '/' && msg[7] == '/' && msg[10] == ' ' {
msg = strings.TrimSpace(msg[19:])
}
tag := ""
if strings.HasPrefix(msg, "[") {
if i := strings.IndexByte(msg, ']'); i > 1 {
tag = msg[1:i]
}
}
return LogLine{TS: time.Now().Format(time.RFC3339), Level: levelOf(msg), Tag: tag, Text: msg}
}
func levelOf(msg string) string {
low := strings.ToLower(msg)
for _, k := range []string{"fatal", "panic", "error", "err:", "失败", "丢弃", "拒绝", "✕", "不可达"} {
if strings.Contains(low, k) {
return "error"
}
}
for _, k := range []string{"warn", "disabled", "禁用", "skip", "stopped", "⚠", "重试"} {
if strings.Contains(low, k) {
return "warn"
}
}
return "info"
}