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>
131 lines
6.3 KiB
Python
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)
|