e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
154 lines
4.7 KiB
Python
154 lines
4.7 KiB
Python
"""L1 raw event trace: append-only JSONL files, one per surface per UTC day.
|
|
|
|
Trace capture must never break the producing surface — every append is
|
|
wrapped and failures are logged-and-swallowed. Writes are serialized
|
|
per-surface with an asyncio lock so multiple turns in the same process
|
|
don't interleave JSON lines.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from dataclasses import asdict, dataclass
|
|
from datetime import datetime, timezone
|
|
import json
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import Any, Iterator
|
|
|
|
from deeptutor.services.memory.ids import new_trace_id
|
|
from deeptutor.services.memory.paths import SURFACES, Surface, trace_dir, trace_file
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_locks: dict[str, asyncio.Lock] = {}
|
|
|
|
|
|
def _lock_for(surface: Surface) -> asyncio.Lock:
|
|
lock = _locks.get(surface)
|
|
if lock is None:
|
|
lock = asyncio.Lock()
|
|
_locks[surface] = lock
|
|
return lock
|
|
|
|
|
|
@dataclass
|
|
class TraceEvent:
|
|
id: str
|
|
ts: str
|
|
surface: Surface
|
|
kind: str
|
|
payload: dict[str, Any]
|
|
session_id: str | None = None
|
|
turn_id: str | None = None
|
|
|
|
@classmethod
|
|
def new(
|
|
cls,
|
|
surface: Surface,
|
|
kind: str,
|
|
payload: dict[str, Any],
|
|
*,
|
|
session_id: str | None = None,
|
|
turn_id: str | None = None,
|
|
) -> "TraceEvent":
|
|
return cls(
|
|
id=new_trace_id(surface),
|
|
ts=datetime.now(tz=timezone.utc).isoformat(),
|
|
surface=surface,
|
|
kind=kind,
|
|
payload=payload,
|
|
session_id=session_id,
|
|
turn_id=turn_id,
|
|
)
|
|
|
|
|
|
async def append(event: TraceEvent) -> None:
|
|
"""Append one event to today's surface trace file. Never raises."""
|
|
try:
|
|
path = trace_file(event.surface, datetime.now(tz=timezone.utc).date())
|
|
line = json.dumps(asdict(event), ensure_ascii=False, separators=(",", ":"))
|
|
async with _lock_for(event.surface):
|
|
await asyncio.to_thread(_append_line, path, line)
|
|
except Exception:
|
|
logger.warning(
|
|
"memory trace append failed surface=%s kind=%s",
|
|
event.surface,
|
|
event.kind,
|
|
exc_info=True,
|
|
)
|
|
|
|
|
|
def _append_line(path: Path, line: str) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
with path.open("a", encoding="utf-8") as fh:
|
|
fh.write(line)
|
|
fh.write("\n")
|
|
|
|
|
|
def iter_since(surface: Surface, since: datetime | None = None) -> Iterator[TraceEvent]:
|
|
"""Yield events for ``surface`` in chronological order, optionally
|
|
filtering to events with ``ts >= since`` (UTC)."""
|
|
files = sorted(trace_dir(surface).glob("*.jsonl"))
|
|
cutoff_iso = since.isoformat() if since else ""
|
|
cutoff_date_iso = since.date().isoformat() if since else ""
|
|
for path in files:
|
|
if cutoff_date_iso and path.stem < cutoff_date_iso:
|
|
continue
|
|
try:
|
|
with path.open("r", encoding="utf-8") as fh:
|
|
for raw in fh:
|
|
raw = raw.strip()
|
|
if not raw:
|
|
continue
|
|
try:
|
|
obj = json.loads(raw)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
if cutoff_iso and obj.get("ts", "") < cutoff_iso:
|
|
continue
|
|
yield TraceEvent(**obj)
|
|
except OSError:
|
|
continue
|
|
|
|
|
|
def iter_by_ids(ids: list[str]) -> Iterator[TraceEvent]:
|
|
"""Resolve trace ids back to their events. Cross-surface walk."""
|
|
wanted_by_surface: dict[str, set[str]] = {}
|
|
for tid in ids:
|
|
if ":" not in tid:
|
|
continue
|
|
surface, _ = tid.split(":", 1)
|
|
if surface in SURFACES:
|
|
wanted_by_surface.setdefault(surface, set()).add(tid)
|
|
|
|
for surface, wanted in wanted_by_surface.items():
|
|
for event in iter_since(surface): # type: ignore[arg-type]
|
|
if event.id in wanted:
|
|
yield event
|
|
|
|
|
|
def count_since(surface: Surface, since: datetime | None = None) -> int:
|
|
return sum(1 for _ in iter_since(surface, since))
|
|
|
|
|
|
def latest_ts(surface: Surface) -> str | None:
|
|
"""Most recent event timestamp for ``surface``, or None."""
|
|
files = sorted(trace_dir(surface).glob("*.jsonl"), reverse=True)
|
|
for path in files:
|
|
try:
|
|
last = ""
|
|
with path.open("r", encoding="utf-8") as fh:
|
|
for raw in fh:
|
|
raw = raw.strip()
|
|
if raw:
|
|
last = raw
|
|
if last:
|
|
obj = json.loads(last)
|
|
ts = obj.get("ts")
|
|
if isinstance(ts, str):
|
|
return ts
|
|
except (OSError, json.JSONDecodeError):
|
|
continue
|
|
return None
|