Files
Cyrene_v2/internal/services/gateway/observability_trace.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

138 lines
3.8 KiB
Go

package gateway
import (
"sync"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// defaultTraceBufCap is how many completed spans the ring buffer holds before the
// oldest is evicted. Compile-time constant.
const defaultTraceBufCap = 512
// AdminTraceSpan is the camelCase wire shape of one traced span. It decouples the
// frontend from the trace package's snake_case SpanRecord.
type AdminTraceSpan struct {
ID string `json:"id"`
ParentID string `json:"parentId,omitempty"`
Name string `json:"name"`
Kind string `json:"kind"`
Status string `json:"status"`
StartedAt string `json:"startedAt"`
EndedAt string `json:"endedAt,omitempty"`
DurationNs int64 `json:"durationNs"`
Props map[string]any `json:"props,omitempty"`
Events []trace.Event `json:"events,omitempty"`
}
// RingBuffer is a concurrency-safe bounded buffer of completed trace spans. The
// oldest span is evicted when the buffer is full, so the admin panel always sees
// the most recent work without unbounded growth.
type RingBuffer struct {
mu sync.RWMutex
buf []trace.SpanRecord
next int
full bool
cap int
}
// newRingBuffer builds a ring buffer holding up to cap spans (default when cap<=0).
func newRingBuffer(cap int) *RingBuffer {
if cap <= 0 {
cap = defaultTraceBufCap
}
return &RingBuffer{buf: make([]trace.SpanRecord, cap), cap: cap}
}
// Append stores rec, evicting the oldest when the buffer is full.
func (r *RingBuffer) Append(rec trace.SpanRecord) {
r.mu.Lock()
defer r.mu.Unlock()
r.buf[r.next] = rec
r.next = (r.next + 1) % r.cap
if r.next == 0 {
r.full = true
}
}
// Snapshot returns up to n most-recent spans, newest first. A non-positive n
// selects defaultObsLimit; n is clamped to maxObsLimit.
func (r *RingBuffer) Snapshot(n int) []trace.SpanRecord {
if n <= 0 {
n = defaultObsLimit
}
if n > maxObsLimit {
n = maxObsLimit
}
r.mu.RLock()
defer r.mu.RUnlock()
count := r.next
if r.full {
count = r.cap
}
if count > n {
count = n
}
out := make([]trace.SpanRecord, 0, count)
for i := 0; i < count; i++ {
idx := (r.next - 1 - i + r.cap*2) % r.cap
out = append(out, r.buf[idx])
}
return out
}
// traceSink adapts a *RingBuffer to trace.Writer so a completed span is appended
// to the gateway's trace buffer.
type traceSink struct{ b *RingBuffer }
// WriteSpan appends rec to the ring buffer.
func (s traceSink) WriteSpan(rec trace.SpanRecord) error {
if s.b == nil {
return nil
}
s.b.Append(rec)
return nil
}
// MultiTraceWriter fans a completed span out to several trace.Writers, so a tracer
// can record to both the gateway's ring buffer and a JSONL file in one pass. A nil
// Writer is skipped. It is the composition-root helper the main session uses to
// route the loop tracer's output into the gateway without dropping stderr.
type MultiTraceWriter struct {
// Writers are the sinks a span is written to. A nil Writer is skipped.
Writers []trace.Writer
}
// WriteSpan writes rec to every configured Writer.
func (m MultiTraceWriter) WriteSpan(rec trace.SpanRecord) error {
for _, w := range m.Writers {
if w == nil {
continue
}
if err := w.WriteSpan(rec); err != nil {
return err
}
}
return nil
}
// adminSpanOf maps a trace.SpanRecord to the camelCase admin wire shape.
func adminSpanOf(rec trace.SpanRecord) AdminTraceSpan {
sp := AdminTraceSpan{
ID: rec.ID,
ParentID: rec.ParentID,
Name: rec.Name,
Kind: string(rec.Kind),
Status: string(rec.Status),
StartedAt: rec.StartedAt.UTC().Format(time.RFC3339),
DurationNs: rec.Duration.Nanoseconds(),
Props: rec.Props,
Events: rec.Events,
}
if !rec.EndedAt.IsZero() {
sp.EndedAt = rec.EndedAt.UTC().Format(time.RFC3339)
}
return sp
}