- 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 空)。
165 lines
5.3 KiB
Go
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
|
|
}
|