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
227 lines
5.7 KiB
Go
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"
|
|
}
|