Run logging and provenance¶
Shared infrastructure in biotp.runlog, used by every pipeline script in every
project. Two things it produces per run: a readable log, and a machine-readable
manifest.
Why, concretely¶
Both of these were real problems on this project before the module existed.
Progress was invisible. Bare print into a redirected file is
block-buffered, so embedding 13,858 sequences produced a 0-byte log for the best
part of an hour. There was no way to tell a working run from a hung one.
logging handlers flush per record, and the embedding loop now reports batch
progress with a throughput estimate.
Provenance was remembered rather than recorded. DECISION_LOG.md entries are
supposed to state the setup that produced a number: data, model, config, device,
code version. Reconstructing that after the fact is how logs quietly become
wrong. Now every run writes a JSON manifest, and log entries can cite it.
What a run produces¶
logs/prepare-data-20260730T014346Z.log # human-readable, flushed live
logs/prepare-data-20260730T014346Z.json # manifest
logs/ is gitignored, since logs are regenerable. For runs that produce
committed metrics, the runner also drops a copy of the manifest beside them
(results/run_manifest_all.json), so a number in a writeup can be traced to the
commit and machine that produced it.
The manifest holds:
| Field | Contents |
|---|---|
status |
completed or failed |
started_at, duration_seconds |
When and how long |
params |
The script's arguments, usually vars(args) |
steps |
Each named stage, its duration, and whether it failed |
records |
Facts the script chose to record: counts, split sizes, metrics |
git |
Commit, branch, and whether the tree was dirty |
environment |
Python, platform, device, and versions of eight key packages |
error |
Exception type and message, on failed runs only |
git.dirty matters as much as the commit. A number produced from uncommitted
edits is not reproducible from the commit alone, and the manifest says so rather
than implying a clean provenance it cannot support.
Using it¶
from biotp.runlog import get_logger, run_context
log = get_logger("my-pipeline")
with run_context("my-pipeline", params=vars(args)) as run:
with run.step("load data"):
table = load(...)
run.record("rows", len(table))
run.step(name)times a stage and logs its start and end. A stage that raises is timed, logged, and markedfailedin the manifest before the exception propagates.run.record(key, value)logs a fact and puts it on the manifest. Use it for anything a writeup would cite. Numpy scalars are coerced, so recording anp.float32metric will not break the JSON write.run.write_manifest(path)writes an extra copy somewhere specific.
Failure is recorded, not swallowed¶
The manifest is written whether the body succeeds or raises, and the exception is re-raised untouched. A crashed run therefore leaves evidence rather than nothing, and the failing stage is identifiable:
{
"status": "failed",
"error": {"type": "AssertionError", "message": "unexpected localization ..."},
"steps": [
{"name": "download DeepLoc FASTA", "duration_seconds": 0.0},
{"name": "parse FASTA", "duration_seconds": 0.001, "failed": true}
]
}
This is the difference between "the run died" and "the run died in the parser on input it refused to guess about," and it is why logging completion matters as much as logging progress.