Skip to content

monitord: keep CSV, add a run.json sidecar and a stdout-only --format jsonl #49

Description

@evandhoffman

Decision first: CSV stays. Do not add a JSON file format.

I measured this rather than reasoning about it. Real file,
sensors.macbook-pro_local.2026_09_05_07_38_55.csv, 122 rows, 43 columns,
re-encoded as JSON Lines with numeric types and separators=(',',':'):

CSV JSONL
file 40,719 B 177,896 B (4.37×)
per row 326 B 1,458 B
projected 24 h @ 1 Hz 28.8 MB 126 MB
gzipped 11,851 B 14,898 B

1,128 of those 1,458 bytes are repeated key names — the same 43 strings,
86,400 times a day, carrying no information. This schema is CSV's best case:
flat, fixed, entirely numeric, no nesting, no optional structure, units already
in the header (sensor.temperature.cpu (°C)). Gzip closes the gap to 1.26×, but
you do not gzip a file you are still appending to.

I expected to argue the other way on type fidelity — CSV cannot tell null from
zero — and the file refuted me. 17 columns are empty on row 0 only: every
rate-derived column, because RateTracker returns nil on a first read by design
(CLAUDE.md, "Counter vs gauge"). Row 0's sensor.fan.1.speed is 0 and its GPU
temp is 37.19, both present. The distinction CSV supposedly cannot make is
already being made correctly.

So: three changes, none of which touch the rotating CSV's row format.


1. A sidecar run.json, one per CSV — the main ask

When monitord opens sensors.<host>.<stamp>.csv, also write
sensors.<host>.<stamp>.json beside it, once, at open. Same basename, so the
pairing needs no convention beyond the extension. Retention sweeps must delete
the pair together.

{
  "monitord_version": "1.6.0",       // MonitorVersion.string
  "commit": "cbb85ca",               // CommitStamp.describe
  "hostname": "macbook-pro_local",   // as sanitised into the filename
  "started_iso8601": "2026-09-05T11:38:55Z",
  "started_epoch_ms": 1788608335288,
  "interval_sec": 1.0,
  "retention": "24h",
  "csv": "sensors.macbook-pro_local.2026_09_05_07_38_55.csv",
  "columns": ["hostname", "time_iso8601", ...],   // verbatim, in order
  "fans": 2,                                       // sensors actually found
  "sensors": ["temperature.cpu", "temperature.gpu", ...]
}

Why this and not JSON rows. Two real problems, both solved for ~300 bytes
per run instead of 100 MB per day:

  • Provenance. Nothing in a CSV says which build wrote it. I am about to feed
    these files into a benchmark (evanwtf/local-llm #116) where the series is an
    experimental artifact, and "which monitord produced this" has to be answerable
    months later. monitord: --help, --version, and unknown flags all start the daemon instead of printing usage #48 asks for --version for the same reason from the other
    side; this is the half that survives in the archive.
  • Schema drift. The NOTES say several machines may share a directory. Fan
    counts vary by machine — that is why fancontrol tells you to probe rather
    than assume — so a consumer globbing that directory meets different headers
    with nothing explaining why. columns and fans explain it.

fans and sensors must record what was actually found on this machine,
not a compile-time list. A machine with no battery should say so by omission.

2. --format jsonl, on stdout only

--format csv|jsonl, default csv. jsonl is legal only when the
destination is stdout; combined with a rotating file it should refuse with a
usage error rather than quietly writing 126 MB a day.

This is where JSON genuinely earns its cost: piping into a log shipper, where
records interleave with other sources and each one must be self-describing.
Different destination, different use case.

monitorctl already does exactly this shape — Sources/monitorctl/main.swift,
emit(_:) under asJSON — one object per sample, timestamp plus
metric.rawValue: value. Reuse that shape. Two consumers disagreeing about
what a monitord record looks like is worse than having only one.

Keep its sparse behaviour: a metric with no reading is an absent key, never
null and never 0. That is the same answer RateTracker gives, and it is the
one place the JSON encoding is genuinely better than the CSV — do not throw it
away by filling in zeros.

3. While you are in the CLI: #48

#48 asks for --help / -h / --version and a non-zero exit on an unknown
flag, plus per-fan sensor.fan.N.mode (auto / forced). This work lands in
the same argument parser. Do them together or not, your call — but a
--format flag added to a parser that silently ignores unknown flags is a trap:
--fromat jsonl would start a daemon writing CSV and say nothing.


Constraints — read these before you touch a filesystem

Do not work in ~/git/monitor. It is pinned at cbb85ca, clean, and is the
target repo for five benchmark tasks in benchmarks/agent/tasks.toml
(swift-downsample-buckets, swift-csv-text, swift-chartaxis-spacing, and
two more). run.py refuses to start if that repo is dirty and verifies
base_commit; the source_repo_intact tripwire voids trials if it moves. A
build there dirties it. Clone fresh somewhere outside ~/git
~/.local-llm-bench/work/monitor-jsonl is fine — from origin/main
(fe30192). Note Sources/monitord does not exist at cbb85ca; it is newer
than the pin, so the pinned tree is not even the right code to read.

House rules, from that repo's own CLAUDE.md:

  • swift build && swift test and
    swiftformat Sources Tests Plugins --lint --cache ignore before you call it
    done. Both are CI gates.
  • Tests are swift-testing (@Suite, @Test, #expect), not XCTest.
  • Keep MonitorCore free of macOS system APIs. The sidecar's encoding belongs
    in MonitorCore where it is testable without a machine; only the reading of
    the sensor set belongs outside it.
  • Only monitorctl prints, because printing is its output — everything else
    logs via os.Logger. monitord writing to stdout under --format jsonl is a
    deliberate exception and worth a comment saying so.
  • No third-party dependencies. JSONSerialization or Codable, nothing else.

Tests I want to see, because these are the failure modes:

  • The sidecar names the CSV that actually exists, and columns matches the CSV
    header exactly and in order — a drifting pair is worse than no sidecar.
  • Retention deletes the .json with its .csv, never one alone.
  • --format jsonl with a file destination refuses, non-zero, without starting.
  • A metric absent from a batch is an absent key in JSONL — not null, not 0.
  • Round-trip: JSONL of a batch and the CSV row of the same batch carry the same
    values.

Not in scope

Changing the CSV row format, changing column names (MetricID is a stable
on-disk key), compressing the rotating file, or a --format that alters what
the app writes. This is additive.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions