Chemenu 2.1.0 - deterministischer Wissenskompiler
Chemenu kompiliert Rohnotizen zu einem verlinkten, quellengebundenen Wiki: raw/ -> types/ + tools/ -> kb/ -> reports/. Was mechanisch ist, macht tools/wikitool; was Urteil braucht, macht ein Agent unter Contracts, deren Grenzen in Code durchgesetzt sind statt im Prompt. Dieser Commit ist der Startpunkt der oeffentlichen Historie. Die vorherige Entwicklung fand in einer privaten Instanz statt und ist nicht Teil dieses Repositorys; ihre Erzaehlung steht vollstaendig in CHANGES.md, das mit 44 Eintraegen von 0.1.0 bis 2.1.0 erhalten geblieben ist. Der mitgelieferte Korpus ist ein Testbett und eine Demo: 170 Seiten ueber den Stack selbst - Gates, Lint, Versionierung, Suche, das Wiki-Muster. Er dokumentiert das Werkzeug mit den eigenen Mitteln des Werkzeugs. Lizenz: AGPL-3.0 fuer den Stack (tools/, types/), CC-BY-4.0 fuer die Inhalte. Die Grenze zwischen beiden ist der Dateiplan, den dist export berechnet - siehe NOTICE.
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
"""Trace telemetry: the harness-independent record of what a session did.
|
||||
|
||||
`schema` is the event contract, `scrub` the redaction applied to every event,
|
||||
`writer` the append path. Stdlib only, deliberately: a hook handler imports this
|
||||
package on every tool call and must not need the venv or a schema library.
|
||||
|
||||
The module is `writer` rather than `emit` so that the exported `emit()` function
|
||||
does not shadow it - `from chemenu.telemetry import emit` should hand a caller
|
||||
the function it is going to call.
|
||||
"""
|
||||
from chemenu.telemetry.schema import (
|
||||
CORE_EVENTS,
|
||||
EVENTS,
|
||||
HARNESS_CAPABILITIES,
|
||||
SCHEMA_VERSION,
|
||||
SOURCES,
|
||||
)
|
||||
from chemenu.telemetry.writer import emit, enabled, trace_path, trace_root, write_event
|
||||
|
||||
__all__ = [
|
||||
"emit",
|
||||
"enabled",
|
||||
"trace_path",
|
||||
"trace_root",
|
||||
"write_event",
|
||||
"CORE_EVENTS",
|
||||
"EVENTS",
|
||||
"HARNESS_CAPABILITIES",
|
||||
"SCHEMA_VERSION",
|
||||
"SOURCES",
|
||||
]
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Reading a trace back.
|
||||
|
||||
The format's owner owns the reader: a consumer that re-derived the sort order or
|
||||
the session-directory rule would drift from the writer the first time either
|
||||
changed.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from chemenu.telemetry.writer import trace_path
|
||||
|
||||
|
||||
def sort_key(record: dict) -> tuple:
|
||||
"""`seq` counts within one process only - a trace is written by the CLI in
|
||||
one process and by a hook handler in another, so it orders by time first."""
|
||||
return (record.get("ts", ""), record.get("pid", 0), record.get("seq", 0))
|
||||
|
||||
|
||||
def read_trace(session: str | None = None, path: Path | None = None) -> list[dict]:
|
||||
"""Return one session's events in order. A missing trace is an empty one:
|
||||
a session that never recorded anything is a normal state, not an error."""
|
||||
target = path or trace_path(session)
|
||||
if not target.exists():
|
||||
return []
|
||||
records = []
|
||||
for line in target.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
record = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
# A torn last line can only happen if a writer died mid-append.
|
||||
# Losing it is better than refusing to read the rest.
|
||||
continue
|
||||
if isinstance(record, dict):
|
||||
records.append(record)
|
||||
records.sort(key=sort_key)
|
||||
return records
|
||||
|
||||
|
||||
def sessions(root: Path | None = None) -> list[str]:
|
||||
"""Every session that has a trace, most recently modified first."""
|
||||
from chemenu.telemetry.writer import trace_root
|
||||
|
||||
base = root or trace_root()
|
||||
if not base.exists():
|
||||
return []
|
||||
traces = [p for p in base.glob("*/trace.jsonl") if p.is_file()]
|
||||
traces.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
return [p.parent.name for p in traces]
|
||||
|
||||
|
||||
def completeness(records: list[dict]) -> list[str]:
|
||||
"""What the harnesses behind this trace said they could report.
|
||||
|
||||
The union across sources, because a session traced by both `wikitool` and a
|
||||
hook adapter can report what either of them can.
|
||||
"""
|
||||
seen: list[str] = []
|
||||
for record in records:
|
||||
if record.get("event") != "session.start":
|
||||
continue
|
||||
for item in record.get("attrs", {}).get("completeness", []):
|
||||
if item not in seen:
|
||||
seen.append(item)
|
||||
return seen
|
||||
@@ -0,0 +1,173 @@
|
||||
"""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",
|
||||
}
|
||||
)
|
||||
|
||||
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
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Redaction applied to every trace event, in one place: the emitter.
|
||||
|
||||
The repo records prompts and assistant replies in cleartext, because a failure
|
||||
taxonomy cannot be read out of hashes - that is the whole point of the
|
||||
comprehension phase. Cleartext is only defensible with three guards, all of them
|
||||
here rather than at each call site:
|
||||
|
||||
1. **Secret scrubbing.** Pattern-based, best effort, never a substitute for
|
||||
discipline - `reports/` is gitignored and a secret scan runs in verification.
|
||||
2. **A content cap**, so one 5 MB tool result cannot dominate a trace.
|
||||
3. **A kill switch.** `WIKI_TRACE_CONTENT=0` drops model-facing text and keeps
|
||||
only its length and SHA-256.
|
||||
|
||||
`raw/` file *contents* never reach a trace at all, whatever these settings say:
|
||||
that text is data, not instruction (AGENTS.md invariant 4), and a trace is read
|
||||
back later. Callers record a path plus a digest instead.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
|
||||
# Sized like Claude Code's own OTel content limit (60 KiB), which is in turn
|
||||
# sized for backends that cap an attribute at 64 KiB.
|
||||
DEFAULT_MAX_CONTENT = 61440
|
||||
|
||||
# Attribute names holding model-facing text. Only these obey the kill switch;
|
||||
# paths, tool names and exit codes stay readable either way.
|
||||
CONTENT_KEYS = frozenset(
|
||||
{"prompt", "response", "message", "text", "tool_input", "tool_output", "error"}
|
||||
)
|
||||
|
||||
|
||||
def _mask(name: str) -> str:
|
||||
return f"[REDACTED:{name}]"
|
||||
|
||||
|
||||
def _keep_key(name: str):
|
||||
"""Replace the value of a `key: value` pair, keep the key readable."""
|
||||
|
||||
def repl(match: re.Match) -> str:
|
||||
return f"{match.group(1)}{match.group(2)}{_mask(name)}"
|
||||
|
||||
return repl
|
||||
|
||||
|
||||
# Order matters: the most specific pattern must match before a generic one can
|
||||
# swallow part of it.
|
||||
SECRET_PATTERNS: list[tuple[str, re.Pattern, object]] = [
|
||||
(
|
||||
"private-key",
|
||||
re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----"),
|
||||
None,
|
||||
),
|
||||
("github-token", re.compile(r"\bgh[pousr]_[A-Za-z0-9]{16,}\b"), None),
|
||||
("github-pat", re.compile(r"\bgithub_pat_[A-Za-z0-9_]{20,}\b"), None),
|
||||
("aws-access-key", re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b"), None),
|
||||
("slack-token", re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b"), None),
|
||||
("anthropic-key", re.compile(r"\bsk-ant-[A-Za-z0-9_-]{20,}\b"), None),
|
||||
("openai-key", re.compile(r"\bsk-[A-Za-z0-9]{20,}\b"), None),
|
||||
("google-api-key", re.compile(r"\bAIza[0-9A-Za-z_-]{35}\b"), None),
|
||||
("onepassword-token", re.compile(r"\bops_[A-Za-z0-9+/=_-]{40,}\b"), None),
|
||||
(
|
||||
"jwt",
|
||||
re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b"),
|
||||
None,
|
||||
),
|
||||
(
|
||||
# Stops at a quote, comma or whitespace after the credential: a tool
|
||||
# input is usually one line of JSON, and eating to end-of-line would
|
||||
# take the rest of the payload with the secret.
|
||||
"auth-header",
|
||||
re.compile(
|
||||
r"""(?ix)
|
||||
\b(authorization|x-api-key|proxy-authorization)
|
||||
(\s*[:=]\s*)
|
||||
(?:bearer|basic|token)?\s*
|
||||
[^\s"',;\\]+
|
||||
"""
|
||||
),
|
||||
"keep-key",
|
||||
),
|
||||
(
|
||||
"secret-assignment",
|
||||
re.compile(
|
||||
r"""(?ix)
|
||||
\b([A-Za-z0-9_.-]*
|
||||
(?:api[_-]?key|secret|token|password|passwd|credential)
|
||||
[A-Za-z0-9_.-]*)
|
||||
(\s*[:=]\s*)
|
||||
(["']?[^\s"',;]{8,}["']?)
|
||||
"""
|
||||
),
|
||||
"keep-key",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def max_content() -> int:
|
||||
raw = os.environ.get("WIKI_TRACE_MAX_CONTENT")
|
||||
if raw and raw.isdigit() and int(raw) > 0:
|
||||
return int(raw)
|
||||
return DEFAULT_MAX_CONTENT
|
||||
|
||||
|
||||
def content_enabled() -> bool:
|
||||
"""Cleartext prompts/responses on by default; `WIKI_TRACE_CONTENT=0` opts out."""
|
||||
return os.environ.get("WIKI_TRACE_CONTENT", "1") != "0"
|
||||
|
||||
|
||||
def sha256_text(text: str) -> str:
|
||||
return hashlib.sha256(text.encode("utf-8", "replace")).hexdigest()
|
||||
|
||||
|
||||
def scrub_text(text: str) -> tuple[str, list[str]]:
|
||||
"""Replace known secret shapes. Returns the text and the pattern names hit."""
|
||||
hits: list[str] = []
|
||||
for name, pattern, mode in SECRET_PATTERNS:
|
||||
repl = _keep_key(name) if mode == "keep-key" else _mask(name)
|
||||
text, count = pattern.subn(repl, text)
|
||||
if count:
|
||||
hits.append(name)
|
||||
return text, hits
|
||||
|
||||
|
||||
def cap_text(text: str, limit: int | None = None) -> str:
|
||||
limit = limit or max_content()
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
dropped = len(text) - limit
|
||||
return text[:limit] + f"... [TRUNCATED {dropped} chars]"
|
||||
|
||||
|
||||
def _clean_string(value: str, hits: list[str]) -> str:
|
||||
scrubbed, found = scrub_text(value)
|
||||
hits.extend(h for h in found if h not in hits)
|
||||
return cap_text(scrubbed)
|
||||
|
||||
|
||||
def scrub_attrs(attrs: dict) -> tuple[dict, list[str]]:
|
||||
"""Walk an attribute tree: scrub every string, cap every string, and apply
|
||||
the content kill switch to the keys that hold model-facing text.
|
||||
|
||||
Content keys always gain `<key>_length` and `<key>_sha256` siblings, so two
|
||||
traces recorded under different settings stay comparable.
|
||||
"""
|
||||
hits: list[str] = []
|
||||
keep_content = content_enabled()
|
||||
|
||||
def walk(value):
|
||||
if isinstance(value, dict):
|
||||
out = {}
|
||||
for key, item in value.items():
|
||||
if key in CONTENT_KEYS and isinstance(item, str):
|
||||
out[f"{key}_length"] = len(item)
|
||||
out[f"{key}_sha256"] = sha256_text(item)
|
||||
if keep_content:
|
||||
out[key] = _clean_string(item, hits)
|
||||
else:
|
||||
out[key] = walk(item)
|
||||
return out
|
||||
if isinstance(value, list):
|
||||
return [walk(item) for item in value]
|
||||
if isinstance(value, str):
|
||||
return _clean_string(value, hits)
|
||||
return value
|
||||
|
||||
return walk(attrs), hits
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Appends trace events to `reports/telemetry/<session>/trace.jsonl`.
|
||||
|
||||
Two rules govern this module.
|
||||
|
||||
**Telemetry never breaks the tool.** `emit()` swallows everything: a full disk,
|
||||
a permission error or a bug in a scrubber pattern must not turn a working
|
||||
`wikitool` command - or a hook wrapped around someone's tool call - into a
|
||||
failure. Tests call `write_event()` instead, which raises.
|
||||
|
||||
**Append, do not rewrite.** The budget state is a whole-file document and is
|
||||
written with the temp-file + `os.replace` dance. A trace is append-only, so the
|
||||
equivalent guarantee is `O_APPEND` plus an exclusive lock: several processes
|
||||
write to one trace at once (the CLI in one, a hook handler per tool call in
|
||||
another), and a line must never land inside another line.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from chemenu import config
|
||||
from chemenu.session import session_id as current_session_id
|
||||
from chemenu.session import session_slug
|
||||
from chemenu.telemetry import schema, scrub
|
||||
|
||||
TRACE_ROOT = config.REPORTS_DIR / "telemetry"
|
||||
TRACE_FILE = "trace.jsonl"
|
||||
|
||||
# Per-process counter. Not comparable across processes - see schema.py on how
|
||||
# to order a trace.
|
||||
_seq = 0
|
||||
|
||||
|
||||
def enabled() -> bool:
|
||||
return os.environ.get("WIKI_TRACE", "1") != "0"
|
||||
|
||||
|
||||
def trace_root() -> Path:
|
||||
"""`WIKI_TRACE_DIR` redirects the whole tree.
|
||||
|
||||
The eval runner gives each run its own directory the same way it gives each
|
||||
run its own `VIBE_HOME`/`COPILOT_HOME`, so two runs cannot write into one
|
||||
another's trace.
|
||||
"""
|
||||
override = os.environ.get("WIKI_TRACE_DIR")
|
||||
return Path(override) if override else TRACE_ROOT
|
||||
|
||||
|
||||
def trace_path(session: str | None = None) -> Path:
|
||||
return trace_root() / session_slug(session) / TRACE_FILE
|
||||
|
||||
|
||||
def _next_seq() -> int:
|
||||
global _seq
|
||||
_seq += 1
|
||||
return _seq
|
||||
|
||||
|
||||
def _traceparent() -> tuple[str | None, str | None]:
|
||||
"""Read W3C trace context if the harness exported it.
|
||||
|
||||
Claude Code sets `TRACEPARENT` on the subprocesses it spawns while tracing
|
||||
is active, so a `wikitool` call made from its Bash tool can record which
|
||||
span it ran under. No other harness documents this today; the fields simply
|
||||
stay absent there.
|
||||
"""
|
||||
raw = os.environ.get("TRACEPARENT", "")
|
||||
parts = raw.split("-")
|
||||
if len(parts) >= 4 and parts[0] == "00":
|
||||
return parts[1], parts[2]
|
||||
return None, None
|
||||
|
||||
|
||||
def write_event(
|
||||
source: str,
|
||||
event: str,
|
||||
attrs: dict | None = None,
|
||||
*,
|
||||
session: str | None = None,
|
||||
run_key: str | None = None,
|
||||
path: Path | None = None,
|
||||
ts: str | None = None,
|
||||
) -> dict:
|
||||
"""Build, scrub, validate and append one event. Raises on a contract breach.
|
||||
|
||||
`ts` exists for post-hoc imports: a session reconstructed from a harness's
|
||||
own store has to keep that store's timestamps, or it would sort as if it had
|
||||
happened at import time.
|
||||
"""
|
||||
session = session or current_session_id()
|
||||
trace_id, span_id = _traceparent()
|
||||
clean_attrs, redactions = scrub.scrub_attrs(attrs or {})
|
||||
|
||||
# Before the record is built, so the header's `seq` stays lower than the
|
||||
# event it precedes in the file.
|
||||
target = path or trace_path(session)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if event != "session.start":
|
||||
_seed_session_header(target, source, session)
|
||||
|
||||
record = schema.make_event(
|
||||
source,
|
||||
event,
|
||||
clean_attrs,
|
||||
session_id=session,
|
||||
seq=_next_seq(),
|
||||
run_key=run_key or os.environ.get("WIKITOOL_RUN_KEY") or None,
|
||||
ts=ts,
|
||||
trace_id=trace_id,
|
||||
span_id=span_id,
|
||||
)
|
||||
if redactions:
|
||||
record["redactions"] = redactions
|
||||
|
||||
errors = schema.validation_errors(record)
|
||||
if errors:
|
||||
raise ValueError(f"invalid trace event: {'; '.join(errors)}")
|
||||
|
||||
line = json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n"
|
||||
with open(target, "a", encoding="utf-8") as handle:
|
||||
_locked_write(handle, line)
|
||||
return record
|
||||
|
||||
|
||||
def _seed_session_header(target: Path, source: str, session: str) -> None:
|
||||
"""Open a new trace with a `session.start` naming what this source can report.
|
||||
|
||||
Without it, a trace from a harness that has no session hook - Mistral Vibe
|
||||
has three hooks and none of them is one - would carry no `completeness` at
|
||||
all, and a scorer could not tell "never happened" from "not observable".
|
||||
|
||||
The `x` mode elects a single writer: several processes append to one trace,
|
||||
and an `exists()` check would let two of them both write the header.
|
||||
"""
|
||||
try:
|
||||
handle = open(target, "x", encoding="utf-8")
|
||||
except FileExistsError:
|
||||
return
|
||||
with handle:
|
||||
header = schema.make_event(
|
||||
source,
|
||||
"session.start",
|
||||
{
|
||||
"harness": source,
|
||||
"completeness": list(schema.HARNESS_CAPABILITIES.get(source, ())),
|
||||
"synthesized": True,
|
||||
},
|
||||
session_id=session,
|
||||
seq=_next_seq(),
|
||||
)
|
||||
handle.write(json.dumps(header, ensure_ascii=False, separators=(",", ":")) + "\n")
|
||||
|
||||
|
||||
def _locked_write(handle, line: str) -> None:
|
||||
try:
|
||||
import fcntl
|
||||
except ImportError: # pragma: no cover - non-POSIX platform
|
||||
handle.write(line)
|
||||
handle.flush()
|
||||
return
|
||||
fcntl.flock(handle, fcntl.LOCK_EX)
|
||||
try:
|
||||
handle.write(line)
|
||||
handle.flush()
|
||||
finally:
|
||||
fcntl.flock(handle, fcntl.LOCK_UN)
|
||||
|
||||
|
||||
def emit(
|
||||
source: str,
|
||||
event: str,
|
||||
attrs: dict | None = None,
|
||||
*,
|
||||
session: str | None = None,
|
||||
run_key: str | None = None,
|
||||
) -> None:
|
||||
"""Fire-and-forget. Records nothing and reports nothing if anything goes wrong."""
|
||||
if not enabled():
|
||||
return
|
||||
try:
|
||||
write_event(source, event, attrs, session=session, run_key=run_key)
|
||||
except Exception: # noqa: BLE001 - telemetry must never break the caller
|
||||
pass
|
||||
Reference in New Issue
Block a user