Telemetry¶
Telemetry is the central record of what femforge did: every log record, every run, every stored artifact and every sweep point, kept as hive-partitioned parquet under one directory. A run bundle describes one run; telemetry answers questions across thousands of them, such as every error from last night's sweep or every run that used one mesh, without opening each bundle. It is off until you name a root, and a process with telemetry off never imports polars.
Turn it on¶
Name a telemetry root, the directory the tables live under, in one of two ways:
| Where | How |
|---|---|
| The environment | Set FEMFORGE_TELEMETRY to the root. Every driver, log sink and command in the process uses it. |
| The code | Pass telemetry= to Driver or setup_logging. An explicit root wins over the variable. |
With a root, the driver appends a row to the run index when it first writes a run's manifest and another once the run settles, so a run is visible while it solves. Every record logged during evaluate() lands in that driver's root. A failed telemetry write only logs a warning; it never fails a solve.
What the root holds¶
telemetry/
├── logs/date=2026-10-13/process=gap-sweep/part-<host>-<pid>-<utc>-<seq>.parquet
├── runs/month=2026-10-01/part-...parquet
├── artifacts/kind=elmer_mesh/part-...parquet
├── sweeps/month=2026-10-01/part-...parquet
├── sweep_events/sweep_id=gap-study/part-...parquet
└── live/logs-<host>-<pid>.jsonl
| Table | One row per | Partitioned by | Written by |
|---|---|---|---|
logs |
log record | date and process name | the parquet log sink |
runs |
run index pass over a bundle | the month the run was created | the driver, femforge index, femforge runs reap |
artifacts |
index pass over a stored artifact | artifact kind | femforge index --store |
sweeps |
sweep launch, plus a closing row when it finishes | the month it started | SweepJournal |
sweep_events |
point state transition | sweep id | SweepJournal |
Telemetry addresses a root and hands out one HiveStore per table. Each table's columns are a polars.Schema whose names come from a StrEnum, such as RunColumn and LogColumn, so you filter with pl.col(RunColumn.STATUS) instead of a bare string.
The store is append-only. Each writer writes its own shard, named by host, process id, a UTC stamp and a sequence number, staged under a temporary name and moved into place, so a reader never sees half a file and many processes write at once without locks. An index re-read adds a new row for the same run; readers keep the latest row per run.
The convergence log is the exception: each bundle keeps its own convergence.jsonl, and scan_convergence reads it as a typed frame with two derived columns, first_relc and progress, the fraction of the way to the tolerance the sif set.
The journal¶
Every log record and every sweep event goes first to the writing process's own JSONL journal under live/, one flushed line per record. The log sink checkpoints the journal into parquet every 500 records, every 5 s, and at exit, then truncates it, so a record sits in exactly one of the two places. A process that dies leaves its journal behind, and the next sink or sweep writer that starts on the root folds it into parquet.
Readers union both halves. scan_logs and SweepQuery.events() return the settled shards together with what is still only in a journal, so a live sweep shows its newest records.
Read it back¶
The query classes are fluent: each method returns a new query, and nothing reads parquet until frame() or page(). This lists the last week's finished runs:
from datetime import datetime, timedelta, timezone
import femforge as ff
from femforge.telemetry import RunColumn, RunIndex
telemetry = ff.Telemetry("telemetry")
index = RunIndex(telemetry, "runs")
added = index.update()
week_ago = datetime.now(timezone.utc) - timedelta(days=7)
recent = index.query().status("ok", "diverged").since(week_ago).newest(20).frame()
print(f"{added} bundles indexed")
print(recent.select(RunColumn.RUN_ID, RunColumn.STATUS, RunColumn.CREATED_AT, RunColumn.WALL_TIME))
update() reads only the bundles whose manifest.json changed since the last pass; update(rebuild=True) reads them all. It parses a dozen manifest keys without validating the config, so a corrupt bundle costs one null instead of the pass.
| Reader | Over | Filters |
|---|---|---|
RunIndex.query() |
every runs directory the root knows | status, tags, text, since, sweep, newest, with_live, where |
ArtifactIndex.query() |
one artifact store | kind, spec_type, since, key_prefix, newest, where |
LogQuery.over(telemetry) |
logs, settled and live | levels, processes, run, sweep, since, until, text, where |
| SweepQuery | one sweep | launch, events, latest, failures, summary |
with_live() joins the claim each run holds right now, read from <run_id>.lock/owner.json, with its heartbeat age. RunQuery.runs() and ArtifactQuery.records() open the matching bundles and artifacts, skipping the ones retention has removed.
For anything the filters do not cover, scan a table as a polars LazyFrame:
import femforge as ff
import polars as pl
from femforge.telemetry import LogColumn
telemetry = ff.Telemetry("telemetry")
errors = (
telemetry.logs.scan()
.filter(pl.col(LogColumn.LEVEL) == "ERROR")
.group_by(LogColumn.PROCESS)
.len()
.collect()
)
print(errors)
scan() reads the settled shards only; use scan_logs(telemetry) to include the live journals.
Record a sweep¶
A sweep is the caller's: you pick its id, and a SweepJournal records its launch row and each point transition. Inside sweep_point, every log record and every run index row the driver writes carries the sweep id and the point key:
import traceback
import femforge as ff
from femforge.telemetry import SweepJournal, SweepState, sweep_point
def gap_config(gap_mm: int) -> ff.SimulationConfig:
"""The actuator config at one air gap."""
return ff.SimulationConfig(
mesh=ff.MeshConfig(step=f"models/actuator-gap-{gap_mm}mm.step", global_mesh_size=0.002),
domain=ff.DomainConfig(materials=[ff.materials.Air(), ff.materials.N52()]),
meta=ff.RunMeta(description=f"Gap {gap_mm} mm", tags={"study": "gap"}),
)
telemetry = ff.Telemetry("telemetry")
driver = ff.Driver(runs_dir="runs", telemetry=telemetry.root)
gaps = [1, 2, 3]
journal = SweepJournal(telemetry, sweep_id="gap-study")
journal.start("Air gap sweep", total=len(gaps))
for gap in gaps:
key = f"gap={gap}"
journal.event(key, SweepState.RUNNING, point_name=f"Gap {gap} mm")
try:
with sweep_point(journal.sweep_id, key):
_ = driver.evaluate(gap_config(gap))
except Exception:
journal.event(key, SweepState.FAILED, error=traceback.format_exc())
continue
journal.event(key, SweepState.DONE)
journal.finish()
Each worker process of a parallel sweep opens a SweepJournal of its own with the same sweep id. SweepQuery(telemetry, "gap-study").summary() returns a SweepSummary: the count per state, the points settled per hour over the last 30 minutes, or over the sweep's life when it is younger, and the ETA that rate gives.
Follow a run live¶
| Tool | Follows |
|---|---|
femforge logs tail --follow |
every process's log journal under live/, re-read every 0.5 s |
| TextTail | one growing text file, such as a bundle's run.log or elmersolver.log; each read_new() returns only the complete lines written since the last call |
| LiveLogTap | this process's own records: a loguru sink that keeps the last 5000 in memory and pushes each new one onto asyncio subscriber queues |
The workbench's live monitors use all three.
Logging¶
femforge logs through loguru. setup_logging installs the terminal sink and routes the standard library's logging and warnings through it; ff.log runs it with the defaults before its first record.
from pathlib import Path
import femforge as ff
from femforge.common import setup_logging
setup_logging(level="DEBUG", telemetry=Path("telemetry"), process="gap-sweep")
ff.log.bind(gap=0.002, mesh_size=0.0005).info("Gap set.")
| Setting | Argument | Default |
|---|---|---|
| Terminal level | level |
FEMFORGE_LOG_LEVEL, else INFO |
| Telemetry root | telemetry |
FEMFORGE_TELEMETRY, else off |
Process name, the logs partition |
process |
the script's file name, folded to [a-z0-9_.-] |
| Library gates | library_levels |
build123d at WARNING |
The parquet sink takes every record at DEBUG whatever the terminal level, so the store keeps what the terminal hides. Each run also writes its own records, at DEBUG and above, to run.log in its bundle.
Log a short, fixed message and put the data in log.bind(), keyed by field name in snake case without a unit suffix. The terminal prints the bound fields beside the message, and the sink stores them as JSON in the extra column, so you can search the message as text and filter on the fields. run_id, sweep_id and point_key get columns of their own.
Commands¶
The femforge command reads and maintains the store; each subcommand takes --telemetry, else reads FEMFORGE_TELEMETRY.
| Command | Does |
|---|---|
femforge index update --runs-dir R [--store S] |
Index the bundles of a runs directory that changed, and an artifact store with --store. |
femforge index build --runs-dir R [--store S] |
The same, re-reading every bundle and artifact. |
femforge runs ls |
List indexed runs, newest first. |
femforge runs reap --runs-dir R |
Break dead claims, mark their bundles abandoned and re-index them. |
femforge logs query |
Search the stored log records. |
femforge logs tail [--follow] |
Print the live journals. |
femforge sweeps ls |
List sweeps, newest first. |
femforge sweeps show <id> |
Show one sweep's counts, points and failures. |
femforge telemetry compact [--settle SECONDS] |
Fold each partition's shards untouched for --settle seconds, 300 by default, into one shard. |
femforge artifacts ls reads the artifact index when a root resolves and the index has rows, and scans the store otherwise. CLI lists every option.