Telemetry¶
femforge.telemetry is the process-wide record of what the pipeline did: every log record, every run, every stored artifact and every sweep point, kept as hive-partitioned parquet under one root. A run bundle describes one run; telemetry answers questions across thousands of them without opening each bundle. This page covers the store, its tables, the journal that makes it live, the log sink, the indexes, sweep progress and convergence files, and the rules a consumer may rely on. Telemetry in the user guide covers turning it on and reading it back.
The root¶
resolve_root is the on and off switch: an explicit path, else FEMFORGE_TELEMETRY, else None, and None means telemetry is off and nothing is created (src/femforge/telemetry/paths.py:55-64). A reader that needs a root calls require_root, which raises TelemetryRootMissing naming both ways to give one (src/femforge/telemetry/paths.py:67-73).
With telemetry off, a process never imports polars. The logging setup and the driver check the explicit root and the variable before they import femforge.telemetry (src/femforge/common/log.py:205-210, src/femforge/run/driver.py:365-374).
Telemetry addresses a root and hands out one cached HiveStore per table (src/femforge/telemetry/paths.py:76-115). TelemetryDir names the directories (src/femforge/telemetry/paths.py:33-52):
| Directory | Holds | Partitioned by |
|---|---|---|
logs/ |
the log table | date, then process |
runs/ |
the run index | month |
artifacts/ |
the artifact index | kind |
sweeps/ |
a launch row per sweep, plus a closing row once it finishes | month |
sweep_events/ |
one row per sweep point transition | sweep_id |
live/ |
each process's write-ahead journals | not a table |
The hive store¶
A HiveStore is one append-only table: a typed scan, an atomic append, and a settling compact (src/femforge/telemetry/hive.py:100-182). Its schema lists every column, the partition columns included. The partition values live in the directory names, so the shard bodies leave those columns out and a scan types them back through hive_schema (src/femforge/telemetry/hive.py:115-123, src/femforge/telemetry/hive.py:145).
| Operation | Behavior | Where |
|---|---|---|
append(frame) |
casts the frame onto the schema and writes one new shard per partition; existing shards stay untouched | src/femforge/telemetry/hive.py:149-162 |
| a shard write | stages the parquet in a .tmp sibling and moves it into place, so a reader never sees half a file |
src/femforge/telemetry/hive.py:53-62 |
| a shard name | part-<host>-<pid>-<utcstamp>-<seq>.parquet, unique across hosts, processes and writes, so writers need no lock |
src/femforge/telemetry/hive.py:43-50 |
scan() |
a lazy frame over every shard, typed onto the current schema | src/femforge/telemetry/hive.py:138-147 |
compact(settle_s) |
in each partition, folds the shards untouched for settle_s, 300 s by default, into one merged shard, then deletes them |
src/femforge/telemetry/hive.py:34-35, src/femforge/telemetry/hive.py:164-182 |
Compaction writes the merged shard before it deletes the originals, so the rows exist on disk at every moment, and a reader that lists a partition between the two steps sees them twice.
Columns are typed once, in src/femforge/telemetry/schemas.py:
- Column names are StrEnums. A query selects
pl.col(LogColumn.LEVEL), so the writer and the reader name a column through one symbol. - Categories come from existing vocabularies.
pl.Enumcolumns take their categories fromLogLevel,ManifestStatus(a union ofLiteraltypes, flattened byliteral_categories),RunSource,SweepStateandConvergenceLoop(src/femforge/telemetry/schemas.py:45-61). Identical categories let shards from different processes concatenate without a cast. - A vocabulary may grow. polars refuses to unify Enum columns whose categories differ, so
scanandcompactsplit the shards into consecutive runs that share one parquet schema and type each run before they meet (src/femforge/telemetry/hive.py:65-67,src/femforge/telemetry/hive.py:144-147).coercefills a column a shard predates with typed nulls, nulls an unknown Enum category instead of raising, and parses ISO date and time strings (src/femforge/telemetry/hive.py:70-97). - Every timestamp is
Datetime("us", "UTC")(src/femforge/telemetry/schemas.py:41-42). Open vocabularies, such as bound log fields, run tags and artifact specs, are JSON in a string column.
Tables and schemas¶
The tables below are generated from src/femforge/telemetry/schemas.py at build time. The meaning column is each member's docstring in the column enum.
logs¶
One row per log record; extra is the JSON of every bound key without a column of its own.
| Column | Type | Meaning |
|---|---|---|
date (partition) |
Date | The UTC day the record was logged on; a partition key. |
process (partition) |
String | The sanitized name of the process that wrote the record; a partition key. |
time |
Datetime(us, UTC) | When the record was logged, in UTC. |
level |
Enum: TRACE, DEBUG, INFO, SUCCESS, WARNING, ERROR, CRITICAL |
The record's level name, such as INFO. |
level_no |
Int16 | The record's numeric severity. |
name |
String | The module that logged the record. |
function |
String | The function that logged the record. |
line |
Int32 | The source line that logged the record. |
message |
String | The formatted message. |
run_id |
String | The run the record was bound to; null outside a run. |
sweep_id |
String | The sweep the record was bound to; null outside a sweep. |
point_key |
String | The sweep point the record was bound to; null outside a sweep. |
host |
String | The machine that wrote the record. |
pid |
Int32 | The id of the process that wrote the record. |
thread |
String | The name of the thread that logged the record. |
exception |
String | The formatted traceback of the exception the record carried; null when it carried none. |
extra |
String | Every other bound key as JSON; null when there are none. |
runs¶
One row per index pass over a run bundle; the latest row per run_id wins.
| Column | Type | Meaning |
|---|---|---|
month (partition) |
Date | The first day of the month the run was created in; the partition key. |
run_id |
String | The run's bundle directory name. |
cache_key |
String | The full content hash of the run's result-affecting inputs. |
runs_dir |
String | The runs directory holding the bundle. |
status |
Enum: running, abandoned, ok, diverged, error |
The manifest's lifecycle status. |
created_at |
Datetime(us, UTC) | When the run was assembled. |
finished_at |
Datetime(us, UTC) | When the run's latest stage finished; null before any has. |
current_stage |
String | The stage running when the row was read; null when none was. |
stages_done |
Int32 | How many stages had finished when the row was read. |
git_sha |
String | The commit of the checkout the run was made in, with -dirty when it had uncommitted changes. |
elmer_version |
String | The ElmerSolver version parsed from the solver log. |
physics |
String | The physics preset the run solved. |
element_count |
Int64 | Total number of volume elements in the solved mesh. |
min_element_size |
Float64 | h_min [m]: Smallest element edge length. |
max_element_size |
Float64 | h_max [m]: Largest element edge length. |
description |
String | The description in the run's metadata. |
tags |
String | The tags in the run's metadata, as a JSON object. |
notes |
String | The manifest's free-form provenance note. |
mesh_name |
String | The mesh artifact's store directory name. |
mesh_key |
String | The mesh artifact's content key. |
published |
Boolean | Whether the bundle was published to the artifact store. |
has_result |
Boolean | Whether the bundle holds a readable result.json. |
has_vtu |
Boolean | Whether the bundle holds a result VTU file. |
cleanups |
Int32 | How many retention passes the manifest records. |
bundle_bytes |
Int64 | The bundle directory's total size in bytes. |
sweep_id |
String | The sweep the run was solved under; null outside a sweep. |
point_key |
String | The sweep point the run was solved for; null outside a sweep. |
manifest_mtime |
Datetime(us, UTC) | The manifest's modification time when the row was read. |
indexed_at |
Datetime(us, UTC) | When the row was written; the latest row per run wins. |
source |
Enum: scan, driver |
How the row was produced. |
model_dim |
Int8 | The mesh dimension the run's frame solves on: 2 or 3. |
artifact_keys |
List(String) | The content keys of the store artifacts the run consumed. |
force_y |
Float64 | F_y [N]: Y component of the net force on the moving array. |
temperature_max |
Float64 | T_max [K]: Peak temperature in the domain. |
wall_time |
Float64 | t_wall [s]: Solver wall-clock time. |
artifacts¶
One row per index pass over an artifact.json sidecar; the tool-version columns mirror ToolVersions.
| Column | Type | Meaning |
|---|---|---|
kind (partition) |
String | The artifact kind name; the partition key. |
key |
String | The content key the artifact is stored under. |
name |
String | The <adjective>-<noun>-<hash8> store directory name. |
store_root |
String | The root of the store holding the artifact. |
created_at |
Datetime(us, UTC) | When the artifact was published. |
spec_type |
String | module:QualName of the spec model. |
producer_version |
Int32 | The kind's producer version at publish time. |
producer_host |
String | The machine that produced the artifact. |
producer_pid |
Int32 | The id of the process that produced the artifact. |
file_count |
Int32 | How many files the artifact holds. |
total_bytes |
Int64 | The total size of the artifact's files in bytes. |
files |
List(String) | The artifact-relative names of its files. |
spec |
String | The hashed spec payload, as JSON. |
femforge |
String | The femforge release that produced the artifact. |
code_sha |
String | The producing checkout's commit. |
gmsh |
String | The gmsh version, when meshing was involved. |
elmer |
String | The ElmerSolver version, when a solve was involved. |
dir_mtime |
Datetime(us, UTC) | The artifact directory's modification time when the row was read. |
indexed_at |
Datetime(us, UTC) | When the row was written; the latest row per artifact wins. |
sweeps¶
A launch row per sweep and a closing row once it finishes, deduplicated on sweep_id.
| Column | Type | Meaning |
|---|---|---|
month (partition) |
Date | The first day of the month the sweep started in; the partition key. |
sweep_id |
String | The sweep's id. |
description |
String | What the sweep runs, as its launcher described it. |
started_at |
Datetime(us, UTC) | When the sweep started. |
finished_at |
Datetime(us, UTC) | When the sweep finished; null on its launch row. |
total |
Int32 | How many points the sweep holds, skipped ones included. |
skipped |
Int32 | How many points were already stored and did not run. |
max_workers |
Int32 | How many worker processes run the points. |
host |
String | The machine that launched the sweep. |
pid |
Int32 | The id of the process that launched the sweep. |
process |
String | The sanitized name of the process that launched the sweep. |
git_sha |
String | The commit of the checkout the sweep ran in. |
indexed_at |
Datetime(us, UTC) | When the row was written; the closing row supersedes the launch row. |
sweep_events¶
Append-only point transitions; the latest state per point_key is the sweep's progress.
| Column | Type | Meaning |
|---|---|---|
sweep_id (partition) |
String | The sweep the point belongs to; the partition key. |
point_key |
String | The point's key within the sweep. |
point_name |
String | A readable label for the point. |
state |
Enum: queued, running, done, failed, skipped |
The state the point entered. |
at |
Datetime(us, UTC) | When the point entered the state. |
host |
String | The machine that recorded the transition. |
pid |
Int32 | The id of the process that recorded the transition. |
origin |
String | Where a finished point's result came from, such as an artifact origin. |
error |
String | The traceback a failed point recorded. |
duration_s |
Float64 | t_point [s]: How long the point took up to this transition. |
Live runs¶
Never stored: live_runs builds these rows on every call from the owner.json of each runs/<run_id>.lock claim, so liveness is always current (src/femforge/telemetry/runs.py:187-215).
| Column | Type | Meaning |
|---|---|---|
run_id |
String | The run the claim is held on. |
host |
String | The machine holding the claim. |
pid |
Int32 | The id of the process holding the claim. |
started_at |
Datetime(us, UTC) | When the claim was taken. |
heartbeat_at |
Datetime(us, UTC) | When the holder last refreshed the claim. |
heartbeat_age_s |
Float64 | t_beat [s]: Time since the holder last refreshed the claim. |
stale |
Boolean | Whether the claim has gone without a heartbeat long enough for the next claimant to break it. |
Convergence¶
Read from one bundle's convergence.jsonl; per run and never centralized, so it has no partitions.
| Column | Type | Meaning |
|---|---|---|
wall_time |
Float64 | t_wall [s]: Time since the parser started reading the solver output. |
solver |
String | The sif Equation name Elmer tags the sample with, lowercased. |
loop |
Enum: NS, SS |
Which loop the sample belongs to. |
iter |
Int32 | One-based iteration count within the loop. |
nrm |
Float64 | ‖u‖₂ [?]: The solution's L₂ norm at this iteration. |
relc |
Float64 | Δ_rel [-]: Relative change of the norm from the previous iteration. |
The journal¶
Every log record and every sweep event goes first to a JSONL journal the writing process owns, live/<table>-<host>-<pid>.jsonl, and parquet receives it in batches (src/femforge/telemetry/journal.py:135-178). The journal is the write-ahead log: a viewer tailing a running sweep sees a record at once, and a killed process loses nothing it wrote.
| Step | Behavior | Where |
|---|---|---|
| write | append one JSON line and flush it, under a lock | src/femforge/telemetry/journal.py:146-155 |
| checkpoint | read the journal's records, append them to the store, truncate the journal | src/femforge/telemetry/journal.py:157-168 |
| close | close the handle and delete the empty file | src/femforge/telemetry/journal.py:170-178 |
| recovery | append the records of every journal whose process id, read from its file name, no longer runs, then delete it | src/femforge/telemetry/journal.py:97-132 |
A reader tolerates a half-written line. A journal is newline-delimited, so a torn tail costs at most one record (src/femforge/telemetry/journal.py:28-29). journal_lines keeps only the lines that parse as a JSON object and counts the rest, such as stray console output or a torn tail (src/femforge/telemetry/journal.py:45-69), and recovery logs that count as a warning. The kept lines go through read_ndjson with ignore_errors and coerce onto the table's schema (src/femforge/telemetry/journal.py:87-94).
Every new ParquetLogSink recovers the log journals dead processes left on its root (src/femforge/telemetry/sink.py:143), and every SweepJournal does the same for sweep events (src/femforge/telemetry/sweep.py:88-93). Live readers union both halves: scan_logs concatenates the parquet shards with the journals (src/femforge/telemetry/sink.py:200-204), and SweepQuery.events does the same for one sweep (src/femforge/telemetry/sweep.py:187-192).
The log sink and loguru¶
src/femforge/common/log.py is the only logging configuration site (src/femforge/common/log.py:1). setup_logging installs the terminal sink, routes the standard library's logging and warnings into loguru, gates chatty libraries such as build123d at the source, and installs the parquet sink when a root resolves (src/femforge/common/log.py:245-298):
| Setting | Resolution | Where |
|---|---|---|
| terminal level | level, else FEMFORGE_LOG_LEVEL, else INFO |
src/femforge/common/log.py:263 |
| parquet sink level | always DEBUG: the store is the searchable record, the terminal only the live view |
src/femforge/common/log.py:231-239 |
| sink order | the parquet sink goes in after logger.remove(), which would otherwise drop it |
src/femforge/common/log.py:297-298 |
| a repeat call | adjusts the terminal level, the library gates and the telemetry root, swapping the sink when the root moves | src/femforge/common/log.py:265-274, src/femforge/common/log.py:212-216 |
ff.log runs setup_logging with the defaults before its first record, so a script that never calls it still gets the terminal sink (src/femforge/common/log.py:358-375).
ParquetLogSink is a plain loguru sink callable (src/femforge/telemetry/sink.py:117-197):
- It writes each record to the journal at once and checkpoints into parquet every 500 records, every 5 s from a timer thread, and at exit through
atexit(src/femforge/telemetry/sink.py:44-48,src/femforge/telemetry/sink.py:150-197). The timer and the sink share one re-entrant lock, andcloseis idempotent. - It never logs into itself. polars and the standard library bridge can emit records from inside a flush, so the flushing thread carries a
FlushGuardflag and the sink drops what arrives on it (src/femforge/telemetry/sink.py:111-114,src/femforge/telemetry/sink.py:156-157). - It promotes
run_id,sweep_idandpoint_keyout of the bound fields into columns, so a query can push a filter on them down. Every other bound key becomes JSON inextra, with Rich renderables flattened to plain text at 120 columns (src/femforge/telemetry/sink.py:38-42,src/femforge/telemetry/sink.py:68-108). - It names the
processpartition after the script, folded to[a-z0-9_.-]because the name becomes a directory (src/femforge/telemetry/sink.py:50-65).
Two context managers route records:
| Context | Effect | Where |
|---|---|---|
telemetry_scope(root) |
binds a route key, so the records inside land in that root's own sink; one sink per folder lives for the process | src/femforge/common/log.py:378-413 |
run_log_context(run_id, dir) |
binds run_id and writes every record that carries it, at DEBUG and above, to the bundle's run.log |
src/femforge/common/log.py:416-446 |
Driver.evaluate wraps a whole evaluation in telemetry_scope(self.telemetry), so its records land in the driver's root and not the process default (src/femforge/run/driver.py:204-205).
A child process relays its records instead of writing its own. log_to_stdout_json replaces the terminal sink with one JSON line per record on stdout (src/femforge/common/log.py:320-328). The parent passes each output line to relay_record, which logs a JSON record at its own level with the child's module, function and line patched in, and any other line at DEBUG (src/femforge/common/log.py:338-355). The mesh worker works this way (src/femforge/meshing/backend.py:240).
The run and artifact indexes¶
An index turns directories of self-describing files into rows, and both indexes follow three rules.
The watermark lives in the rows. update on RunIndex takes one os.scandir over the runs directory and one stat per bundle, then anti-joins (run_id, manifest_mtime) against the rows it holds, so a pass over an unchanged directory parses nothing (src/femforge/telemetry/runs.py:329-388). ArtifactIndex does the same on (kind, name, dir_mtime) and reads a sidecar only for a directory that moved (src/femforge/telemetry/artifacts.py:134-188). update(rebuild=True) ignores the watermark, which replays a schema change. No catalog file sits beside the rows to keep consistent.
A manifest is read, never validated. A changed bundle's manifest is parsed with json.loads and a dozen keys are picked out of it, because validating the embedded config would cost more than the rest of the pass (src/femforge/telemetry/runs.py:91-98). Every key reads defensively, down to the number in a serialized quantity such as "370.9 K" without a unit registry (src/femforge/telemetry/runs.py:42-88), so one corrupt bundle costs one null and not the pass. The month partition is never null: a manifest without a readable date files under the modification time that got it read (src/femforge/telemetry/runs.py:148-149).
Rows are append-only, and the reader dedupes. RunQuery.frame sorts by indexed_at and keeps the last row per run_id (src/femforge/telemetry/runs.py:278-280); ArtifactQuery.frame keeps the last per (kind, key) (src/femforge/telemetry/artifacts.py:100-102). A run's second row supersedes its first, and nothing rewrites a shard.
The driver records its own runs. With a root, it appends a row when it first writes the manifest and another once the run has settled, so a running sweep is visible and a later scan finds nothing new (src/femforge/run/driver.py:315-316, src/femforge/run/driver.py:448). A failed write only warns, because telemetry never fails a solve (src/femforge/run/driver.py:376-385). current_sweep_point() supplies the sweep and point ids of a driver row (src/femforge/telemetry/runs.py:145), and a later scan row inherits them, because only the worker that solved the run knew them (src/femforge/telemetry/runs.py:390-410).
RunQuery and ArtifactQuery are fluent: each method returns a new query, and nothing is collected until frame(). runs() and records() return the real objects and skip whatever retention removed since (src/femforge/telemetry/runs.py:301-317). with_live() joins live_runs, because the claim files are the truth about what is in flight. femforge artifacts ls lists from the artifact index when a root resolves and the index holds rows, and scans the store otherwise (src/femforge/cli.py:273-276, src/femforge/telemetry/cli.py:445-462).
Sweep progress¶
SweepJournal writes one sweep (src/femforge/telemetry/sweep.py:67-170):
| Call | Writes |
|---|---|
start() |
the launch row, so a reader sees the sweep from its first point |
event() |
one point transition, through this process's own sweep-event journal |
checkpoint() |
this process's journalled events into parquet |
finish() |
the closing row with finished_at; then it checkpoints, closes its journal, recovers the journals of exited workers and compacts the sweep's shards into one |
The parent and every worker own a journal of their own, one flushed line per transition, so a killed worker's events survive it. sweep_point sets a context variable and binds both ids into the log context, so every record emitted under a point is attributable to it without the caller binding anything (src/femforge/telemetry/context.py:38-49).
SweepQuery reads one sweep back (src/femforge/telemetry/sweep.py:173-232). latest() is the last transition per point_key, and failures() keeps the failed points with the traceback each worker recorded. summary() counts the states, divides the points settled in the trailing 30 minutes, or over the sweep's whole life when it is younger, by that window to get a rate, and divides the outstanding points by the rate for an ETA (src/femforge/telemetry/sweep.py:27, src/femforge/telemetry/sweep.py:202-232). Sweep ids are the caller's.
Convergence¶
solve_case writes convergence.jsonl in the bundle, one flushed line per convergence sample, with exactly the keys of the convergence schema (src/femforge/elmer/runtime/solve.py:72-84). Immediately before the solve, the driver's run_solver stage writes convergence_targets.json from the solver stack's convergence_targets, the same mapping it hands the terminal renderer (src/femforge/run/driver.py:682-692). ConvergenceTargetsFile is that file's model, and a bundle without one reads as having no targets (src/femforge/telemetry/convergence.py:50-100). Both files belong to the logs retention class (src/femforge/common/files.py:89).
scan_convergence reads the log onto the convergence schema, skipping a malformed line, and derives two columns (src/femforge/telemetry/convergence.py:111-142):
| Column | Meaning |
|---|---|
first_relc |
the relative change at iteration 1 of each (solver, loop); iteration 1 resets it, so a nonlinear loop that restarts inside a steady-state sweep is measured from its own start |
progress |
the log-scale fraction of the decades between first_relc and the loop's tolerance already covered |
progress calls convergence_progress, the same pure function the terminal renderer uses, so a stored run and a live one report the same number (src/femforge/elmer/solver_log.py:105-119, src/femforge/elmer/solver_log.py:151-153).
Tail and tap¶
Two readers follow data that is still moving:
| Reader | Follows | Where |
|---|---|---|
| TextTail | one growing text file, such as a journal, a run.log or an elmersolver.log: a byte-offset cursor that holds back an unterminated last line and rewinds when the file shrinks |
src/femforge/telemetry/tail.py:15-50 |
| LiveLogTap | this process's own records: a loguru sink with a ring buffer of the last 5000 and one asyncio queue per subscriber | src/femforge/telemetry/tap.py:34-84 |
femforge logs tail runs one TextTail per log journal and re-reads them every 0.5 s with --follow (src/femforge/telemetry/cli.py:367-388). LiveLogTap builds the same row shape as the parquet sink, imports no trame, delivers to each subscriber's loop with call_soon_threadsafe, drops a record rather than block when a queue is full, and unsubscribes a subscriber whose loop has closed (src/femforge/telemetry/tap.py:46-65).
The CLI¶
src/femforge/telemetry/cli.py adds the index, runs, logs, sweeps and telemetry subcommands to the femforge command (src/femforge/telemetry/cli.py:148-230). Each resolves its root through --telemetry, else FEMFORGE_TELEMETRY, and fails with a message naming both (src/femforge/telemetry/cli.py:108-113). Every listing renders through frame_table, whose headers are the schema's column names, so a table cannot drift from the frame behind it (src/femforge/telemetry/cli.py:130-145). runs reap imports femforge.run only when it runs, because a listing never needs the solve stack (src/femforge/telemetry/cli.py:289-290). CLI lists every option.
What a consumer may rely on¶
| A consumer may rely on | Because |
|---|---|
| A shard, once in place, never changes | writers only add shards; compaction writes the merged shard before it deletes the originals |
| A reader never sees a half-written shard | every shard is staged under .tmp and moved into place |
| The latest row is the truth for an indexed entity | readers sort by indexed_at and keep the last row per run, artifact or sweep |
| A column missing from an older shard reads as null | HiveStore.scan coerces every shard onto the current schema |
| A record logged a moment ago is readable | it is in the writer's journal, and scan_logs and SweepQuery.events read the journals too |
| A claim's liveness is current | live_runs reads the claim files on every call and stores nothing |
| A consumer may not rely on | Because |
|---|---|
| One row per run, artifact or sweep in the raw scan | every pass appends; dedupe by the key and indexed_at |
| One shard per partition | each append adds shards until a compaction folds the settled ones |
| A shard path that stays on disk | a compaction deletes the shards it merged |
| A record in exactly one place during a checkpoint or a compaction | a checkpoint appends before it truncates the journal, and a compaction writes before it deletes, so a concurrent reader can see a record twice |
| Telemetry rows for every run | a failed telemetry write only warns, and a process without a root writes none |