Logging
A runnable shouldn't have to wire up handlers, formatters, or rotation
policies. parse() does it for you — and because a runnable is both a CLI tool
and an agent tool, every run is recorded.
Availability
Logging is on by default from runspec 0.71.0 (Python): a runnable
with no [config.logging] section gets all the defaults. Before that, and
in runspec-node until its port lands, you opt in by adding
[config.logging] (available since 0.10.0 / node-0.9.0).
It's on by default
You don't need to add anything. To tune it, add the block to your spec:
[config.logging]
rotate = "midnight" # see "Rotation policies" below
keep = 7 # number of rotated backups to keep
Every field is optional. Defaults: rotate = "midnight", keep = 7.
Bring your own logging: enabled = false
If your runnable configures logging itself (structlog, loguru, a corporate handler, OpenTelemetry), turn runspec's takeover off:
[config.logging]
enabled = false
This is audit-only mode. runspec leaves your logging alone: no handlers on
the root logger, no stdout/stderr routing, no capture of print() output, no
--debug flag, and your own sys.excepthook still runs. It still writes the run
record (who ran what, with which args, the exit code and how long it took) and
any audit events to the venv's logs/, through its own private
loggers. The run audit can't be turned off.
If audit-only doesn't fit how you log, tell us what you need — the plan is to make runspec sit alongside more logging setups over time.
Use a logger
Just use stdlib logging — no runspec imports needed beyond parse().
import logging
from runspec import parse
logger = logging.getLogger(__name__)
def main():
args = parse()
logger.info("Starting run for %s", args.target)
logger.debug("Resolved args: %s", dict(args._args))
parse() calls logging.getLogger(__name__) work because [config.logging]
configured the root logger before your code ran. You don't need to call
logging.basicConfig() or any runspec-specific setup.
Import getLogger from runspec-node. Loggers are named, lightweight, and
write to the same file/console handlers parse() configured.
import { parse, getLogger } from 'runspec-node';
const logger = getLogger('myapp');
function main(): void {
const args = parse();
logger.info('Starting run for %s', args.target);
logger.debug('Resolved args: %j', args);
}
main();
getLogger is a no-op until parse() has run — if you somehow call it
earlier, log records are buffered and replayed once configuration completes.
Where logs go
Three surfaces, all always on, routed by record level:
| Surface | Receives | Format | Notes |
|---|---|---|---|
| stdout | INFO and below | plain message (reads like print()) |
Captured as the MCP tool response in agent mode |
| stderr | WARNING and above | LEVEL: message |
Pinned at WARNING — not affected by --debug |
| File | INFO by default; DEBUG with --debug |
Structured JSON | {package_dir}/logs/{runnable}.log |
package_dir is the directory containing runspec.toml. When that path is
not writable, runspec falls back to ~/logs/{runnable}.log rather than
silently dropping log records.
The split between stdout and stderr matches Unix stream conventions: routine output on stdout (greppable, pipeable), warnings and errors on stderr (still visible when stdout is redirected). The file is the audit trail — it defaults to INFO so third-party libraries logging at DEBUG (urllib3, boto3, sqlalchemy, …) don't flood it, and flips to DEBUG together with stdout when you need full detail.
HTTP client libraries (httpx, httpcore, urllib3) are kept off stdout
below WARNING: httpx logs every request at INFO (HTTP Request: GET … 200
OK), which would otherwise print ahead of a runnable's JSON output. Those lines
still go to the file, and --debug shows them on stdout too.
// excerpt from {package_dir}/logs/deploy.log
{"time":"2026-05-20T14:02:18.412Z","level":"INFO","logger":"deploy","msg":"Starting run for prod"}
{"time":"2026-05-20T14:02:18.430Z","level":"DEBUG","logger":"deploy","msg":"Resolved args","extra":{"target":"prod","dry_run":false}}
Agent mode
When a runnable is invoked via runspec serve, RUNSPEC_AGENT=1 is set in
the environment. The routing is the same as CLI mode — there's no
separate agent code path to maintain. runspec serve captures the
subprocess's stdout as the MCP tool response, so every logger.info(...)
line reaches the calling agent automatically. Stderr stays on stderr (the
serve loop forwards it to the agent's logs). The file handler is
unaffected.
You write the same code for both surfaces — logger.info("done") shows up
in your terminal when you run the tool by hand, and shows up as the tool
response when an agent invokes it.
Runtime override: --debug
A --debug flag is automatically added to every runnable (not in audit-only
mode, and not when your runnable defines its own debug argument). It only raises visibility — there is no level knob to
silence INFO, because silencing INFO would break agent responses.
deploy --target prod --debug
RUNSPEC_DEBUG=1 deploy --target prod
With --debug:
- stdout also includes DEBUG records (plus full tracebacks on errors)
- stdout also shows HTTP client request lines (httpx and friends)
- file flips from INFO to DEBUG too
Stderr stays pinned at WARNING regardless. The CLI flag wins if both
--debug and RUNSPEC_DEBUG=1 are set.
Sensitive data redaction
Every log record — console and file — is passed through a sensitive-data
filter before emission. The filter replaces matches with [REDACTED]:
- Common credential field names (
password,passwd,token,api_key,apikey,secret,auth,authorization) Authorization: Bearer …andAuthorization: Basic …headers- URL credentials (
https://user:pass@host/) - JSON-encoded credential fields (
"password": "...","token": "...") - Form-encoded credential fields (
password=...&token=...)
logger.info('Calling https://admin:hunter2@api.example.com/deploy')
# → "Calling https://[REDACTED]@api.example.com/deploy"
logger.info('Got response: %s', '{"token": "sk_live_abc123", "ok": true}')
# → 'Got response: {"token": "[REDACTED]", "ok": true}'
Filter errors are silent — a bad pattern never suppresses a log record.
You don't have to think about this. Code defensively where you can, but trust that the filter is the safety net.
Structured extra fields
You don't need a wrapper library for structured logs. Pass extra context through the standard idiom for each language:
Use stdlib extra= — exactly the API you'd use for stdlib logging:
logger.info('Deploy succeeded', extra={
'target': args.target,
'release': args.release,
'duration_ms': 1240,
})
logger.error('Deploy failed', extra={'target': args.target}, exc_info=exc)
Pass an object as the trailing argument; the error key is special and
extracts an Error:
logger.info('Deploy succeeded', {
target: args.target,
release: args.release,
duration_ms: 1240,
});
logger.error('Deploy failed', { target: args.target, error: err });
Where they appear:
- JSON file output: under a nested
"extra"object on the record. - Console output: appended as
{key=value key=value …}after the message.
Sensitive key names (token, password, api_key, secret, etc.) are
unconditionally redacted in extra fields too — both the key check and the
string-value filter run.
Rotation policies
The rotate field accepts time-based and size-based policies:
| Value | Rotates when |
|---|---|
"midnight" (default) |
At local midnight |
"daily" |
After 24 hours from first write |
"weekly" |
After 7 days from first write |
"100 KB", "10 MB", "1 GB" |
When the file exceeds the size |
keep controls how many rotated backups are retained. Files older than
keep + 1 are deleted on the next rotation.
Per-invocation files (shared venvs)
The default store = "single" keeps one rotating {runnable}.log. That's
correct for local, single-user runs — but in-process rotation is not safe
when several users (or one user running in parallel) write the same file at
once: a rotation in one process can lose records from another. For that case,
switch to one file per invocation:
[config.logging]
store = "per-run"
Each run then writes its own file:
{runnable}.{utc-ts}.{run_id}.log e.g. deploy.20260603T142201Z.4f3c…e7.log
Because no file is ever shared, there is no rotation race and no cross-user
ownership conflict — it just works for a shared deployment venv with many
operators. The run_id in the filename matches the run_id field in the
records inside it.
There is no automatic rotation in this mode (rotate/keep are ignored) —
files accumulate until you clean them up. Viewing and retention are handled by
the runspec logs command:
runspec logs deploy # merged view of every invocation, as one stream
runspec logs deploy --follow # live tail across runs
runspec logs compact --older-than 7d --gzip # roll old runs into an archive
runspec logs prune --older-than 90d # delete old files
To make retention automatic, give the venv a policy (runspec >= 0.80.0):
runspec logs retention --compact-after 7d --delete-after 90d
The venv owner's runs then apply it at most once a day in the background, with
no cron line to maintain. The console's Provision venv sets one by default.
See the CLI reference.
You can still schedule compact/prune from cron or a systemd timer instead.
Sharing the logs dir across accounts (runspec logs share)
A shared venv is owned by one account, the owner of the venv root (e.g. the
svc account the console's Provision venv… built it as). Its runnables are also
run by other accounts: operators logged in as themselves, root (a
run_as = "root" runnable), other accounts' crontabs. None of them can write
the owner's logs/, so without help their runs fall back to their own
~/logs, where the venv's history (and every console) can't see them.
Since runspec 0.68.0 this is rarer. Runnables run as the venv's owner by default
(run_as = "@owner"), and a direct run by anyone else is refused, so most runs
are the owner's own. The inbox covers the rest: root (run_as = "root") and
packages that set run_as = "".
Set up the logs inbox (recommended, runspec >= 0.66.1, POSIX). Run this once per venv, as the venv's owner:
sudo -u <owner> {venv}/bin/runspec logs share --inbox --owner-only
A plain venv is enough — no runspec-linux needed. Create it, install
runspec>=0.66.1, and run the command above, all as the owner with umask 022.
Shared venvs — the logs inbox walks through the whole
thing: set-up, how to check it works, and troubleshooting.
It creates {venv}/logs/inbox: sticky, 01777, owned by the owner. It works
like /tmp or a mail spool:
- Every other account (root included) writes its per-run file into the inbox
and never touches the runs index. The file is created exclusively, never
through a symlink, mode
0644, and held under a lock while the run is live. - The owner sweeps finished inbox runs into the runs index and moves their
files into
logs/. This happens automatically at the end of the owner's own runs;runspec history sweepdoes it on demand (or from cron). - Until a run is swept, readers show it anyway:
runspec history list,runspec logs <runnable>, and the console's History tab (tagged inbox).
No group, no ACL, no extra sudo rule. Run as root, logs share --inbox and
history sweep drop to the venv's owner first, so the standard
runspec-linux-root's enable-passwordless-sudo --scope scoped grant (which is (root) on the venv's bin/)
is enough. The console's Provision venv… creates the inbox for you, and the
owner's (or root's) own runs create or repair it automatically as a safety net.
Opt a venv out with runspec logs share --no-inbox (a logs/.no-inbox marker);
add --owner-only to make logs/ itself 0755. runspec history stats
reports the inbox's state.
Trust model:
- A swept run's
os_user(runs index v6) is the file's kernel-attested owner. The run summary'suserstays self-reported. - One account's file can never rewrite another account's row.
- Symlinks, hard links and FIFOs in the inbox are never followed, indexed or read.
Full design: venv logs inbox.
The older alternative: a shared group. `runspec logs share --group
runspec logs share --group consoles # chmod 2775 (setgid) + chgrp to `consoles`
runspec logs share # just make it group-writable + setgid, group unchanged
sudo -u <owner> {venv}/bin/runspec logs share --acl-user alice
runspec: deploy completed in 1.84s — 12 events (2 warn, 0 err)
[config.logging]
summary_console = true # show the closing line in the terminal
args = parse()
action = args.no_such_arg # AttributeError — caught and shown as a one-liner,
# not a raw traceback exposing runspec internals
{"ts": "...", "level": "CRITICAL", "logger": "runspec.exception",
"message": "uncaught exception",
"exc": "Traceback (most recent call last):\n ...full traceback...",
"exc_structured": {
"type": "ValueError",
"message": "invalid release tag 'v9.9.9-bad'",
"module": "release",
"frames": [
{"file": "/abs/deploy.py", "line": 48, "func": "main", "code": "main()"},
{"file": "/abs/release.py", "line": 212, "func": "resolve_release", "code": "raise ValueError(...)"}
]
},
"extra": {"run_id": "..."}}
$ deploy # without --debug — one concise line
ERROR: ValueError: invalid release tag 'v9.9.9-bad' (run with --debug for traceback)
$ deploy --debug # neat, aligned trace; internal runspec frames filtered out
ValueError: invalid release tag 'v9.9.9-bad'
deploy.py:48 main()
release.py:212 raise ValueError(f"invalid release tag {tag!r}")