You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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.
#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 testand 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.
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=(',',':'):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×, butyou 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
RateTrackerreturns nil on a first read by design(CLAUDE.md, "Counter vs gauge"). Row 0's
sensor.fan.1.speedis0and its GPUtemp is
37.19, both present. The distinction CSV supposedly cannot make isalready 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 askWhen
monitordopenssensors.<host>.<stamp>.csv, also writesensors.<host>.<stamp>.jsonbeside it, once, at open. Same basename, so thepairing 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:
these files into a benchmark (
evanwtf/local-llm#116) where the series is anexperimental 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
--versionfor the same reason from the otherside; this is the half that survives in the archive.
counts vary by machine — that is why
fancontroltells you to probe ratherthan assume — so a consumer globbing that directory meets different headers
with nothing explaining why.
columnsandfansexplain it.fansandsensorsmust 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, defaultcsv.jsonlis legal only when thedestination 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.
monitorctlalready does exactly this shape —Sources/monitorctl/main.swift,emit(_:)underasJSON— one object per sample,timestampplusmetric.rawValue: value. Reuse that shape. Two consumers disagreeing aboutwhat 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
nulland never0. That is the same answerRateTrackergives, and it is theone 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/--versionand a non-zero exit on an unknownflag, plus per-fan
sensor.fan.N.mode(auto/forced). This work lands inthe same argument parser. Do them together or not, your call — but a
--formatflag added to a parser that silently ignores unknown flags is a trap:--fromat jsonlwould 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 atcbb85ca, clean, and is thetarget repo for five benchmark tasks in
benchmarks/agent/tasks.toml(
swift-downsample-buckets,swift-csv-text,swift-chartaxis-spacing, andtwo more).
run.pyrefuses to start if that repo is dirty and verifiesbase_commit; thesource_repo_intacttripwire voids trials if it moves. Abuild there dirties it. Clone fresh somewhere outside
~/git—~/.local-llm-bench/work/monitor-jsonlis fine — fromorigin/main(
fe30192). NoteSources/monitorddoes not exist atcbb85ca; it is newerthan 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 testandswiftformat Sources Tests Plugins --lint --cache ignorebefore you call itdone. Both are CI gates.
@Suite,@Test,#expect), not XCTest.MonitorCorefree of macOS system APIs. The sidecar's encoding belongsin
MonitorCorewhere it is testable without a machine; only the reading ofthe sensor set belongs outside it.
monitorctlprints, because printing is its output — everything elselogs via
os.Logger.monitordwriting to stdout under--format jsonlis adeliberate exception and worth a comment saying so.
JSONSerializationorCodable, nothing else.Tests I want to see, because these are the failure modes:
columnsmatches the CSVheader exactly and in order — a drifting pair is worse than no sidecar.
.jsonwith its.csv, never one alone.--format jsonlwith a file destination refuses, non-zero, without starting.null, not0.values.
Not in scope
Changing the CSV row format, changing column names (
MetricIDis a stableon-disk key), compressing the rotating file, or a
--formatthat alters whatthe app writes. This is additive.