ai-servers/llm-gateway/internal/storage/debuglog.go
Ray Andrew 90adf6f3a8
feat(gateway): add circuit breaker, retry, and concurrency limit support
feat(gateway): add debug logging with file storage and retention

feat(gateway): add audit logging for user actions

feat(gateway): add request ID tracking and rate limit headers

feat(gateway): add model aliases and load balancing strategies

feat(gateway): add config hot-reload via SIGHUP

feat(gateway): add CORS support

feat(gateway): add data export API and dashboard endpoints

feat(gateway): add dashboard pages for audit and debug logs

feat(gateway): add concurrent request limiting per token

feat(gateway): add streaming timeout support

feat(gateway): add migration support for new schema fields
2026-02-15 04:21:40 -06:00

250 lines
7 KiB
Go

package storage
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"sort"
"strings"
"sync/atomic"
"time"
)
type DebugLogEntry struct {
ID int64 `json:"id"`
RequestID string `json:"request_id"`
Timestamp int64 `json:"timestamp"`
TokenName string `json:"token_name"`
Model string `json:"model"`
Provider string `json:"provider"`
RequestBody string `json:"request_body"`
ResponseBody string `json:"response_body"`
RequestHeaders string `json:"request_headers"`
ResponseStatus int `json:"response_status"`
FilePath string `json:"-"`
}
// debugFile is the JSON structure written to disk.
type debugFile struct {
RequestHeaders string `json:"request_headers"`
RequestBody string `json:"request_body"`
ResponseBody string `json:"response_body"`
}
type DebugLogger struct {
db *DB
enabled atomic.Bool
dataDir string
}
func NewDebugLogger(db *DB, enabled bool, dataDir string) *DebugLogger {
dl := &DebugLogger{db: db, dataDir: dataDir}
dl.enabled.Store(enabled)
return dl
}
func (d *DebugLogger) SetEnabled(v bool) {
d.enabled.Store(v)
}
func (d *DebugLogger) IsEnabled() bool {
return d.enabled.Load()
}
// debugLogDir returns the base directory for debug log files.
func (d *DebugLogger) debugLogDir() string {
return filepath.Join(d.dataDir, "debug-logs")
}
// debugFilePath builds the file path for a debug log entry.
func (d *DebugLogger) debugFilePath(requestID string, ts time.Time) string {
date := ts.Format("2006-01-02")
return filepath.Join(d.debugLogDir(), date, requestID+".json")
}
func (d *DebugLogger) Log(entry DebugLogEntry) {
if !d.IsEnabled() {
return
}
if entry.Timestamp == 0 {
entry.Timestamp = time.Now().Unix()
}
ts := time.Unix(entry.Timestamp, 0)
fp := d.debugFilePath(entry.RequestID, ts)
// Write body file
if err := os.MkdirAll(filepath.Dir(fp), 0755); err != nil {
log.Printf("ERROR: debug log mkdir: %v", err)
return
}
df := debugFile{
RequestHeaders: entry.RequestHeaders,
RequestBody: entry.RequestBody,
ResponseBody: entry.ResponseBody,
}
data, err := json.Marshal(df)
if err != nil {
log.Printf("ERROR: debug log marshal: %v", err)
return
}
if err := os.WriteFile(fp, data, 0644); err != nil {
log.Printf("ERROR: debug log write: %v", err)
return
}
// Insert metadata into DB (no bodies)
_, err = d.db.Exec(`INSERT INTO debug_log
(request_id, timestamp, token_name, model, provider, response_status, file_path)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
entry.RequestID, entry.Timestamp, entry.TokenName, entry.Model,
entry.Provider, entry.ResponseStatus, fp,
)
if err != nil {
log.Printf("ERROR: debug log db insert: %v", err)
}
}
type DebugLogQueryResult struct {
Entries []DebugLogEntry `json:"entries"`
Page int `json:"page"`
TotalPages int `json:"total_pages"`
Total int `json:"total"`
}
// Query returns paginated debug log metadata (no bodies — fast).
func (d *DebugLogger) Query(page, limit int) *DebugLogQueryResult {
if page < 1 {
page = 1
}
if limit <= 0 {
limit = 50
}
offset := (page - 1) * limit
var total int
d.db.QueryRow("SELECT COUNT(*) FROM debug_log").Scan(&total)
totalPages := (total + limit - 1) / limit
if totalPages < 1 {
totalPages = 1
}
rows, err := d.db.Query(`SELECT id, request_id, timestamp, COALESCE(token_name, ''),
COALESCE(model, ''), COALESCE(provider, ''), COALESCE(response_status, 0), COALESCE(file_path, '')
FROM debug_log ORDER BY timestamp DESC LIMIT ? OFFSET ?`, limit, offset)
if err != nil {
return &DebugLogQueryResult{Entries: []DebugLogEntry{}, Page: page, TotalPages: totalPages, Total: total}
}
defer rows.Close()
var entries []DebugLogEntry
for rows.Next() {
var e DebugLogEntry
rows.Scan(&e.ID, &e.RequestID, &e.Timestamp, &e.TokenName,
&e.Model, &e.Provider, &e.ResponseStatus, &e.FilePath)
entries = append(entries, e)
}
if entries == nil {
entries = []DebugLogEntry{}
}
return &DebugLogQueryResult{Entries: entries, Page: page, TotalPages: totalPages, Total: total}
}
// QueryFull returns paginated debug log entries including request/response bodies read from files.
func (d *DebugLogger) QueryFull(page, limit int) *DebugLogQueryResult {
result := d.Query(page, limit)
for i := range result.Entries {
d.populateFromFile(&result.Entries[i])
}
return result
}
// GetByRequestID returns a single debug log entry with bodies read from file.
func (d *DebugLogger) GetByRequestID(requestID string) *DebugLogEntry {
var e DebugLogEntry
err := d.db.QueryRow(`SELECT id, request_id, timestamp, COALESCE(token_name, ''),
COALESCE(model, ''), COALESCE(provider, ''), COALESCE(response_status, 0), COALESCE(file_path, '')
FROM debug_log WHERE request_id = ?`, requestID).Scan(
&e.ID, &e.RequestID, &e.Timestamp, &e.TokenName,
&e.Model, &e.Provider, &e.ResponseStatus, &e.FilePath)
if err != nil {
return nil
}
d.populateFromFile(&e)
return &e
}
// populateFromFile reads body data from the debug file on disk.
// Falls back to DB columns for pre-migration entries that have no file_path.
func (d *DebugLogger) populateFromFile(e *DebugLogEntry) {
if e.FilePath == "" {
// Legacy entry: try reading bodies from DB columns
d.db.QueryRow(`SELECT COALESCE(request_body, ''), COALESCE(response_body, ''), COALESCE(request_headers, '')
FROM debug_log WHERE id = ?`, e.ID).Scan(&e.RequestBody, &e.ResponseBody, &e.RequestHeaders)
return
}
data, err := os.ReadFile(e.FilePath)
if err != nil {
log.Printf("WARN: debug log read file %s: %v", e.FilePath, err)
return
}
var df debugFile
if err := json.Unmarshal(data, &df); err != nil {
log.Printf("WARN: debug log parse file %s: %v", e.FilePath, err)
return
}
e.RequestHeaders = df.RequestHeaders
e.RequestBody = df.RequestBody
e.ResponseBody = df.ResponseBody
}
// Cleanup removes debug log entries and files older than retentionDays.
func (d *DebugLogger) Cleanup(retentionDays int) error {
cutoff := time.Now().AddDate(0, 0, -retentionDays)
cutoffUnix := cutoff.Unix()
// Delete old DB rows
result, err := d.db.Exec("DELETE FROM debug_log WHERE timestamp < ?", cutoffUnix)
if err != nil {
return fmt.Errorf("delete old debug rows: %w", err)
}
affected, _ := result.RowsAffected()
if affected > 0 {
log.Printf("Cleaned up %d old debug log entries", affected)
}
// Remove old date directories
baseDir := d.debugLogDir()
dirs, err := os.ReadDir(baseDir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("read debug log dir: %w", err)
}
cutoffDate := cutoff.Format("2006-01-02")
sort.Slice(dirs, func(i, j int) bool { return dirs[i].Name() < dirs[j].Name() })
for _, dir := range dirs {
if !dir.IsDir() {
continue
}
// Date directories are named YYYY-MM-DD; string comparison works
if strings.Compare(dir.Name(), cutoffDate) < 0 {
dirPath := filepath.Join(baseDir, dir.Name())
if err := os.RemoveAll(dirPath); err != nil {
log.Printf("WARN: failed to remove debug log dir %s: %v", dirPath, err)
} else {
log.Printf("Removed old debug log directory: %s", dir.Name())
}
}
}
return nil
}