Files
AskaEth 0e1f163ef9
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(gateway,admin-web): 可观测面从轮询改为 WS 帧推送(裁决 05.4.3 + 16.4.3-WS)
- 新增三个下行帧(纯新增,既有帧名/形状零改动):observability_log / observability_trace /
  observability_metrics,各带 ≤1s 节拍、无新数据不发帧、单帧上限(日志 200 / span 100)
- 帧壳 {type,data} 不带 sessionId;**不进 frameReachesAll 白名单** ⇒ fail-closed 只发 owner+admin
  (变异验证两次:加进白名单 → viewer 用例必红)
- 推送开启条件=owner/admin 凭据「读过一次」对应 REST(引导数据仍由该次读取返回),
  连接全断即退订并停循环(无常驻 goroutine);既有 REST 端点路径/RBAC/形状完全不变
- 前端:删除 logs/metrics/trace 三处 useAutoReload 轮询;新增 observabilityStream(引导+去重合并+窗口)
  与 observabilityViews(三个面板 + WS 徽标),长列表统一走已落地的 VirtualList
- 复核:build/vet 绿 + gateway 全包测试绿(含真 bus→真 /ws/chat 三角色帧断言)+ `-race`;
  前端 tsc 0 错 + vite build 成功
- 待登记契约:api-client 需新增「可观测实时推送」一节
2026-10-04 13:58:03 +08:00

142 lines
4.5 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package gateway
import (
"net/http"
"strconv"
"sync/atomic"
"time"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// trace/audit 端点共享的默认列表上限。编译期常量,
// 因此运行路径中不出现魔法数字(项目规范 §10)。
const (
defaultObsLimit = 50
maxObsLimit = 500
)
// observability 打包网关的实时管理仪表:请求
// 指标计数器、trace span 环形缓冲、审计存储与
// 进程级日志环形缓冲。它在 New 中构建,且对并发
// 使用安全。
type observability struct {
metrics *MetricsCollector
traceBuf *RingBuffer
audit AuditStore
// watches 是日志/指标/trace 推送的订阅登记(裁决 05.4.3 + 16.4.3-WS)。
// 没有订阅者时推送循环不运行,见 observability_push.go。
watches obsWatches
// logBuf 是管理日志尾部端点背后的 logger 日志环形缓冲。
// 组合根在网关存在后经 SetLogBuffer 注入它;在那之前
// 该端点返回 501,而不是空但虚假的尾部。
logBuf atomic.Pointer[logger.RingBuffer]
}
// newObservability 构建仪表,未注入存储时回退到内存审计
// 存储。
func newObservability(deps Deps) *observability {
obs := &observability{
metrics: newMetricsCollector(),
traceBuf: newRingBuffer(defaultTraceBufCap),
watches: newObsWatches(),
}
if deps.AuditStore != nil {
obs.audit = deps.AuditStore
} else {
obs.audit = newMemoryAuditStore()
}
return obs
}
// ObserveSpan 把一个已完成的 trace span 送入网关的环形缓冲与
// LLM/子代理指标计数器。它是网关的 trace 注入点:
// 组合根把 loop tracer 接到这里(例如通过 TraceSink),使
// 管理面板能查询 span 而网关无需触碰 wire.go。
func (s *Server) ObserveSpan(rec trace.SpanRecord) {
if s.obs == nil {
return
}
s.obs.traceBuf.Append(rec)
s.obs.metrics.Observe(rec)
}
// TraceSink 返回一个 trace.Writer,把已完成的 span 追加进网关的
// trace 环形缓冲。在组合根把它与既有的 JSONRecorder 结合
// (例如 trace.New(MultiTraceWriter{...})),使 span 既落 stderr 又进
// 管理面板。
func (s *Server) TraceSink() trace.Writer {
if s.obs == nil {
return nil
}
return traceSink{s.obs.traceBuf}
}
// recordMetric 为可观测面板计一次请求。
func (s *Server) recordMetric(method, path string, status int, durMs int64) {
if s.obs == nil {
return
}
s.obs.metrics.Record(method, path, status, durMs)
}
// adminObservabilityMetrics 返回当前网关指标(管理读)。读取同时充当
// 实时推送的订阅握手(见 observability_push.go):此后指标由 WS 帧推来,
// 面板不再轮询。
func (s *Server) adminObservabilityMetrics(w http.ResponseWriter, r *http.Request) {
s.watchObservability(r)
if s.obs == nil {
writeJSON(w, http.StatusOK, AdminMetrics{
PerMethod: map[string]int64{}, PerPath: map[string]int64{}, StatusCounts: map[string]int64{},
})
return
}
writeJSON(w, http.StatusOK, s.obs.metrics.Snapshot(time.Now()))
}
// adminObservabilityTrace 返回最近的已追踪 span(管理读)。读取同时充当
// 实时推送的订阅握手(见 observability_push.go)。
func (s *Server) adminObservabilityTrace(w http.ResponseWriter, r *http.Request) {
s.watchObservability(r)
if s.obs == nil {
writeJSON(w, http.StatusOK, []AdminTraceSpan{})
return
}
spans := s.obs.traceBuf.Snapshot(parseLimit(r))
out := make([]AdminTraceSpan, 0, len(spans))
for _, sp := range spans {
out = append(out, adminSpanOf(sp))
}
writeJSON(w, http.StatusOK, out)
}
// adminObservabilityAudit 返回最近的审计条目(管理读)。
func (s *Server) adminObservabilityAudit(w http.ResponseWriter, r *http.Request) {
if s.obs == nil || s.obs.audit == nil {
writeJSON(w, http.StatusOK, []AuditRecord{})
return
}
records, err := s.obs.audit.List(r.Context(), parseLimit(r))
if err != nil {
s.log.Error("list audit", "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "audit_failed", "failed to list audit records"))
return
}
writeJSON(w, http.StatusOK, records)
}
// parseLimit 解析 ?limit= 查询参数,夹取到 [1, maxObsLimit],
// 缺失或非法时选用 defaultObsLimit。
func parseLimit(r *http.Request) int {
n, err := strconv.Atoi(r.URL.Query().Get("limit"))
if err != nil || n <= 0 {
return defaultObsLimit
}
if n > maxObsLimit {
return maxObsLimit
}
return n
}