Files
wehub-resource-sync 4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

431 lines
15 KiB
Python

"""Tests for process stats and session trace sink."""
from __future__ import annotations
import contextvars
import json
import threading
import time
from pathlib import Path
from typing import Any
import pytest
from core.agent_harness.session.persistence.jsonl_storage import JsonlSessionStorage
from platform.observability.trace import process_stats
from platform.observability.trace.process_stats import (
sample_resource_snapshot,
sample_thread_snapshot,
sample_turn_boundary_stats,
)
from platform.observability.trace.spans import (
NoopSessionTraceSink,
bind_session_trace,
component_span,
current_trace_session_id,
emit_route,
emit_span,
emit_thread_boundary,
get_session_trace_sink,
is_session_trace_active,
llm_span,
mark_span_outcome,
set_session_trace_sink,
stage_span,
timed_span,
tool_span,
traced_session,
)
from surfaces.interactive_shell.session.trace_sink import (
JsonlSessionTraceSink,
jsonl_trace_sink_for_session,
)
@pytest.fixture(autouse=True)
def _reset_session_trace_sink() -> Any:
set_session_trace_sink(NoopSessionTraceSink())
yield
set_session_trace_sink(NoopSessionTraceSink())
def _seed_session_jsonl(tmp_path: Path, session_id: str) -> Path:
path = tmp_path / f"{session_id}.jsonl"
path.write_text(
json.dumps({"type": "session", "version": 2, "id": session_id}) + "\n",
encoding="utf-8",
)
return path
def _activate_jsonl_sink(tmp_path: Path, session_id: str, monkeypatch: pytest.MonkeyPatch) -> Path:
monkeypatch.setattr(
"core.agent_harness.session.persistence.jsonl_storage.session_path",
lambda sid: tmp_path / f"{sid}.jsonl",
)
path = _seed_session_jsonl(tmp_path, session_id)
set_session_trace_sink(JsonlSessionTraceSink(storage=JsonlSessionStorage()))
return path
def _trace_spans(path: Path) -> list[dict[str, Any]]:
return [
json.loads(line)
for line in path.read_text(encoding="utf-8").strip().splitlines()
if json.loads(line).get("type") == "trace_span"
]
def test_sample_thread_snapshot_lists_current_thread() -> None:
snap = sample_thread_snapshot()
assert snap["thread_count"] >= 1
names = {row["name"] for row in snap["threads"]}
assert threading.current_thread().name in names
assert "main_thread_ident" in snap
def test_sample_resource_snapshot_includes_gc_counts() -> None:
snap = sample_resource_snapshot()
assert {"gc_gen0", "gc_gen1", "gc_gen2"} <= snap.keys()
# Prefer current RSS (Linux /proc); peak watermark is separate.
if "rss_mb" in snap:
assert isinstance(snap["rss_mb"], float)
assert snap["rss_mb"] > 0
if process_stats._resource is not None:
assert "rss_peak_mb" in snap
assert isinstance(snap["rss_peak_mb"], float)
def test_sample_resource_snapshot_current_rss_not_peak(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``rss_mb`` must be current RSS; ``rss_peak_mb`` is the high-water mark."""
monkeypatch.setattr(process_stats, "_current_rss_mb", lambda: 50.0)
class _FakeResource:
RUSAGE_SELF = 0
@staticmethod
def getrusage(_who: int) -> object:
# Peak higher than current (bytes on darwin path via normalize)
return type("RU", (), {"ru_maxrss": 200 * 1024 * 1024})()
monkeypatch.setattr(process_stats, "_resource", _FakeResource)
monkeypatch.setattr(process_stats.sys, "platform", "darwin")
snap = sample_resource_snapshot()
assert snap["rss_mb"] == 50.0
assert snap["rss_peak_mb"] == 200.0
assert snap.get("rss_is_peak") is not True
def test_sample_resource_snapshot_skips_rss_without_backends(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""No resource / no Linux /proc → snapshot still succeeds without rss_mb."""
monkeypatch.setattr(process_stats, "_resource", None)
monkeypatch.setattr(process_stats.sys, "platform", "win32")
snap = sample_resource_snapshot()
assert "rss_mb" not in snap
assert {"gc_gen0", "gc_gen1", "gc_gen2"} <= snap.keys()
combined = sample_turn_boundary_stats()
assert "rss_mb" not in combined
assert "thread_count" in combined
def test_emit_span_and_thread_boundary_are_free_when_noop() -> None:
assert not is_session_trace_active()
assert emit_span(span_kind="route", name="gather_and_answer", session_id="s") == ""
assert emit_thread_boundary("s", name="turn_boundary", phase="turn_start") == ""
with timed_span(span_kind="component", name="x", session_id="s") as attrs:
attrs["ok"] = True
time.sleep(0)
def test_noop_emit_paths_skip_sampling_and_io(monkeypatch: pytest.MonkeyPatch) -> None:
"""Production default: emit helpers must not touch process stats or sink I/O."""
calls: list[str] = []
def _boom(*_a: Any, **_k: Any) -> Any:
calls.append("sample")
raise AssertionError("process sampling must not run on noop sink")
monkeypatch.setattr(
"platform.observability.trace.spans.sample_turn_boundary_stats",
_boom,
)
assert emit_thread_boundary("s", name="turn_boundary", phase="turn_start") == ""
assert emit_span(span_kind="route", name="x", session_id="s") == ""
with timed_span(span_kind="component", name="y", session_id="s"):
pass
assert calls == []
def test_emit_span_without_session_id_is_noop_even_when_sink_active(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
path = _activate_jsonl_sink(tmp_path, "sess-no-sid", monkeypatch)
assert is_session_trace_active()
assert emit_span(span_kind="route", name="orphan") == ""
assert emit_route("orphan") == ""
with timed_span(span_kind="component", name="orphan"):
pass
assert _trace_spans(path) == []
def test_bind_session_trace_sets_and_clears_context() -> None:
assert current_trace_session_id() is None
with bind_session_trace("sess-bound"):
assert current_trace_session_id() == "sess-bound"
assert current_trace_session_id() is None
with bind_session_trace(None):
assert current_trace_session_id() is None
with bind_session_trace(""):
assert current_trace_session_id() is None
def test_emit_span_writes_route_when_sink_active(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
session_id = "sess-route-test"
path = _activate_jsonl_sink(tmp_path, session_id, monkeypatch)
with bind_session_trace(session_id):
emit_span(
span_kind="route",
name="gather_and_answer",
attributes={"handled": False},
)
with timed_span(span_kind="stage", name="extract_alert") as attrs:
attrs["fields_updated"] = ["alert"]
time.sleep(0.001)
kinds = {(rec["span_kind"], rec["name"]) for rec in _trace_spans(path)}
assert ("route", "gather_and_answer") in kinds
assert ("stage", "extract_alert") in kinds
stage = next(r for r in _trace_spans(path) if r.get("name") == "extract_alert")
assert stage["duration_ms"] >= 0
assert stage["attributes"]["fields_updated"] == ["alert"]
def test_timed_span_honors_status_override(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
from platform.observability.trace.spans import SPAN_STATUS_ATTR, SPAN_STATUS_ERROR
session_id = "sess-status"
path = _activate_jsonl_sink(tmp_path, session_id, monkeypatch)
with timed_span(span_kind="component", name="investigation", session_id=session_id) as attrs:
attrs[SPAN_STATUS_ATTR] = SPAN_STATUS_ERROR
attrs["failure_category"] = "user_cancelled"
rec = _trace_spans(path)[-1]
assert rec["status"] == SPAN_STATUS_ERROR
assert SPAN_STATUS_ATTR not in rec.get("attributes", {})
assert rec["attributes"]["failure_category"] == "user_cancelled"
def test_timed_span_marks_error_on_exception(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
from platform.observability.trace.spans import SPAN_STATUS_ERROR
def _raise_runtime_error() -> None:
raise RuntimeError("boom")
session_id = "sess-exc"
path = _activate_jsonl_sink(tmp_path, session_id, monkeypatch)
# Outer raises so the exception still propagates through timed_span's
# finally (status=error); inner span must not be wrapped by pytest.raises
# or the exception would be swallowed before the span exits.
with (
pytest.raises(RuntimeError, match="boom"),
timed_span(span_kind="component", name="failing", session_id=session_id),
):
_raise_runtime_error()
rec = _trace_spans(path)[-1]
assert rec["name"] == "failing"
assert rec["status"] == SPAN_STATUS_ERROR
assert rec["duration_ms"] >= 0
def test_timed_span_marks_error_on_base_exception(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# KeyboardInterrupt / CancelledError derive from BaseException, not Exception.
from platform.observability.trace.spans import SPAN_STATUS_ERROR
def _raise_keyboard_interrupt() -> None:
raise KeyboardInterrupt
session_id = "sess-cancel"
path = _activate_jsonl_sink(tmp_path, session_id, monkeypatch)
with (
pytest.raises(KeyboardInterrupt),
timed_span(span_kind="tool", name="cancelled", session_id=session_id),
):
_raise_keyboard_interrupt()
rec = _trace_spans(path)[-1]
assert rec["name"] == "cancelled"
assert rec["status"] == SPAN_STATUS_ERROR
def test_timed_span_exception_status_beats_preset_override(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# A pre-set "ok" override must not mask an exception that propagates out.
from platform.observability.trace.spans import (
SPAN_STATUS_ATTR,
SPAN_STATUS_ERROR,
SPAN_STATUS_OK,
)
def _boom() -> None:
raise RuntimeError("boom")
session_id = "sess-override-race"
path = _activate_jsonl_sink(tmp_path, session_id, monkeypatch)
with (
pytest.raises(RuntimeError, match="boom"),
timed_span(span_kind="tool", name="racing", session_id=session_id) as attrs,
):
attrs[SPAN_STATUS_ATTR] = SPAN_STATUS_OK
_boom()
rec = _trace_spans(path)[-1]
assert rec["status"] == SPAN_STATUS_ERROR
def test_stage_span_emits_from_thread_with_copied_context(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# The investigation pipeline runs stages in a worker thread; a copied context
# carries the bound session id across the thread boundary so the span emits.
session_id = "sess-thread"
path = _activate_jsonl_sink(tmp_path, session_id, monkeypatch)
def _emit_stage() -> None:
with stage_span("gather_evidence"):
pass
with bind_session_trace(session_id):
thread = threading.Thread(target=contextvars.copy_context().run, args=(_emit_stage,))
thread.start()
thread.join()
assert "gather_evidence" in {rec["name"] for rec in _trace_spans(path)}
def test_stage_span_drops_in_plain_thread_without_context(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# A plain thread starts with a fresh context, so the bound session id is unseen.
session_id = "sess-thread-plain"
path = _activate_jsonl_sink(tmp_path, session_id, monkeypatch)
def _emit_stage() -> None:
with stage_span("gather_evidence"):
pass
with bind_session_trace(session_id):
thread = threading.Thread(target=_emit_stage)
thread.start()
thread.join()
assert _trace_spans(path) == []
def test_mark_span_outcome_sets_status_and_extra_attrs() -> None:
from platform.observability.trace.spans import SPAN_STATUS_ATTR, SPAN_STATUS_ERROR
attrs: dict[str, Any] = {}
mark_span_outcome(attrs, "ok", source="agent")
assert attrs == {"outcome": "ok", "source": "agent"}
attrs = {}
mark_span_outcome(attrs, "blocked", error=True, reason="deny", ignored=None)
assert attrs["outcome"] == "blocked"
assert attrs[SPAN_STATUS_ATTR] == SPAN_STATUS_ERROR
assert attrs["reason"] == "deny"
assert "ignored" not in attrs
def test_semantic_helpers_match_span_kinds(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
session_id = "sess-helpers"
path = _activate_jsonl_sink(tmp_path, session_id, monkeypatch)
with traced_session(session_id, component="gateway_turn") as attrs:
mark_span_outcome(attrs, "ok")
emit_route("gather_and_answer", attributes={"handled": False})
with component_span("action_turn"):
pass
with stage_span("intake"):
pass
with tool_span("echo", tool_call_id="c1") as tool_attrs:
mark_span_outcome(tool_attrs, "ok", source="agent")
with llm_span("model-x", iteration=1):
pass
spans = _trace_spans(path)
kinds = {(rec["span_kind"], rec["name"]) for rec in spans}
assert ("component", "gateway_turn") in kinds
assert ("route", "gather_and_answer") in kinds
assert ("component", "action_turn") in kinds
assert ("stage", "intake") in kinds
assert ("tool", "echo") in kinds
assert ("llm", "model-x") in kinds
tool = next(r for r in spans if r["span_kind"] == "tool")
assert tool["attributes"]["tool_call_id"] == "c1"
assert tool["attributes"]["outcome"] == "ok"
assert tool["attributes"]["source"] == "agent"
llm = next(r for r in spans if r["span_kind"] == "llm")
assert llm["attributes"]["iteration"] == 1
gateway = next(r for r in spans if r["name"] == "gateway_turn")
assert gateway["attributes"]["outcome"] == "ok"
def test_traced_session_noop_when_session_id_missing() -> None:
assert not is_session_trace_active()
with traced_session(None, component="gateway_turn") as attrs:
attrs["note"] = "still mutable"
assert current_trace_session_id() is None
assert attrs["note"] == "still mutable"
def test_jsonl_trace_sink_writes_trace_span(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
session_id = "sess-thread-test"
path = _activate_jsonl_sink(tmp_path, session_id, monkeypatch)
emit_thread_boundary(session_id, name="turn_boundary", phase="turn_start")
lines = path.read_text(encoding="utf-8").strip().splitlines()
assert len(lines) == 2
rec = json.loads(lines[1])
assert rec["type"] == "trace_span"
assert rec["span_kind"] == "thread"
attrs: dict[str, Any] = rec["attributes"]
assert attrs["phase"] == "turn_start"
assert attrs["thread_count"] >= 1
assert isinstance(attrs["threads"], list)
set_session_trace_sink(NoopSessionTraceSink())
assert isinstance(get_session_trace_sink(), NoopSessionTraceSink)
def test_jsonl_trace_sink_for_session_uses_jsonl_or_noop() -> None:
from core.agent_harness.session import InMemorySessionStorage
from surfaces.interactive_shell.session import Session
jsonl_session = Session(storage=JsonlSessionStorage())
assert isinstance(jsonl_trace_sink_for_session(jsonl_session), JsonlSessionTraceSink)
memory_session = Session(storage=InMemorySessionStorage())
assert isinstance(jsonl_trace_sink_for_session(memory_session), NoopSessionTraceSink)
assert isinstance(jsonl_trace_sink_for_session(object()), NoopSessionTraceSink)
def test_set_session_trace_sink_none_restores_noop() -> None:
class _RecordingSink:
def emit(self, *_a: Any, **_k: Any) -> str:
return "id"
set_session_trace_sink(_RecordingSink()) # type: ignore[arg-type]
assert is_session_trace_active()
set_session_trace_sink(None)
assert not is_session_trace_active()
assert isinstance(get_session_trace_sink(), NoopSessionTraceSink)