merge: W10 — 接上可观测性下行帧(概览页实时刷新 metrics / trace / 日志)

This commit is contained in:
AskaEth committed 2026-10-04 18:42:58 +08:00
commit 93941cd1ea
9 files changed
+831 -11

No files matched your search

@@ -0,0 +1,198 @@
package top.yeij.cyrene.data.ws
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* 可观测实时推送三帧的解码(契约 v0.55 §3.12,api-client §3.12)。
*
* 帧形状取自上**后端生产者逐字**(`internal/services/gateway/observability_push.go` +
* `ws_bridge.go` 的 `eventBridge`):`wsEvent{Type: typ, Data: e.Payload}`——**帧壳不带**
* `sessionId` / `turnId` / `seq`(它们不属任何会话),载荷分别是
* `AdminLogPush{entries}` / `AdminTracePush{spans}` / `AdminMetrics` **本体**。
*
* 三条要点在这里钉死:
* 1. 三个帧名与载荷键逐字对齐(`entries` / `spans` / 9 个扁平指标键),任何一条对不上,
* 推送就会安静地落到「未知帧」被丢掉——这正是本模块要修的那个缺口;
* 2. 载荷缺席时**整帧安全忽略**(返回 `null`),绝不回落成默认值:metrics 的默认值是
* 全零快照,会把界面上的真实指标擦掉;
* 3. 未知帧、坏帧仍然安静丢弃(回归:不能因为接了新帧就把这条约定弄坏)。
*/
class WsEventObservabilityTest {
private fun parse(raw: String): WsEvent =
requireNotNull(WsEvent.parse(raw)) { "帧没解出来:$raw" }
/** 真实 metrics 帧(9 个键,扁平,与 `GET /admin/observability/metrics` 同形)。 */
private val metricsFrame = """
{"type":"observability_metrics","data":{"totalRequests":42,"qps":1.5,"avgLatencyMs":3.25,
"maxLatencyMs":88,"perMethod":{"GET":40,"POST":2},
"perPath":{"/api/v1/x":7},"statusCounts":{"200":7},"llmCalls":3,"subagentEvents":1}}
""".trimIndent().replace("\n", "")
private val traceFrame = """
{"type":"observability_trace","data":{"spans":[
{"id":"sp-2","name":"llm.chat","kind":"llm","status":"ok","startedAt":"2026-10-04T12:00:01Z",
"endedAt":"2026-10-04T12:00:02Z","durationNs":1000000000},
{"id":"sp-1","name":"tool.run","kind":"tool","status":"error","startedAt":"2026-10-04T12:00:00Z",
"durationNs":500000}]}}
""".trimIndent().replace("\n", "")
private val logFrame = """
{"type":"observability_log","data":{"entries":[
{"time":"2026-10-04T12:00:02Z","level":"warn","source":"gateway","msg":"慢请求"},
{"time":"2026-10-04T12:00:01Z","level":"info","source":"","msg":"启动完成"}]}}
""".trimIndent().replace("\n", "")
@Test
fun `the produced metrics frame decodes as the flat REST snapshot`() {
val event = parse(metricsFrame) as WsEvent.ObservabilityMetrics
assertEquals("observability_metrics", event.type)
val metrics = requireNotNull(event.metrics)
assertEquals(42L, metrics.totalRequests)
assertEquals(1.5, metrics.qps, 0.0)
assertEquals(3.25, metrics.avgLatencyMs, 0.0)
assertEquals(88L, metrics.maxLatencyMs)
assertEquals(40L, metrics.perMethod["GET"])
assertEquals(7L, metrics.perPath["/api/v1/x"])
assertEquals(7L, metrics.statusCounts["200"])
assertEquals(3L, metrics.llmCalls)
assertEquals(1L, metrics.subagentEvents)
// 这三帧不属任何会话:帧壳本来就没有这些字段。
assertNull(event.sessionId)
assertNull(event.turnId)
assertNull(event.seq)
}
@Test
fun `a metrics frame without a payload is dropped instead of becoming an all zero snapshot`() {
// 关键:默认值是一份「全零指标」,合并进界面就是把真实数字擦成 0。宁可整帧丢掉。
assertNull(WsEvent.parse("""{"type":"observability_metrics"}"""))
assertNull(WsEvent.parse("""{"type":"observability_metrics","data":null}"""))
assertNull(WsEvent.parse("""{"type":"observability_metrics","data":"oops"}"""))
}
@Test
fun `unknown keys inside the metrics payload do not break the frame`() {
val frame = """{"type":"observability_metrics","data":{"totalRequests":9,"tokens":123}}"""
val event = parse(frame) as WsEvent.ObservabilityMetrics
assertEquals(9L, requireNotNull(event.metrics).totalRequests)
}
@Test
fun `the trace frame decodes its spans in the order the server sent them`() {
val event = parse(traceFrame) as WsEvent.ObservabilityTrace
assertEquals("observability_trace", event.type)
assertEquals(listOf("sp-2", "sp-1"), event.spans.map { it.id })
val first = event.spans.first()
assertEquals("llm.chat", first.name)
assertEquals("llm", first.kind)
assertEquals("ok", first.status)
assertEquals("2026-10-04T12:00:01Z", first.startedAt)
assertEquals("2026-10-04T12:00:02Z", first.endedAt)
assertEquals(1_000_000_000L, first.durationNs)
assertNull(event.sessionId)
}
@Test
fun `a trace frame with no spans is an empty delta, not a failure`() {
val event = parse("""{"type":"observability_trace","data":{"spans":[]}}""")
as WsEvent.ObservabilityTrace
assertTrue(event.spans.isEmpty())
}
@Test
fun `a trace frame whose payload is missing is safely ignored`() {
// 空增量的语义是「什么也不改」;而「没有任何载荷」根本不该被当成一帧数据。
assertNull(WsEvent.parse("""{"type":"observability_trace"}"""))
assertNull(WsEvent.parse("""{"type":"observability_trace","data":null}"""))
}
@Test
fun `a trace span of the wrong shape drops the frame without throwing`() {
// span 缺 id(DTO 必填)→ 整帧按坏帧忽略。契约里 id 恒在,这里只钉「绝不抛」。
val frame = """{"type":"observability_trace","data":{"spans":[{"name":"no-id"}]}}"""
assertNull(WsEvent.parse(frame))
}
@Test
fun `the log frame decodes its entries verbatim`() {
val event = parse(logFrame) as WsEvent.ObservabilityLog
assertEquals("observability_log", event.type)
assertEquals(listOf("慢请求", "启动完成"), event.entries.map { it.msg })
assertEquals("2026-10-04T12:00:02Z", event.entries.first().time)
assertEquals("warn", event.entries.first().level)
assertEquals("gateway", event.entries.first().source)
// 记录没带来源时是空串(不是 null)——原样保留,不做归一。
assertEquals("", event.entries[1].source)
assertNull(event.sessionId)
}
@Test
fun `an unknown log level is kept as is instead of being dropped`() {
// level 是开放集合:未知值原样留着(UI 侧过滤时也保留,见 filterLogEntries)。
val frame = """
{"type":"observability_log","data":{"entries":[
{"time":"2026-10-04T12:00:00Z","level":"trace","source":"x","msg":"未来等级"}]}}
""".trimIndent().replace("\n", "")
val event = parse(frame) as WsEvent.ObservabilityLog
assertEquals("trace", event.entries.single().level)
}
@Test
fun `a log entry with unknown keys still decodes`() {
val frame = """
{"type":"observability_log","data":{"entries":[
{"time":"2026-10-04T12:00:00Z","level":"error","source":"x","msg":"炸了","stack":"..."}]}}
""".trimIndent().replace("\n", "")
val event = parse(frame) as WsEvent.ObservabilityLog
assertEquals("炸了", event.entries.single().msg)
}
@Test
fun `a log frame whose payload is missing is safely ignored`() {
assertNull(WsEvent.parse("""{"type":"observability_log"}"""))
assertNull(WsEvent.parse("""{"type":"observability_log","data":null}"""))
}
@Test
fun `a session on the shell would be passed through, though the server sends none`() {
// 契约说这三帧不带 sessionId;解析层不替服务端做判断,有就原样带上。
val event = parse("""{"type":"observability_log","sessionId":"web","data":{"entries":[]}}""")
as WsEvent.ObservabilityLog
assertEquals("web", event.sessionId)
}
@Test
fun `unknown frames are still ignored quietly`() {
// 回归:本次新增三个帧不能把「未知帧安静忽略」这条约定弄坏
//(审计 tab 的帧后端至今没做,将来真做了也不该在旧客户端上抛)。
assertNull(WsEvent.parse("""{"type":"observability_audit","data":{"x":1}}"""))
assertNull(WsEvent.parse("""{"type":"brand.new.frame","data":{}}"""))
assertNull(WsEvent.parse("不是 JSON"))
assertNull(WsEvent.parse("""{"no":"type"}"""))
}
@Test
fun `the observability frames are not session scoped, so every session accepts them`() {
// 会话闸对它们是空操作:帧壳没有 sessionId(空 = 无法判定 = 放行)。
// 上层因此只能按「连接身份 + 角色」决定收不收,而不是按会话。
val event = parse(logFrame)
assertTrue(event.isForSession("web"))
assertTrue(event.isForSession("web:other"))
}
}
@@ -68,4 +68,26 @@ class AdminAccessTest {
assertTrue(AdminRole.Viewer.rank < AdminRole.Admin.rank)
assertTrue(AdminRole.Admin.rank < AdminRole.Owner.rank)
}
@Test
fun `only owner and admin receive observability frames`() {
// 契约 §3.12:这三个帧不在「状态机信号」白名单里 ⇒ 后端 fail-closed 只发 owner + admin。
assertTrue(canReceiveObservabilityFrames(AdminRole.Owner))
assertTrue(canReceiveObservabilityFrames(AdminRole.Admin))
assertFalse(canReceiveObservabilityFrames(AdminRole.Viewer))
}
@Test
fun `being able to open the overview page is not enough for the frames`() {
// 概览页是 Monitor 档(viewer 也能进、也能读那两个 REST),但它收不到推送——
// 两条门必须分开判,否则 viewer 会开着连接干等一份永远不来的帧。
assertTrue(canAccess(AdminRole.Viewer, AdminPage.Overview))
assertFalse(canReceiveObservabilityFrames(AdminRole.Viewer))
}
@Test
fun `an unknown role is treated as a viewer for the frames too`() {
assertFalse(canReceiveObservabilityFrames(AdminRole.of("superowner")))
assertFalse(canReceiveObservabilityFrames(AdminRole.of(null)))
}
}
@@ -0,0 +1,135 @@
package top.yeij.cyrene.ui.admin
import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.data.api.dto.AdminTraceSpanDto
import top.yeij.cyrene.data.ws.ObservabilityLogEntryDto
/**
* 可观测推送帧的合并与本地过滤(契约 v0.55 §3.12)的纯函数。
*
* 断言的是**三条不会写进类型里的语义**:新帧在前(列表两端本来就最新在前,不排序)、
* 按业务键去重(帧与 REST 竞态时不会出现两条同样的数据)、窗口截断(不许无界增长);
* 以及日志的等级过滤保留「判不出等级」的条目。
*/
class AdminOverviewMergeTest {
private fun span(id: String) = AdminTraceSpanDto(id = id, name = "span-$id", startedAt = "2026-10-04T12:00:00Z")
private fun entry(msg: String, level: String = "info") =
ObservabilityLogEntryDto(time = "2026-10-04T12:00:00Z", level = level, source = "gateway", msg = msg)
@Test
fun `an empty delta leaves the snapshot untouched`() {
val existing = listOf(span("a"))
// 空增量不是「清空」:REST 引导读取拿到的快照必须原样留着。
assertSame(existing, mergeTraceSpans(existing, emptyList()))
val logs = listOf(entry("启动"))
assertSame(logs, mergeLogEntries(logs, emptyList()))
}
@Test
fun `new spans go in front of the guide snapshot`() {
val merged = mergeTraceSpans(listOf(span("old")), listOf(span("new")))
assertEquals(listOf("new", "old"), merged.map { it.id })
}
@Test
fun `a span already present does not appear twice`() {
// 帧与那次 REST 引导读取可能覆盖同一段:按 id 去重,且**新到的那一份**留在前面。
val merged = mergeTraceSpans(listOf(span("a"), span("b")), listOf(span("b"), span("c")))
assertEquals(listOf("b", "c", "a"), merged.map { it.id })
}
@Test
fun `the trace window keeps only the newest entries`() {
val existing = (1..5).map { span("s$it") }
val merged = mergeTraceSpans(existing, listOf(span("n")), window = 3)
assertEquals(listOf("n", "s1", "s2"), merged.map { it.id })
}
@Test
fun `log entries are deduplicated by their whole business key`() {
// 服务端两端都是**最新在前**,合并保留各自的相对顺序(不排序):帧里新到的在前,
// 已经在列表里的那条不会被重复追加。
val merged = mergeLogEntries(listOf(entry("启动")), listOf(entry("慢请求"), entry("启动")))
assertEquals(listOf("慢请求", "启动"), merged.map { it.msg })
}
@Test
fun `log entries differing only in level are distinct`() {
val merged = mergeLogEntries(
listOf(entry("同一句", level = "info")),
listOf(entry("同一句", level = "error")),
)
assertEquals(2, merged.size)
}
@Test
fun `the log window keeps only the newest entries`() {
val existing = (1..4).map { entry("m$it") }
val merged = mergeLogEntries(existing, listOf(entry("fresh")), window = 2)
assertEquals(listOf("fresh", "m1"), merged.map { it.msg })
}
@Test
fun `no level filter keeps every entry`() {
val entries = listOf(entry("a", "debug"), entry("b", "info"), entry("c", "error"))
assertSame(entries, filterLogEntries(entries, null))
assertSame(entries, filterLogEntries(entries, ""))
// 未知的过滤档不猜:退回「不过滤」,而不是把列表过滤空。
assertSame(entries, filterLogEntries(entries, "verbose"))
}
@Test
fun `a minimum level keeps that level and above`() {
val entries = listOf(entry("d", "debug"), entry("i", "info"), entry("w", "warn"), entry("e", "error"))
// 与后端 `?level=` 同口径:是最低等级,不是精确匹配。
assertEquals(listOf("w", "e"), filterLogEntries(entries, "warn").map { it.msg })
assertEquals(listOf("e"), filterLogEntries(entries, "error").map { it.msg })
assertEquals(4, filterLogEntries(entries, "debug").size)
}
@Test
fun `the level filter tolerates case and blanks and keeps unknown levels`() {
val entries = listOf(entry("w", " WARN "), entry("u", "trace"), entry("i", "info"))
// 等级是开放集合:判不出高低的条目一律保留,绝不因为「可能低于阈值」被静默藏掉。
assertEquals(listOf("w", "u"), filterLogEntries(entries, "warn").map { it.msg })
}
@Test
fun `log keys distinguish the four fields of an entry`() {
val base = entry("同一句")
val keys = setOf(
logEntryKey(base),
logEntryKey(base.copy(time = "2026-10-04T12:00:01Z")),
logEntryKey(base.copy(level = "warn")),
logEntryKey(base.copy(source = "other")),
logEntryKey(base.copy(msg = "另一句")),
)
assertEquals(5, keys.size)
}
@Test
fun `window constants match what the contract promises`() {
// 契约 §3.12:单帧上限 trace 100 / log 200,客户端保留窗口与它们对齐。
assertEquals(100, TRACE_WINDOW)
assertEquals(200, LOG_WINDOW)
assertTrue(TRACE_WINDOW > 0)
}
}