Files
Cyrene_v2/internal/services/gateway/observability_push.go
T
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

285 lines
10 KiB
Go
Raw 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 (
"bytes"
"context"
"encoding/json"
"net/http"
"sync"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
)
// 本文件是裁决 05.4.3 + 16.4.3-WS 的落点:日志 / 指标 / trace 从「前端轮询
// 管理 REST」改为「网关主动推 WS 帧」(design/10 §5 口径)。
//
// 两条边界分开,谁都不越界:
//
// - **谁能收到帧**:仍由 ws_frame_scope.go 的角色门决定。这三个帧类型没有
// 登记进 frameReachesAll,因此按 fail-closed 默认档只发 owner + admin;
// viewer 继续走既有 REST 只读档取数(design/11 §7.1)。
// - **什么时候发帧**:由本文件的订阅登记决定。没有订阅者时推送循环不启动,
// 网关不为可观测数据做任何周期性工作 —— 这正是「推送替代轮询」相对
// 「每个面板各自 1s 轮询」的收益所在。
//
// 订阅从哪来:面板挂载时的首次读取(GET /api/v1/admin/observability/{logs,
// metrics,trace})兼任订阅握手。上游订阅帧需要 ws_route.go 的上行分发开口,
// 那不在本次可动的文件面内;用「读过实时数据」作显式订阅动作语义等价,且天然
// 只由真正打开过面板的凭据触发。订阅以凭据身份(sub + role + iat,等价于那次
// 登录)为键,其连接全部断开后自动退订。
const (
// wsObservabilityLog / Trace / Metrics 是三个下行帧类型。帧壳与既有下行
// 帧一致(wsEvent{type,data}),不带 sessionId:它们不属任何会话。
wsObservabilityLog = "observability_log"
wsObservabilityTrace = "observability_trace"
wsObservabilityMetrics = "observability_metrics"
// 产生这三个帧的 bus 主题。ws_bridge.go 的 eventBridge 把主题映射成上面的
// 帧类型,因此推送走的是与其它实时事件完全相同的
// 「bus → relay → 角色门 → 连接」路径(不新开通道、不新开端口)。
obsLogSubject = "observability.log"
obsTraceSubject = "observability.trace"
obsMetricsSubject = "observability.metrics"
// observabilityPushInterval 是推送循环的节拍:每拍最多各发一帧
// (日志增量 / 新 span / 变化过的指标快照),只发真的有新数据的那类。
observabilityPushInterval = time.Second
// maxLogPushBatch / maxSpanPushBatch 界定单帧载荷大小。一拍的突发超过上限
// 时只发最新的那些 —— 更旧的条目本就落在面板保留窗口之外,与既有 REST 尾部
// (limit 上限)语义一致,因此不丢「看得见」的数据。
maxLogPushBatch = 200
maxSpanPushBatch = 100
)
// AdminLogPush 是 observability_log 帧的 data:一批日志条目,**最新在前**
// (与 GET /api/v1/admin/observability/logs 同序),面板据此前缀拼接。
type AdminLogPush struct {
Entries []AdminLogEntry `json:"entries"`
}
// AdminTracePush 是 observability_trace 帧的 data:一批新完成的 span,
// **最新在前**(与 GET /api/v1/admin/observability/trace 同序)。
type AdminTracePush struct {
Spans []AdminTraceSpan `json:"spans"`
}
// obsWatchKey 是「谁在订阅」的键:凭据身份,等价于那次登录。用 iat 而不用
// token 串,避免把凭据本身留在内存里。
type obsWatchKey struct {
sub string
role auth.Role
iat int64
}
// watchKeyOf 把连接/请求的凭据折叠成订阅键。
func watchKeyOf(claims *auth.Claims) obsWatchKey {
return obsWatchKey{sub: claims.Sub, role: claims.Role, iat: claims.IssuedAt}
}
// obsWatches 是订阅登记表。它与推送循环同生命周期:第一个订阅建立时启动
// 循环,最后一个订阅被剪掉时循环自行退出,因此订阅表空着的时候没有任何
// 常驻 goroutine,也没有任何周期性开销。
type obsWatches struct {
mu sync.Mutex
set map[obsWatchKey]struct{}
running bool
}
// newObsWatches 构建一个空的订阅登记表。
func newObsWatches() obsWatches {
return obsWatches{set: make(map[obsWatchKey]struct{})}
}
// add 登记一个订阅,必要时启动推送循环。没有 bus 就没有下行通道,
// 登记也不会产生任何推送,因此直接跳过(不启动循环)。
func (w *obsWatches) add(s *Server, key obsWatchKey) {
if s == nil || s.bus == nil {
return
}
w.mu.Lock()
w.set[key] = struct{}{}
start := !w.running
if start {
w.running = true
}
w.mu.Unlock()
if start {
go w.loop(s)
}
}
// loop 是推送循环:每拍先剪掉「凭据已不在线」的订阅,再按需发布三类数据。
// 返回即代表循环已退出(下一个订阅会重新启动它)。
//
// 锚点在循环启动时取定(见 newObsPushState):只发订阅之后新产生的数据,
// 历史由面板自己那次引导读取提供。
func (w *obsWatches) loop(s *Server) {
ticker := time.NewTicker(observabilityPushInterval)
defer ticker.Stop()
state := newObsPushState(s)
for range ticker.C {
if !w.prune(s) {
return
}
state.publish(s)
}
}
// prune 剪掉没有在线 owner/admin 连接的订阅,并报告循环是否还要继续。
// 「订阅表空了」与「退出」必须在同一把锁里判定:否则一个新订阅可能刚看到
// running=true(以为循环还活着),而循环已经走向退出,推送就此静默停摆。
func (w *obsWatches) prune(s *Server) bool {
w.mu.Lock()
defer w.mu.Unlock()
live := liveWatchKeys(s)
for k := range w.set {
if !live[k] {
delete(w.set, k)
}
}
if len(w.set) == 0 {
w.running = false
return false
}
return true
}
// liveWatchKeys 返回当前在线 owner/admin 连接的凭据身份集合。
func liveWatchKeys(s *Server) map[obsWatchKey]bool {
out := make(map[obsWatchKey]bool)
if s == nil || s.hub == nil {
return out
}
s.hub.mu.RLock()
defer s.hub.mu.RUnlock()
for c := range s.hub.conns {
if c.claims == nil || !ownerAdminClaims(c.claims) {
continue
}
out[watchKeyOf(c.claims)] = true
}
return out
}
// watchObservability 把一次可观测读取登记为订阅。只有 owner/admin 会登记:
// viewer 仍走纯 REST 只读档,不被推送(design/10 §5)。读取本身照常返回引导
// 数据,因此面板不需要轮询也能拿到首屏。
func (s *Server) watchObservability(r *http.Request) {
if s == nil || s.obs == nil || r == nil {
return
}
claims, ok := auth.ClaimsFromContext(r.Context())
if !ok || claims == nil || !ownerAdminClaims(claims) {
return
}
s.obs.watches.add(s, watchKeyOf(claims))
}
// obsPushState 是推送循环的游标:日志/span 已经发到哪里、上一次发出的指标
// 指纹是什么。它只被循环自己的 goroutine 触碰,因此不需要锁。
type obsPushState struct {
lastLogTime time.Time
lastSpanSeq uint64
lastMetrics []byte
}
// newObsPushState 取定本轮的推送锚点:日志取当前最新条目的时间、trace 取当前
// 写入序号、指标取当前快照的指纹。三者都表示「截至此刻的数据面板已经通过
// REST 引导读取看到过了」,因此循环不会把环形缓冲里的历史整段灌成巨型帧。
func newObsPushState(s *Server) *obsPushState {
st := &obsPushState{}
if buf := s.logBuffer(); buf != nil {
if entries := buf.Snapshot(1); len(entries) == 1 {
st.lastLogTime = entries[0].Time
}
}
if s.obs != nil {
st.lastSpanSeq = s.obs.traceBuf.Seq()
if raw, err := json.Marshal(s.obs.metrics.Snapshot(time.Now())); err == nil {
st.lastMetrics = raw
}
}
return st
}
// publish 发布本拍新增的数据:日志增量、新 span、变化过的指标快照。
// 三类互相独立 —— 没有新数据的那类不发帧(design/10 §5「推送只在有数据时
// 发一帧」),因此空转的服务端一帧都不发。
func (st *obsPushState) publish(s *Server) {
st.publishLogs(s)
st.publishSpans(s)
st.publishMetrics(s)
}
// publishLogs 把锚点之后的新日志条目发成一帧(最新在前)。日志环形缓冲未注入
// (网关没接组合根的日志尾部)时没有数据源,直接跳过。
func (st *obsPushState) publishLogs(s *Server) {
buf := s.logBuffer()
if buf == nil {
return
}
entries := buf.Snapshot(maxLogPushBatch)
fresh := make([]AdminLogEntry, 0, len(entries))
for _, e := range entries {
if !e.Time.After(st.lastLogTime) {
break // 最新在前:一旦遇到不新的条目,后面的只会更旧
}
fresh = append(fresh, adminLogOf(e))
}
if len(fresh) == 0 {
return
}
st.lastLogTime = entries[0].Time
s.publishObservability(obsLogSubject, AdminLogPush{Entries: fresh})
}
// publishSpans 把锚点之后新完成的 span 发成一帧(最新在前)。
func (st *obsPushState) publishSpans(s *Server) {
if s.obs == nil {
return
}
entries := s.obs.traceBuf.SnapshotSince(st.lastSpanSeq, maxSpanPushBatch)
if len(entries) == 0 {
return
}
spans := make([]AdminTraceSpan, 0, len(entries))
for _, e := range entries {
spans = append(spans, adminSpanOf(e.rec))
}
st.lastSpanSeq = entries[0].seq
s.publishObservability(obsTraceSubject, AdminTracePush{Spans: spans})
}
// publishMetrics 在当前指标快照与上次发出的不同时发一帧。用序列化结果做指纹:
// 快照的 map 副本会被编码,编码是确定性的(键排序),因此「没变」可以精确判定,
// 而 QPS 这类会随时间衰减的量自然在变化时照常推。
func (st *obsPushState) publishMetrics(s *Server) {
if s.obs == nil {
return
}
snap := s.obs.metrics.Snapshot(time.Now())
raw, err := json.Marshal(snap)
if err != nil {
return
}
if bytes.Equal(raw, st.lastMetrics) {
return
}
st.lastMetrics = raw
s.publishObservability(obsMetricsSubject, snap)
}
// publishObservability 把一个可观测事件发上 bus:它会经 ws_bridge.go 的主题
// 映射变成下行帧,并照常过一遍角色门(本包唯一的可见性决策点)。bus 缺席时
// 是 no-op。
func (s *Server) publishObservability(subject string, payload any) {
if s == nil || s.bus == nil {
return
}
if err := s.bus.Publish(context.Background(), subject, payload); err != nil {
s.log.Warn("publish observability frame", "subject", subject, "err", err)
}
}