Files
tai/logging.md
T

235 lines
12 KiB
Markdown

# logging.md
Extensive debug logging for tai, behind `--debug`. One `DebugLog`
owns three outputs: a sink (file, stdout, stderr), the vault `logs`
table, and nothing else. `LoggingDocTests` fails when this file
drifts from the code.
## Flags and sinks
`--debug` takes an optional sink: `--debug` alone means
`--debug file`, `--debug stdout` and `--debug stderr` redirect the
stream. `--debug-format` is `sentence` (default) or `json`.
`TAI_DEBUG` and `TAI_DEBUG_FORMAT` are env fallbacks when the flags
are absent. Without either flag or env var, logging is fully off:
no file, no rows, no overhead beyond one boolean check per hook.
The file sink is `<TAI_HOME>/debug.log`, one line per event,
flushed on every write so a crash loses nothing. File mode also
writes every event to the `logs` table when a store exists; modes
without a store (demo, mplex client, grr client) are file-only.
stdout/stderr sinks still write the table too: the sink only moves
the human stream.
## Wipe rule
The log file is wiped on the first write of a root process, and a
process is root when `TAI_CHILD` is unset. A child tai (currently
the `CapAnswerer` research spawn, which sets `TAI_CHILD=1`) appends
to whatever is there and never wipes. In-process workers (fork,
scheduler) share the open handle, so they neither wipe nor reopen.
Wipe-on-first-write, not wipe-on-boot, so `--version` and other
instant exits never destroy the previous log. Two concurrent roots
on one home are last-wipe-wins, documented, not prevented.
## Formats
Both formats carry the same fields: `ts` (UTC ISO), `level`,
`run` (per-process hex id, correlates everything one boot wrote),
`profile`, `bot`, `chat` (telegram chat id, else empty), then the
dotted event name, the message, and the meta map.
Sentence (default), free text last so the head splits cleanly:
```text
2026-10-18T12:01:02Z INFO run=ab12cd34 profile=work bot=main chat= component.event k=v k="quoted value" :: message
```
Meta values containing whitespace or quotes are double-quoted with
backslash escapes; everything before ` :: ` is whitespace-split
`key=value`, nothing else. JSON is one object per line with sorted
keys:
```json
{"bot": "main", "chat": "", "component": "agent", "event": "turn_end", "level": "INFO", "message": "turn finished", "meta": {"exec_ms": 12340, "steps": 4}, "profile": "work", "run": "ab12cd34", "ts": "2026-10-18T12:01:02Z"}
```
## Vault table
```sql
logs(id INTEGER PRIMARY KEY, profile, bot, chat, ts, run, level, component, event, message, meta_json)
```
Every filter column is indexed: `idx_logs_profile(profile, id)`,
`idx_logs_bot(bot)`, `idx_logs_chat(chat)`, `idx_logs_level(level)`,
`idx_logs_event(component, event)`, `idx_logs_run(run)`. An FTS
trigger indexes each row under `kind='log'` (`trg_logs_ai`, and
`trg_logs_ad` removes pruned rows), so `/search` and `fts_search`
rank log lines like any other vault text. `message` and `meta_json`
are sealed with `tai_enc` whenever the vault seal is enabled, and
unsealed only through `Store.log_query`, which takes the same
filters as the indexes plus `limit` and returns newest first.
The logger opens its own SQLite connection (WAL, 30s busy timeout,
one lock for all threads) instead of sharing `Store.db`, commits every row (so the logger
never holds a write transaction that could stall the main store
connection, and readers never lag), and prunes to `LOG_DB_KEEP`
newest rows every `LOG_DB_PRUNE_EVERY` inserts plus once per
configure. Logging never raises: a failed write is counted in
`dropped` and skipped.
## Caps
| Cap | Value | Meaning |
|---|---|---|
| `LOG_DB_KEEP` | 20000 | newest log rows kept per vault; older pruned with their FTS rows |
| `LOG_DB_PRUNE_EVERY` | 256 | inserts between prune checks |
| `LOG_MSG_MAX` | 2000 | message truncation, chars |
| `LOG_TEXT_MAX` | 4000 | each trapped text (system/user/assistant) in DB meta, chars |
| `LOG_PREVIEW` | 300 | each trapped text in the file sink, chars |
| `LOG_META_MAX` | 16384 | `meta_json` cap, bytes; oversized metas shrink longest strings first, so the stored JSON stays valid |
## The rules
R1. One event per interesting action, at exactly one site. No
action logs twice: `Tools.dispatch` covers all 61 tools, so no
`run_*` method logs; the bridge, the scheduler watcher, and the
serve dispatcher each log once for their layer.
R2. Level follows consequence, not volume. `INFO` for actions
taken, `WARNING` for retries and refused auth, `ERROR` for failed
operations and uncaught exceptions, `DEBUG` for durability
mutations (record/secret/schedule writes). There is no TRACE:
anything worth trapping is worth INFO.
R3. Every event carries the execution context it ran in: profile,
bot, chat (telegram only, else empty), and the process run id.
Callers pass what they know; the logger merges its sticky
defaults under explicit arguments.
R4. LLM completions trap the full accounting tuple: backend,
model, `exec_ms`, `tokens_in`, `tokens_out`, `tokens_total`,
`cost_usd`, message char counts, the system, user, and assistant
texts (full to `LOG_TEXT_MAX` in the DB, previewed to
`LOG_PREVIEW` in the file), and the tool census: how many schemas
were actually sent, split into MCP, core, and lazy-native, plus
the sorted name list. Tokens are estimated at 4 chars each unless
a backend reports real usage (none do today); `cost_usd` is null
until a backend reports cost.
R5. Timed operations always log `exec_ms` as an integer. The
measurement wraps the operation, never the logging.
R6. Secret values, passphrases, keys, tokens, and header values
never reach a log. Names are fine (`secret_saved name=wifi`).
Every trapped text passes through `Store.redact` for its profile
before truncation, so a known value lands as `[redacted:NAME]`;
tool arguments whose key names a secret (`LOG_MASKED_ARGS`) are
masked to `***` before logging, which also covers values that are
not stored yet and therefore unknown to `redact`.
R7. Errors log where they are decided, with `short_error` text:
backend failure at the raise site, tool failure at dispatch, auth
refusal at the check, uncaught exceptions via `sys.excepthook`.
A catch that only re-raises or translates never logs.
R8. Hot paths never log: TUI render, input, and compositor paths,
token estimators, `redact` itself, FTS internals, health records,
and everything inside `DebugLog`. A hook in a per-keystroke or
per-frame path is a bug, not coverage.
R9. The logger never raises, never blocks on the network, never
touches a tool, and never calls back into logged code. A logging
failure is a dropped event, counted, not an error event.
R10. Child processes append, roots wipe (see Wipe rule). Log volume
is bounded by the caps above, never by sampling: pruning deletes
oldest first, deterministically.
R11. Sentence format keeps free text after ` :: ` and quotes any
meta value containing whitespace. JSON format sorts keys. Both are
line-delimited and greppable without a parser.
R12. New events extend the registry below with a fixed level, a
single site, and a fixed meta vocabulary (`exec_ms`, `tokens_in`,
`tokens_out`, `tokens_total`, `cost_usd`, `outcome`, `error`,
`steps`, `tools_sent`, `tools_mcp`, `tools_core`, `tools_lazy`,
`tool_names`, `*_chars`, `*_text`). A schema counts as MCP when
its name is not a native `TOOL_SCHEMAS` name. Synonyms are
rejected in review.
## Event registry
| Event | Level | Site | Meta |
|---|---|---|---|
| `app.boot` | INFO | `boot()` end | `version`, `home`, `mode`, `profile` |
| `app.exception` | ERROR | `sys.excepthook` | `error` |
| `agent.turn_start` | INFO | `run_turn` wrapper | `goal`, `capture`, `max_steps`, `depth`, `routed` |
| `agent.turn_end` | INFO | `run_turn` wrapper | `exec_ms`, `steps`, `tools_used`, `outcome`, `reply_chars` |
| `agent.verified` | INFO | `ensure_verified` end | `success`, `failures` |
| `agent.compact` | INFO | `compact_messages` end | `before_tokens`, `after_tokens`, `kept_turns` |
| `llm.completion` | INFO | `try_candidate` success | `backend`, `model`, `exec_ms`, `tokens_in`, `tokens_out`, `tokens_total`, `cost_usd`, `history_msgs`, `system_chars`, `user_chars`, `assistant_chars`, `system_text`, `user_text`, `assistant_text`, `tool_calls`, `retried_without_tools`, `tools_sent`, `tools_mcp`, `tools_core`, `tools_lazy`, `tool_names` |
| `llm.failed` | WARNING | `try_candidate` failure | `backend`, `model`, `error`, `transient` |
| `llm.round_retry` | WARNING | `complete()` before sleep | `round`, `pause_s`, `candidates`, `errors` |
| `llm.fatal` | ERROR | `complete()` wrapper on `BackendError` | `error` |
| `tool.call` | INFO | `Tools.dispatch` | `name`, `args`, `exec_ms`, `outcome`, `error`, `result_chars` |
| `approval.decision` | INFO | `ask_approval` wrapper | `command`, `decision`, `mode` |
| `vault.record_saved` | DEBUG | `add_record` end | `id`, `kind`, `size` |
| `vault.record_deleted` | DEBUG | `delete_record` end | `id` |
| `vault.secret_saved` | DEBUG | `save_secret` end | `name` |
| `vault.secret_deleted` | DEBUG | `delete_secret` end | `name` |
| `vault.schedule_saved` | DEBUG | `add_schedule` end | `id`, `name` |
| `vault.schedule_removed` | DEBUG | `remove_schedule` end | `id` |
| `vault.seal_rotated` | INFO | `rotate_seal` end | `home` |
| `vault.schema_upgraded` | INFO | `boot()` after print | `notes` |
| `sched.fired` | INFO | `scheduler_tick` after spawn | `id`, `profile`, `every_sec` |
| `sched.finished` | INFO | `watch_schedule` end | `id`, `status`, `result_chars` |
| `serve.request` | INFO | `dispatch_message` | `method`, `tool`, `session`, `exec_ms`, `outcome` |
| `serve.denied` | WARNING | `do_POST` auth check | `path` |
| `cap.run_start` | INFO | `cap_run_main` head | `sessions` |
| `cap.turn` | INFO | `CapFleet.ask` | `target`, `payload_chars`, `exec_ms`, `reply_chars`, `outcome` |
| `telegram.update` | INFO | `handle_telegram_update` wrapper | `chat`, `text_chars`, `reply_chars`, `voice`, `outcome` |
Outcomes are from a fixed set: `ok`, `error`, `denied`,
`unknown_tool`, `bad_args`, `timeout`. `goal`, `args`, `command`,
and all `*_text` values are redacted then truncated; `*_chars`
always reports the pre-truncation length.
## Validation against the source
Every registry site was checked before implementation: `boot`,
`run_turn`, `ensure_verified`, `compact_messages`,
`try_candidate`, `complete`, `Tools.dispatch`, `ask_approval`,
`add_record`, `delete_record`, `save_secret`, `delete_secret`,
`add_schedule`, `remove_schedule`, `rotate_seal`,
`scheduler_tick`, `watch_schedule`, `dispatch_message`, `do_POST`,
`cap_run_main`, `CapFleet.ask`, and `handle_telegram_update` all
exist with the shapes the hooks assume. Adjusted during review:
serve logging moved from `do_POST` (seven early returns) into
`dispatch_message` plus one auth line; scheduler completion moved
from `poll_agent` (shared, would double-log forks) into
`watch_schedule`; uncaught exceptions moved from a `main()` wrap
into `sys.excepthook`; `run_turn`, `complete`, and `handle_telegram_update` became thin
wrappers over renamed inners so their many exits log once;
`ask_approval` instead logs through an inline `decide()` closure,
because `TuiChatTests` calls it unbound on a stub that a rename
would break.
## Code map
`LOG_*` caps pin the bounds; `DebugLog` owns sink, format, run
id, wipe-on-first-write, the DB connection, prune, and redact;
module-global `LOG` is the one logger, inert until `configure()`;
`TAI_CHILD` selects append; `LOG_MASKED_ARGS` masks secret-shaped
tool arguments; `Store.log_query` reads rows back seal-aware.
`CapAnswerer.research` marks its child.
## Test hooks
`LoggingDocTests` asserts this file names every `LOG_*` cap, every
registry event, the `logs` table with its columns and indexes, the
`trg_logs_*` triggers, both flags, both formats, and `TAI_CHILD`,
with no em dash. `LoggerTests` covers formats, wipe/append,
redaction, prune, seal, `log_query`, the never-raise rule, and
representative hooks (dispatch, completion, approval, turn).