161 lines
4.9 KiB
Python
161 lines
4.9 KiB
Python
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from opensquilla.observability.trace import (
|
|
JsonlTraceSink,
|
|
MemoryTraceSink,
|
|
PrivacyGuardSink,
|
|
TraceContext,
|
|
TraceEvent,
|
|
load_trace_events,
|
|
write_trace_event,
|
|
)
|
|
|
|
|
|
def test_trace_context_child_inherits_parent_identity() -> None:
|
|
parent = TraceContext.new(
|
|
trace_id="trace-1",
|
|
session_key="agent:main:test",
|
|
session_id="session-1",
|
|
turn_id="turn-1",
|
|
task_id="task-1",
|
|
agent_id="main",
|
|
)
|
|
|
|
child = parent.child(run_id="child-run", agent_id="child")
|
|
|
|
assert child.trace_id == "trace-1"
|
|
assert child.session_key == "agent:main:test"
|
|
assert child.session_id == "session-1"
|
|
assert child.turn_id == "turn-1"
|
|
assert child.task_id == "task-1"
|
|
assert child.parent_run_id == "task-1"
|
|
assert child.run_id == "child-run"
|
|
assert child.agent_id == "child"
|
|
|
|
|
|
def test_trace_event_serializes_required_contract_fields() -> None:
|
|
context = TraceContext.new(
|
|
trace_id="trace-1",
|
|
session_key="agent:main:test",
|
|
turn_id="turn-1",
|
|
agent_id="main",
|
|
)
|
|
event = TraceEvent(
|
|
kind="turn_start",
|
|
context=context,
|
|
privacy="diagnostic",
|
|
seq=7,
|
|
attrs={"source": "test"},
|
|
payload={"message_hash": "abc123"},
|
|
)
|
|
|
|
payload = event.to_dict()
|
|
|
|
assert payload["schema_version"] == 1
|
|
assert payload["kind"] == "turn_start"
|
|
assert payload["privacy"] == "diagnostic"
|
|
assert payload["trace_id"] == "trace-1"
|
|
assert payload["session_key"] == "agent:main:test"
|
|
assert payload["turn_id"] == "turn-1"
|
|
assert payload["agent_id"] == "main"
|
|
assert payload["seq"] == 7
|
|
assert payload["attrs"] == {"source": "test"}
|
|
assert payload["payload"] == {"message_hash": "abc123"}
|
|
|
|
|
|
def test_privacy_guard_blocks_raw_events_by_default() -> None:
|
|
sink = MemoryTraceSink()
|
|
guarded = PrivacyGuardSink(sink)
|
|
context = TraceContext.new(trace_id="trace-1", session_key="agent:main:test")
|
|
|
|
guarded.write(TraceEvent(kind="turn_start", context=context, privacy="diagnostic"))
|
|
|
|
assert len(sink.events) == 1
|
|
with pytest.raises(ValueError, match="raw trace event"):
|
|
guarded.write(
|
|
TraceEvent(
|
|
kind="llm_request",
|
|
context=context,
|
|
privacy="raw",
|
|
payload={"messages": [{"role": "user", "content": "secret"}]},
|
|
)
|
|
)
|
|
|
|
|
|
def test_memory_trace_sink_filters_by_trace_id() -> None:
|
|
sink = MemoryTraceSink()
|
|
sink.write(TraceEvent(kind="turn_start", context=TraceContext.new(trace_id="trace-a")))
|
|
sink.write(TraceEvent(kind="turn_start", context=TraceContext.new(trace_id="trace-b")))
|
|
|
|
assert [event.trace_id for event in sink.by_trace_id("trace-b")] == ["trace-b"]
|
|
|
|
|
|
def test_jsonl_trace_sink_persists_and_loads_by_trace_id(tmp_path) -> None:
|
|
sink = JsonlTraceSink(log_dir=tmp_path)
|
|
sink.write(
|
|
TraceEvent(
|
|
kind="turn_start",
|
|
context=TraceContext.new(
|
|
trace_id="trace-a",
|
|
session_key="agent:main:test",
|
|
turn_id="turn-1",
|
|
),
|
|
seq=1,
|
|
)
|
|
)
|
|
write_trace_event(
|
|
TraceEvent(
|
|
kind="turn_start",
|
|
context=TraceContext.new(trace_id="trace-b", turn_id="turn-2"),
|
|
seq=1,
|
|
),
|
|
log_dir=tmp_path,
|
|
)
|
|
|
|
[path] = list(tmp_path.glob("traces-*.jsonl"))
|
|
assert path.exists()
|
|
events = load_trace_events("trace-a", log_dir=tmp_path)
|
|
|
|
assert [event.kind for event in events] == ["turn_start"]
|
|
assert events[0].trace_id == "trace-a"
|
|
assert events[0].context.session_key == "agent:main:test"
|
|
assert events[0].context.turn_id == "turn-1"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("kwargs", "message"),
|
|
[
|
|
({"trace_id": ""}, "trace_id must be non-empty"),
|
|
({"trace_id": "trace-1", "session_key": " "}, "session_key must be non-empty"),
|
|
],
|
|
)
|
|
def test_trace_context_rejects_invalid_identity(kwargs: dict[str, str], message: str) -> None:
|
|
with pytest.raises(ValueError, match=message):
|
|
TraceContext.new(**kwargs)
|
|
|
|
|
|
def test_trace_context_child_rejects_blank_parent_override() -> None:
|
|
parent = TraceContext.new(trace_id="trace-1", turn_id="turn-1")
|
|
|
|
with pytest.raises(ValueError, match="parent_run_id must be non-empty"):
|
|
parent.child(parent_run_id="")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("kwargs", "message"),
|
|
[
|
|
({"kind": ""}, "kind must be non-empty"),
|
|
({"schema_version": 99}, "unsupported trace schema_version"),
|
|
({"privacy": "unsafe"}, "invalid trace privacy"),
|
|
],
|
|
)
|
|
def test_trace_event_rejects_invalid_contract(kwargs: dict[str, object], message: str) -> None:
|
|
context = TraceContext.new(trace_id="trace-1")
|
|
base = {"kind": "turn_start", "context": context}
|
|
base.update(kwargs)
|
|
|
|
with pytest.raises(ValueError, match=message):
|
|
TraceEvent(**base) # type: ignore[arg-type]
|