lili f4cea4677a feat(tier2): 统一 trace 落库 sink + SAA schema + 生成控制面只读后端(切片一 C1/C3a)
JsonlFileSink 把五字段 trace 落 workdir/trace.jsonl(非阻塞·写失败不阻断生成);studio.py sink 接线;SAA 扩展段 schema(接口对称五核心+内容不对称 ext);管理面 3 只读端点 roles/traces/cost(惰性 import 保住「仅 import service.app 不牵 fastapi」红线)。测试 test_jsonl_sink 3 / test_admin_routes 6 全绿。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 01:39:06 -07:00

131 lines
6.3 KiB
Python

"""test_jsonl_sink.py —— U-C1a JSONL sink 落盘单测(TDD:先红后绿)。
跑:python3 tests/test_jsonl_sink.py(无 pytest,__main__ 跑全部断言)。
验 JsonlFileSink:
1. emit 三条 trace step → trace.jsonl 落盘;
2. 每行是合法 JSON;
3. 五字段(traceId / step / cost / verdict / timestamp)齐全;
4. 目录不存在 → 自动创建;
5. 写失败(不可写路径) → 只告警、不抛,不中断调用方。
"""
import sys
import os
import json
import tempfile
from pathlib import Path
_HERE = os.path.dirname(os.path.abspath(__file__))
_GW = os.path.dirname(_HERE) # gen-worker/
sys.path.insert(0, _GW)
from observability.trace import JsonlFileSink, make_jsonl_sink, TraceAdapter # noqa: E402
# ── 测试一:核心落盘 + 五字段完整性 ──────────────────────────────────────────────
def test_jsonl_sink_creates_file_and_lines():
"""emit 三条 trace step → trace.jsonl 落盘、每行合法 JSON、五字段齐、内容对。"""
with tempfile.TemporaryDirectory() as tmpdir:
out = Path(tmpdir) / "trace.jsonl"
sink = JsonlFileSink(out)
# 用 TraceAdapter + dict 桩事件(无需 AgentScope 真环境)驱动 sink。
adapter = TraceAdapter(trace_id="test-trace-001", sink=sink)
fake_events = [
# ReplyStartEvent → 落 raw.reply_boundary / agent_name
{"type": "ReplyStartEvent", "created_at": "2026-06-29T10:00:00",
"reply_id": "r1", "name": "writer"},
# ModelCallEndEvent → cost.tokens 非空
{"type": "ModelCallEndEvent", "created_at": "2026-06-29T10:00:01",
"input_tokens": 100, "output_tokens": 50},
# ToolResultEndEvent → verdict=success
{"type": "ToolResultEndEvent", "created_at": "2026-06-29T10:00:02",
"state": "success"},
]
for e in fake_events:
adapter.ingest(e)
# 断言①:文件已落盘
assert out.exists(), f"trace.jsonl 应存在,但未落盘:{out}"
lines = out.read_text(encoding="utf-8").strip().split("\n")
# 断言②:三条事件 → 三行
assert len(lines) == 3, f"应有 3 行,实际 {len(lines)} 行:\n" + "\n".join(lines)
# 断言③:每行合法 JSON + 五字段齐
CORE_FIELDS = {"traceId", "step", "cost", "verdict", "timestamp"}
for i, line in enumerate(lines):
obj = json.loads(line) # 若非合法 JSON 此处抛
missing = CORE_FIELDS - set(obj.keys())
assert not missing, f"{i} 行缺五字段 {missing}:{line}"
assert obj["traceId"] == "test-trace-001", (
f"{i} 行 traceId 错(期望 test-trace-001,实际 {obj['traceId']})")
assert obj["step"] == i, f"{i} 行 step 错(期望 {i},实际 {obj['step']})"
# 断言④:ModelCallEnd 步 cost 非空且 token 字段正确
second = json.loads(lines[1])
assert second["cost"] is not None, "ModelCallEnd 步 cost 应非空"
assert second["cost"]["tokens"]["in"] == 100, "input_tokens 应为 100"
assert second["cost"]["tokens"]["out"] == 50, "output_tokens 应为 50"
# 断言⑤:ToolResultEnd 步 verdict=success
third = json.loads(lines[2])
assert third["verdict"] == "success", (
f"ToolResultEnd 步 verdict 应为 success,实际 {third['verdict']}")
print(f" [ok] lines={len(lines)}, traceId={json.loads(lines[0])['traceId']}, "
f"cost.tokens.in={second['cost']['tokens']['in']}, verdict={third['verdict']}")
# ── 测试二:嵌套目录自动创建 ─────────────────────────────────────────────────────
def test_jsonl_sink_auto_creates_dir():
"""目录不存在 → sink 自动创建嵌套目录;emit 成功落盘。"""
with tempfile.TemporaryDirectory() as tmpdir:
out = Path(tmpdir) / "subdir" / "nested" / "trace.jsonl"
sink = make_jsonl_sink(out)
step = {"traceId": "t2", "step": 0, "cost": None, "verdict": None,
"timestamp": "2026-06-29T00:00:00",
"ext": {"raw": {"event": "ReplyStartEvent"}}}
sink(step)
assert out.exists(), f"嵌套目录下 trace.jsonl 应自动创建:{out}"
obj = json.loads(out.read_text(encoding="utf-8").strip())
assert obj["traceId"] == "t2", f"traceId 错:{obj['traceId']}"
print(f" [ok] auto-mkdir:{out}")
# ── 测试三:写失败只告警、不抛 ────────────────────────────────────────────────────
def test_jsonl_sink_write_failure_no_raise():
"""写失败(不可写路径) → 只 print 告警,绝不抛,不中断调用方。"""
# /proc/nonexistent/ 在 macOS 下不存在且不可写,触发写失败场景。
bad_path = Path("/proc/nonexistent_for_test_sink/trace.jsonl")
sink = JsonlFileSink(bad_path)
try:
sink({"traceId": "t3", "step": 0, "cost": None, "verdict": None,
"timestamp": "", "ext": {"raw": {"event": "X"}}})
print(" [ok] write-fail → 只告警,不抛")
except Exception as e:
raise AssertionError(f"sink 写失败时不应抛异常,但抛了:{e}") from e
# ── 入口 ────────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
tests = [
("test_jsonl_sink_creates_file_and_lines", test_jsonl_sink_creates_file_and_lines),
("test_jsonl_sink_auto_creates_dir", test_jsonl_sink_auto_creates_dir),
("test_jsonl_sink_write_failure_no_raise", test_jsonl_sink_write_failure_no_raise),
]
passed = failed = 0
for name, fn in tests:
try:
print(f"[RUN] {name}")
fn()
print(f"[PASS] {name}")
passed += 1
except Exception as exc:
import traceback
print(f"[FAIL] {name}: {exc}")
traceback.print_exc()
failed += 1
print(f"\n结果:{passed} 通过 / {failed} 失败")
sys.exit(0 if failed == 0 else 1)