Engine Logging¶
Every line vayu-engine, vayu-cli and the Electron app write under
<data-dir>/logs/ is one JSON object (issue #1557 for the engine, #1558 for
the app, both under the wider #1556). The console gets a text rendering of
the same record; the two are never a choice a config entry makes, since a
second switch is exactly what #1510 retired.
The record¶
{"ts":"2026-09-07T13:57:19.123Z","level":"debug","src":"engine","cat":"http","msg":"GET /inbox 200 1.3ms 412B","pid":4242,"tid":"139812","method":"GET","path":"/inbox","status":200,"ms":1.3,"bytes":412}
Required on every record: ts (RFC 3339, UTC, milliseconds, trailing Z -
never local time, so an engine and a CLI record from the same machine sort
together with no ambiguous hour), level (debug | info | warn |
error), src (engine | cli | app | mcp | renderer - the last
three are the app side's, #1558), cat (one lowercase token from the closed
list below), msg (one human sentence - never key=value text, which is a
field now), pid. Almost always present: tid (the app side omits it - see
below). Free typed fields follow, named in lowercase ASCII with no dots
(docs/engine/log-record.schema.json's propertyNames rule).
The schema is log-record.schema.json, JSON Schema
draft 7. Both the engine and the app validate every record they write against
it in tests, so a category added on one side and not the schema reds a test
rather than silently drifting.
Console rendering¶
HH:MM:SS.mmm LEVEL cat<padding>msg key=value ... - the uppercase level name
(unchanged from before #1557), the category left-justified, then the message
and any extra fields as key=value pairs. vayu-engine's -v 0|1|2 still
governs which levels reach the console; the file's own floor is the separate
logLevel setting below.
One request line per HTTP call (issue #1510), at the level its own status
calls for rather than one fixed level for every call: a 2xx is debug, a
3xx or 4xx is info, and a 5xx is warn, because an engine failure is not
something a quiet run should hide. install_request_logger
(http/request_log.cpp) is the one place this decision is made, for both
the management server and an inbox listener.
Categories¶
Engine: startup, config, http, db, run, script, inbox, mock,
oauth, client, shutdown. CLI: the same set plus cli. Adding one is one
line in docs/engine/log-record.schema.json's cat enum and one in
tests/log_category_scan_test.cpp's allow-list - the source scan that pins
every log_debug/log_info/log_warning/log_error call to a real category
(rg 'log_(debug|info|warning|error) \("' engine/src).
App and MCP (src: "app" and src: "mcp" share one list, since both write
app_<stamp>.log): main, sidecar, window, updater, ipc, mcp,
power, notify. Renderer (src: "renderer"): renderer, boundary. See
"The app's log" below.
Redaction by key¶
Before either renderer sees a record, Logger::write walks fields at every
depth (vayu::utils::redact_fields, utils/log_redact.hpp):
- A field named
authorization,proxy-authorization,cookie,set-cookie,www-authenticate,proxy-authenticate,authentication-info,token,access_token,refresh_token,client_secret,password,apikeyorx-api-key(case-insensitive) becomes"<redacted>"wholesale. - A field whose name ends in
urlorUrlgoes throughstrip_url_secrets: userinfo and the query string removed, scheme/host/path kept (https://u:p@h/x?y=1becomeshttps://h/x).
A curl verbose exchange (vayu-engine --verbose 2, or a run with its own
verbose override) is one cat=client record per transfer rather than one
line per frame: lines[] holds each physical line of the outgoing and
incoming header blocks, each redacted the same way debug_redact.hpp always
has (Authorization: <redacted>), plus the request line's query string
through the same URL rule.
Files, rotation and retention¶
<data-dir>/logs/engine_<stamp>.log (the daemon) and
<data-dir>/logs/cli_<stamp>.log (vayu-cli, its own default data directory
when --data-dir names none). One file per process start; logLevel
(Settings, Observability, restart-required) is the floor both write at,
defaulting to debug. maxLogFileBytes rotates the open file once to .1
when it would cross the cap (0 = unlimited); the newest 10 per-prefix files
survive a start, oldest deleted first along with their .1.
The app's log¶
electron/log.ts (#1558) is a from-scratch TypeScript port of the record
above and of log_redact.hpp's redaction rule - same required keys, same
"<redacted>" and URL-stripping behaviour, same JSON-lines file, same text
console shape - so a reader (jq, a human) treats every file under one
logs/ directory the same way regardless of which binary wrote it.
<data-dir>/logs/app_<stamp>.log carries every src: "app" record (the
Electron main process: main.ts, sidecar.ts, power-save.ts, notify.ts
and the rest) and every src: "mcp" record from the Electron-hosted MCP
transport (mcp/http.ts) - one file, since both live in the same process and
share the same buffer and floor. The standalone stdio MCP server
(mcp/cli.ts, node dist-electron/mcp/cli.js) is a separate OS process with
no Electron app module to derive a data directory from, so it writes its
own <dir>/mcp_<stamp>.log when launched with VAYU_LOG_DIR set (unset:
console only) - its rotation, retention and redaction are otherwise the same
port. Neither app-side file sets tid: the main process has no per-connection
worker threads the way the engine does, so the field would name nothing a
reader could use.
One floor for both files. The engine's logLevel config entry governs
app_<stamp>.log too - the app introduces no setting of its own. log.ts
buffers every record in memory until the sidecar answers GET /config once
after startEngine() resolves (or, if the engine never answers, at debug -
a launch that failed is exactly the one whose every record is wanted), then
applies that floor to the whole buffer and to everything logged after.
electron/app-log.ts exposes the two Electron-coupled singletons every
main-process module shares, appLogger() and mcpLogger(); mcp/cli.ts
calls log.ts's createLogger directly, since it has no app module to
couple to.
In dev (!app.isPackaged) and under VAYU_LOG_CONSOLE=1, the same text
renderer as the engine's also prints: error to stderr, everything else to
stdout - except the stdio MCP server, which prints everything to stderr
unconditionally, since its stdout is the JSON-RPC channel.
A renderer error reaches the same file through one more hop: error-logger.ts
sends {level, cat, msg, err, fields} over the one-way log:record IPC
channel (mirroring runs:progress's shape), and log-ipc.ts validates it,
stamps src: "renderer" and the sender's OS process id, and caps the channel
at 20 records per second per window - the 21st in a window is dropped with one
warn, and every later drop in the same window silently, so a render loop
cannot fill the disk with either the records or the warnings about them.
Settings,
General's Open logs folder button (app:openLogsFolder) opens
<data-dir>/logs/ in the OS file manager.
Reading the files¶
jq -r 'select(.cat=="http") | .status' logs/engine_*.log
jq -s 'sort_by(.ts) | .[] | "\(.ts) \(.src) \(.level) \(.cat) \(.msg)"' logs/*.log
The second line is the merge across every file in the directory - engine_*,
cli_*, app_* and, when it exists, mcp_* - every record from every
source, interleaved by timestamp.