Files
Cyrene_v2/internal/services/gateway/observability_audit.go
T
Aska e0a543cc37
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(observability): 管理面板可观测性接真实数据——metrics/trace/audit 三端点 + 审计持久化
- observability.go/observability_metrics.go/observability_trace.go/observability_audit.go:
  拆分保证 ≤500行/函数≤80。metrics 并发安全 MetricsCollector(方法/路径/状态计数、平均/最大延迟、
  近1s QPS窗口);trace 并发安全 RingBuffer(默认512, newest-first)+traceSink+MultiTraceWriter;
  audit AuditStore 接口+内存回退+SQLite 持久化(NewSQLiteAuditStore 参数化 SQL+audit_logs 表)。
- server.go:Deps.AuditStore + Server.obs + 3 路由 GET /admin/observability/{metrics,trace,audit};
  ObserveSpan/TraceSink 注入点;middleware logging recordMetric;audit 落库(仅 slog→Append)。
- 前端 Obs.tsx:metrics 接 4 张真实 StatCard+请求分布、trace 接 span 列表(展开 props/events)、
  audit 从后端拉(前端内存审计作回退);api.ts/types.ts 加 obs 端点/类型(AdminObservabilityMetrics/
  AdminTraceSpan/AuditRecord.id 放宽)。成本指标不伪造(保留'—'+注明)。
- wire.go 收口:Deps.AuditStore 注入 gateway.NewSQLiteAuditStore(ctx, a.db)(审计真落库),
  未注入回退内存。
- Docs/api-client.md §2/v0.6。

实机验证:三端点 200,metrics 返回 totalRequests:4/qps:4/perPath/statusCounts:{200:4}。
go build/vet/test 全绿 + 前端 tsc。trace 数据源对接(gw.TraceSink→loop tracer)因 gw/tracer
构造顺序留作可选后置(端点已就绪, 未对接 span 空)。
2026-09-06 21:02:46 +08:00

165 lines
5.3 KiB
Go

package gateway
import (
"context"
"database/sql"
"fmt"
"log/slog"
"sync"
"time"
)
// AuditRecord is one persisted admin audit entry (who/what/when/result).
type AuditRecord struct {
ID int64 `json:"id"`
Who string `json:"who"`
What string `json:"what"`
When string `json:"when"`
Result string `json:"result"`
}
// AuditStore persists the admin audit trail. Implementations must be safe for
// concurrent use. *SQLiteAuditStore is the production implementation (wired by the
// composition root over a.db); when none is injected the gateway falls back to an
// in-memory store so the endpoint always returns data.
type AuditStore interface {
// Append persists one audit entry at ts.
Append(ctx context.Context, who, what, result string, ts time.Time) error
// List returns at most limit most-recent entries, newest first. A non-positive
// limit selects the default.
List(ctx context.Context, limit int) ([]AuditRecord, error)
}
// memoryAuditStore is the nil-injection fallback: it holds the audit trail in
// process memory so the admin endpoint works before the composition root injects a
// SQLite store. It is concurrency-safe.
type memoryAuditStore struct {
mu sync.Mutex
records []AuditRecord
seq int64
}
// newMemoryAuditStore builds an empty in-memory audit store.
func newMemoryAuditStore() *memoryAuditStore {
return &memoryAuditStore{records: make([]AuditRecord, 0, 64)}
}
// Append stores one entry with a monotonically increasing id.
func (m *memoryAuditStore) Append(ctx context.Context, who, what, result string, ts time.Time) error {
if err := ctx.Err(); err != nil {
return err
}
m.mu.Lock()
defer m.mu.Unlock()
m.seq++
m.records = append(m.records, AuditRecord{
ID: m.seq, Who: who, What: what, When: ts.UTC().Format(time.RFC3339), Result: result,
})
return nil
}
// List returns the most recent entries, newest first.
func (m *memoryAuditStore) List(ctx context.Context, limit int) ([]AuditRecord, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
if limit <= 0 {
limit = defaultObsLimit
}
if limit > maxObsLimit {
limit = maxObsLimit
}
m.mu.Lock()
defer m.mu.Unlock()
n := len(m.records)
start := n - limit
if start < 0 {
start = 0
}
out := make([]AuditRecord, n-start)
copy(out, m.records[start:])
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
out[i], out[j] = out[j], out[i]
}
return out, nil
}
// audit DDL for a self-contained SQLite store (no migration bookkeeping), the
// same pattern channel/session and backthink use. recorded_at is the UTC RFC3339
// wall-clock of the audited action.
const (
createAuditSQL = `
CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
who TEXT NOT NULL,
what TEXT NOT NULL,
result TEXT NOT NULL,
recorded_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
)`
createAuditIndexSQL = `CREATE INDEX IF NOT EXISTS idx_audit_logs_recorded_at ON audit_logs(recorded_at)`
insertAuditSQL = `INSERT INTO audit_logs (who, what, result, recorded_at) VALUES (?, ?, ?, ?)`
selectAuditSQL = `SELECT id, who, what, result, recorded_at FROM audit_logs ORDER BY id DESC LIMIT ?`
)
// SQLiteAuditStore persists the admin audit trail in SQLite. It is safe for
// concurrent use by virtue of the underlying *sql.DB.
type SQLiteAuditStore struct {
db *sql.DB
log *slog.Logger
}
// NewSQLiteAuditStore builds a SQLite-backed audit store over db, guaranteeing the
// audit_logs table and index exist. It returns an error when db is nil or the DDL
// fails. The composition root wires this over the primary *sql.DB (a.db).
func NewSQLiteAuditStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*SQLiteAuditStore, error) {
if db == nil {
return nil, fmt.Errorf("gateway: audit store: nil database")
}
if _, err := db.ExecContext(ctx, createAuditSQL); err != nil {
return nil, fmt.Errorf("gateway: ensure audit_logs: %w", err)
}
if _, err := db.ExecContext(ctx, createAuditIndexSQL); err != nil {
return nil, fmt.Errorf("gateway: ensure audit_logs index: %w", err)
}
return &SQLiteAuditStore{db: db, log: sourcedLog(log)}, nil
}
// Append persists one audit entry at ts.
func (s *SQLiteAuditStore) Append(ctx context.Context, who, what, result string, ts time.Time) error {
if _, err := s.db.ExecContext(ctx, insertAuditSQL, who, what, result, ts.UTC().Format(time.RFC3339Nano)); err != nil {
return fmt.Errorf("gateway: insert audit: %w", err)
}
return nil
}
// List returns the most recent entries, newest first.
func (s *SQLiteAuditStore) List(ctx context.Context, limit int) ([]AuditRecord, error) {
if limit <= 0 {
limit = defaultObsLimit
}
if limit > maxObsLimit {
limit = maxObsLimit
}
if err := ctx.Err(); err != nil {
return nil, err
}
rows, err := s.db.QueryContext(ctx, selectAuditSQL, limit)
if err != nil {
return nil, fmt.Errorf("gateway: list audit: %w", err)
}
defer rows.Close()
records := make([]AuditRecord, 0, limit)
for rows.Next() {
var r AuditRecord
if err := rows.Scan(&r.ID, &r.Who, &r.What, &r.Result, &r.When); err != nil {
return nil, fmt.Errorf("gateway: scan audit: %w", err)
}
records = append(records, r)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("gateway: iterate audit: %w", err)
}
return records, nil
}