Files
chemenu/tools/chemenu/telemetry/schema.py
T
torben 82a22eaa93
CI / verify (push) Successful in 53s
Release / release (push) Successful in 36s
stack: Telemetrie-Default nach Installationsform, Byte-Deckel und Session-Retention (schliesst #55)
Files changed:
- .gitea/workflows/ci.yml
- .gitignore
- CHANGES.md
- EVALS.md
- INSTALL-MCP.md
- INSTALL.md
- VERSION
- instructions/setup-instance.md
- reports/CONTRACT.md
- tools/CONTRACT.md
- tools/chemenu/commands/doctor.py
- tools/chemenu/config.py
- tools/chemenu/mcp/server.py
- tools/chemenu/telemetry/policy.py
- tools/chemenu/telemetry/schema.py
- tools/chemenu/telemetry/writer.py
- tools/chemenu/tests/conftest.py
- tools/chemenu/tests/test_doctor.py
- tools/chemenu/tests/test_mcp_server.py
- tools/chemenu/tests/test_telemetry_emit.py
- tools/chemenu/tests/test_telemetry_policy.py
- tools/chemenu/version.py
2026-09-10 23:38:22 +02:00

175 lines
5.6 KiB
Python

"""The trace event contract: one JSON object per line, one line per event.
Why the schema lives in code rather than under `types/`: `types/` is the page
type system - every spec there describes a `kb/` page, carries a `base_dir`, and
is discovered by `wikitool types list`. A trace event is not a page, so putting
it there would put a non-page into the page catalog.
Ordering. Events are appended by several processes at once (the CLI in one
process, a hook handler in another, one per tool call), so `seq` is a
*per-process* counter and cannot be compared across processes. Sort a trace by
`(ts, pid, seq)`.
Degradation. No consumer may require an event class that some harness cannot
produce - Mistral Vibe has three hooks where Claude Code has thirty. `session.start`
carries a `completeness` list naming the classes its harness can emit, so a scorer
can say "not measurable here" instead of silently scoring zero. The common core
every surface provides is `tool.pre`, `tool.post`, `wikitool.call`, `gate.refused`.
"""
from __future__ import annotations
import os
from datetime import datetime, timezone
SCHEMA_VERSION = 1
# Where an event came from. `runner` is the eval runner itself, which owns the
# session boundaries because not every harness reports them.
SOURCES = frozenset(
{
"wikitool",
"runner",
"claude-code",
"copilot-cli",
"vscode-chat",
"mistral-vibe",
}
)
# The common core, available on every surface. Scorers may depend on these.
CORE_EVENTS = frozenset({"tool.pre", "tool.post", "wikitool.call", "gate.refused"})
# Everything else refines a scorer but may never be a precondition for one.
OPTIONAL_EVENTS = frozenset(
{
"session.start",
"session.end",
"session.error",
"prompt.submitted",
"assistant.message",
"turn.end",
"tool.error",
"instructions.loaded",
"subagent.start",
"subagent.stop",
"compaction",
"page.written",
"publish.commit",
"budget.state",
"gate.cleared",
"telemetry.limit",
}
)
EVENTS = CORE_EVENTS | OPTIONAL_EVENTS
# Which event classes each harness can actually produce, from its documented
# hook surface. Written into `session.start` as `completeness` so a trace is
# self-describing: a missing class means "this harness cannot report it", not
# "the agent never did it".
HARNESS_CAPABILITIES: dict[str, tuple[str, ...]] = {
"claude-code": (
"session.start",
"session.end",
"prompt.submitted",
"tool.pre",
"tool.post",
"tool.error",
"turn.end",
"instructions.loaded",
"subagent.start",
"subagent.stop",
"compaction",
),
"copilot-cli": (
"session.start",
"session.end",
"session.error",
"prompt.submitted",
"tool.pre",
"tool.post",
"tool.error",
"turn.end",
"subagent.start",
"subagent.stop",
"compaction",
),
# Three hooks only: pre_tool, post_tool, post_agent. No session, prompt,
# compaction, permission or subagent lifecycle event exists to hook.
"mistral-vibe": ("tool.pre", "tool.post", "turn.end"),
# Post-hoc import from the chronicle store: turns and touched files, no
# tool-level lifecycle.
"vscode-chat": ("session.start", "prompt.submitted", "assistant.message", "tool.post"),
"wikitool": (
"wikitool.call",
"gate.refused",
"page.written",
"publish.commit",
"budget.state",
"gate.cleared",
),
"runner": ("session.start", "session.end"),
}
def utc_now_iso() -> str:
"""Timestamp with microseconds, so events inside one millisecond still order."""
return datetime.now(timezone.utc).isoformat(timespec="microseconds")
def make_event(
source: str,
event: str,
attrs: dict | None = None,
*,
session_id: str,
seq: int,
run_key: str | None = None,
ts: str | None = None,
trace_id: str | None = None,
span_id: str | None = None,
) -> dict:
record = {
"v": SCHEMA_VERSION,
"ts": ts or utc_now_iso(),
"session_id": session_id,
"pid": os.getpid(),
"seq": seq,
"source": source,
"event": event,
"attrs": attrs or {},
}
if run_key:
record["run_key"] = run_key
if trace_id:
record["trace_id"] = trace_id
if span_id:
record["span_id"] = span_id
return record
def validation_errors(record: dict) -> list[str]:
"""Return a list of contract violations; empty means valid.
Deliberately hand-written rather than jsonschema: this runs on the hot path
of every hook invocation, and the telemetry package must stay stdlib-only so
a hook can run it without the venv.
"""
errors: list[str] = []
for field in ("v", "ts", "session_id", "pid", "seq", "source", "event", "attrs"):
if field not in record:
errors.append(f"missing required field '{field}'")
if record.get("v") != SCHEMA_VERSION:
errors.append(f"unknown schema version {record.get('v')!r}")
if record.get("source") not in SOURCES:
errors.append(f"unknown source {record.get('source')!r}")
if record.get("event") not in EVENTS:
errors.append(f"unknown event {record.get('event')!r}")
if not isinstance(record.get("attrs", {}), dict):
errors.append("'attrs' must be an object")
if not isinstance(record.get("seq"), int):
errors.append("'seq' must be an integer")
if not isinstance(record.get("session_id"), str) or not record.get("session_id"):
errors.append("'session_id' must be a non-empty string")
return errors