Logging#
Optional, file-based logging shared by the Python bindings and the CLI. One
log file is created per process (one Python interpreter, one ecp CLI
invocation, or your own Rust program embedding ecp-core). The file
format is JSONL, which stores a log line as one JSON object per line,
making it easy to read with jq or load with any JSON-lines reader.
Turning it on#
From Python:
from ecpfs import init_logging
log_path = init_logging(log_dir="ecp_logs", level="info")
From the CLI, on any subcommand:
ecp search my_index.zarr query.h5 --with-logging --log-dir ecp_logs --log-level info
Both default to ecp_logs/ in the current directory and the info
level. init_logging returns the file’s path; the CLI prints it to
stderr.
Levels#
Six levels, each including everything the ones before it show: off,
error, warn, info (default), debug, trace.
offStill creates the log file (so
init_logging/--with-loggingreturns/prints a path), but logs nothing to it.errorThe operation failed. Only logged when a failure is not returned as a Result or exception for the caller to assess.
warnThe operation finished, but the result may be worth a second look, such as a narrower
embedding_dtypethan the source data or a query id that wasn’t found.infoLogs one line per user-level operation, such as loading an index or running a search.
debugLogs internal function details, such as batch information during a build, or every search call’s parameters.
traceLogs per-node details during a search, such as a cache hit versus a disk read, or how many items a leaf node contributed. The most detailed level, and the most expensive to leave on for a large search.
Record format#
Every line is one JSON object with four fields, for example:
{"timestamp":"2026-09-21T12:28:09.815337Z","level":"INFO","target":"ecp_core::search::index::query","message":"search: query_id=0 k=3 leaf_nodes_scanned=2 items_returned=3"}
timestampRFC 3339, UTC, with sub-second precision.
levelUpper case (
INFO,DEBUG, …), regardless of how you spelled it ininit_logging/--log-level(lower case there, e.g."info").targetThe Rust module that logged the line, e.g.
ecp_core::build::builderorecp_core::search::index::query. Useful for filtering to one area of the code.messagePlain text, not itself further structured. Parameter values are interpolated directly into it (
query_id=0 k=3 ...), not broken out into their own JSON fields.
One file per process#
One file per process, shared by every thread in it; concurrent writes never
interleave or corrupt a line. A new process gets a new file, even at the
same log_dir, and only the first init_logging/--with-logging
call in a process does anything, later calls return the same path. Files
are named {start_time}-{6_hex_chars}.jsonl, for example
20260921T122809Z-05f388.jsonl, so sorting by filename sorts them
chronologically, and nothing rotates a file once it’s created.
Reading a log#
Every line is independently valid JSON, so ordinary line-oriented tools work
directly on the file, with no JSONL-aware library needed: tail/head
for a window of it, wc -l for how many records it holds, jq for
filtering and reshaping.
The last 20 records:
tail -n 20 ecp_logs/*.jsonl | jq .
Only the errors, across every file in the directory:
jq -r 'select(.level=="ERROR") | .message' ecp_logs/*.jsonl
The same two things from Python, one json.loads per line and no extra
dependency:
import json
from pathlib import Path
records = [
json.loads(line)
for path in sorted(Path("ecp_logs").glob("*.jsonl"))
for line in path.read_text().splitlines()
]
last_20 = records[-20:]
errors = [r for r in records if r["level"] == "ERROR"]