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
Add a monitor-exporter executable that serves the SMC and power sensors — and
the rest of what MonitorSources already reads — as Prometheus metrics, so an
M5 Max gets historic retention, Grafana and alerting.
This is the native-Swift answer to evanwtf/local-llm#361, which
debated two ways to get these sensors into Prometheus: wrap macmon (Python
textfile job or Go), or reimplement Apple's IOReport/IOKit in Go. Both exist
only to obtain sensor readers. This repo already has them — MonitorSources reads CPU, memory, disk, network, GPU and the SMC through
native mach/IOKit/sysctl, and the readers are tested and shipping. So the
cheapest durable exporter reuses that code here, in the language the repo is
already written in. No macmon dependency, no CGo against undocumented Apple
APIs, and no Go added to a Python-preferred tree.
Why a fourth binary, not a flag on monitord
monitord writes rotating CSV on a fixed clock; the exporter answers a scrape
at scrape time. Different clock, different consumer, different failure mode.
Keeping them separate holds the same boundary the package already enforces
between monitor (no disk) and monitord (the disk logger): one binary, one
job. The exporter links MonitorSources and MonitorCore, never MonitorLog
or MonitorStore.
Approach — own port (recommended)
Serve GET /metrics over HTTP and let Prometheus scrape it directly, the way
node_exporter itself works. The daemon reads the sensors at scrape time, so
the scrape interval is the sampling cadence — no timer to configure.
Why this over the textfile-collector relay:
A dead exporter fails the scrape, so Prometheus surfaces it as up == 0.
The textfile path serves the last temperature forever and looks healthy until
a separate mtime-staleness alert fires.
No launchd timer, no atomic temp-file + os.replace dance, no per-file
staleness alert to maintain. #361's whole correctness section (skip-absent-
keys, one # HELP/# TYPE per metric, atomic write) exists only because of
the textfile format. Own-port deletes that class of bug.
One long-running process instead of a spawn per interval.
Textfile fallback (documented, not built first)
node_exporter is not on the Mac yet but is coming. Its textfile collector is the
one reason to keep the relay in mind: if inbound scrape of a new port is ever
awkward, monitor-exporter --textfile <dir> could write an atomic .prom into
node_exporter's directory instead of listening. Same rendering code, different
sink. Build the listener first; add the textfile sink only if reachability
forces it.
Metrics
The real monitord CSV columns are the source of truth for the metric set (see
the sample-data comment on this issue), not the guesses in #361.
Scope: the macOS gap by default, --all to mirror the rest. node_exporter
is coming to this Mac and its darwin collectors already read CPU, memory, disk
and network — the first ~28 CSV columns. What node_exporter does not read on
macOS is the SMC (temperature, fans, power) and the GPU. So the default export
is that gap; a running node_exporter covers the rest, and no series is scraped
twice under two names. --all also exposes CPU/memory/disk/network for a host
with no node_exporter — deferred, and its disk/network series must be
cumulative counters (_total), not monitord's pre-differenced rates, so rate() works.
Default (gap) set — all gauges, all instantaneous:
monitord column
Prometheus metric
sensor.temperature.{cpu,gpu,storage,battery,enclosure} °C
macos_smc_temperature_celsius{sensor="…"}
sensor.fan.{1,2}.speed rpm
`macos_smc_fan_rpm{fan="1
sensor.power.{input,soc} W
`macos_smc_power_watts{rail="input
gpu.utilization fraction
macos_gpu_utilization_ratio (0–1)
gpu.vram.used B
macos_gpu_vram_used_bytes
Plus monitor_exporter_build_info{commit,version} 1 from the existing StampCommit stamp, so a scrape says which build answered.
Two rules the CSV forces:
One canonical unit. The CSV carries every temperature in both °C and °F
because a flat file must stand alone; the exporter emits _celsius only and
Grafana converts. Exporting °F too is a redundant series.
No hostname label. The scrape target's instance already identifies
the Mac; a hostname label duplicates it and fights federation.
Dependencies
None new. The repo keeps its single Apple dependency
(swift-argument-parser), and this ships against MonitorCore, MonitorSources and Network.framework only.
Exposition format: rendered in-house in a MonitorPrometheus target
(pure MonitorCore). The default set is entirely simple gauges, and the
gauge text format is small and frozen — # HELP / # TYPE … gauge / a value
line — so a golden-file test pins it exactly. A metric library
(swift-server/swift-prometheus) was considered and set aside: its API
shifts across versions and it pulls swift-nio transitively, which is a lot of
surface for a handful of gauges. Reach for it only if --all later adds
counters and histograms. (The monitord: --help, --version, and unknown flags all start the daemon instead of printing usage #48 lesson is about parsing input, where
ambiguity bites; this is rendering fixed output, which a golden test nails.)
HTTP: Network.frameworkNWListener, no dependency. One read-only GET /metrics route, so a thin Apple-native listener is enough. Hummingbird
is the fallback only if hand-rolled HTTP/1.1 response framing proves fiddly.
Correctness and testing
The MonitorPrometheus renderer is pure and lives on MonitorCore only: a Sample plus its MetricDescriptor in, an exposition line out. Golden-file
test for the format, and a mapping test that each sensor MetricID produces
the right metric name, labels and unit — both with fake readings, no hardware.
The HTTP handler is tested directly: GET /metrics returns 200 with the
Prometheus content type and the rendered body; any other path returns 404.
A failed reading is a gap, never a zero — the same rule the app follows.
Skip the sample and let the scrape omit that series, so an idle machine and a
broken sensor do not read alike.
CLI parsing (--bind-port, --bind-address, later --textfile) via swift-argument-parser with a CommandLineTests case, matching how monitord/monitorctl are covered — monitord: --help, --version, and unknown flags all start the daemon instead of printing usage #48's front-door bug was invisible to
every other suite. Default port 9650: verified unallocated in the
Prometheus default-port-allocations registry and clear of the dense exporter
band at 9100–9130 (node=9100, blackbox=9110). It is a local default, not a
public registration — the registry asks projects not to squat FREE slots — so
it stays overridable. Default bind address is loopback; --bind-address 0.0.0.0 opens it to the LAN for a remote Prometheus.
A --help smoke run in CI, like the other binaries.
Deployment
Ships in the release zip as a bare binary, next to monitord.
Runs as a launchd LaunchAgent.
Prometheus adds a scrape target for the Mac's port; the scrape interval is the
sample rate. node_exporter arriving on the Mac is independent — the exporter
does not need it in own-port mode.
Constraint
Do not run the exporter during a live thermal benchmark — it adds a process and
load to the exact thing under measurement (carried over from #361). It touches
no git state, so it will not void a run through harness_dirty.
Resolved
Scope: the macOS gap (SMC + GPU) by default; --all mirrors CPU/memory/
disk/network (deferred; disk/network as _total counters).
Namespace:macos_smc_* for the sensors, macos_gpu_* for the GPU, per
the table above. No node_* reuse — node_exporter owns those.
Dependency + HTTP: no new dependency — exposition rendered in-house in a MonitorPrometheus target, NWListener for the single route.
Port + bind:--bind-port default 9650 (verified free in the Prometheus
default-port-allocations registry), --bind-address default loopback, 0.0.0.0 for a remote Prometheus. Auth is out of scope for a read-only
endpoint on a trusted network.
Open questions
Target and library names: monitor-exporter binary, and does the mapping
earn its own MonitorPrometheus library target or fold into an existing one?
Whether --all ships in the first cut or waits until a host without
node_exporter actually needs it (leaning: wait — YAGNI).
Goal
Add a
monitor-exporterexecutable that serves the SMC and power sensors — andthe rest of what
MonitorSourcesalready reads — as Prometheus metrics, so anM5 Max gets historic retention, Grafana and alerting.
This is the native-Swift answer to
evanwtf/local-llm#361, which
debated two ways to get these sensors into Prometheus: wrap
macmon(Pythontextfile job or Go), or reimplement Apple's
IOReport/IOKit in Go. Both existonly to obtain sensor readers. This repo already has them —
MonitorSourcesreads CPU, memory, disk, network, GPU and the SMC throughnative mach/IOKit/sysctl, and the readers are tested and shipping. So the
cheapest durable exporter reuses that code here, in the language the repo is
already written in. No
macmondependency, no CGo against undocumented AppleAPIs, and no Go added to a Python-preferred tree.
Why a fourth binary, not a flag on monitord
monitordwrites rotating CSV on a fixed clock; the exporter answers a scrapeat scrape time. Different clock, different consumer, different failure mode.
Keeping them separate holds the same boundary the package already enforces
between
monitor(no disk) andmonitord(the disk logger): one binary, onejob. The exporter links
MonitorSourcesandMonitorCore, neverMonitorLogor
MonitorStore.Approach — own port (recommended)
Serve
GET /metricsover HTTP and let Prometheus scrape it directly, the waynode_exporter itself works. The daemon reads the sensors at scrape time, so
the scrape interval is the sampling cadence — no timer to configure.
Why this over the textfile-collector relay:
up == 0.The textfile path serves the last temperature forever and looks healthy until
a separate mtime-staleness alert fires.
os.replacedance, no per-filestaleness alert to maintain. #361's whole correctness section (skip-absent-
keys, one
# HELP/# TYPEper metric, atomic write) exists only because ofthe textfile format. Own-port deletes that class of bug.
Textfile fallback (documented, not built first)
node_exporter is not on the Mac yet but is coming. Its textfile collector is the
one reason to keep the relay in mind: if inbound scrape of a new port is ever
awkward,
monitor-exporter --textfile <dir>could write an atomic.promintonode_exporter's directory instead of listening. Same rendering code, different
sink. Build the listener first; add the textfile sink only if reachability
forces it.
Metrics
The real
monitordCSV columns are the source of truth for the metric set (seethe sample-data comment on this issue), not the guesses in #361.
Scope: the macOS gap by default,
--allto mirror the rest. node_exporteris coming to this Mac and its darwin collectors already read CPU, memory, disk
and network — the first ~28 CSV columns. What node_exporter does not read on
macOS is the SMC (temperature, fans, power) and the GPU. So the default export
is that gap; a running node_exporter covers the rest, and no series is scraped
twice under two names.
--allalso exposes CPU/memory/disk/network for a hostwith no node_exporter — deferred, and its disk/network series must be
cumulative counters (
_total), notmonitord's pre-differenced rates, sorate()works.Default (gap) set — all gauges, all instantaneous:
monitordcolumnsensor.temperature.{cpu,gpu,storage,battery,enclosure}°Cmacos_smc_temperature_celsius{sensor="…"}sensor.fan.{1,2}.speedrpmsensor.power.{input,soc}Wgpu.utilizationfractionmacos_gpu_utilization_ratio(0–1)gpu.vram.usedBmacos_gpu_vram_used_bytesPlus
monitor_exporter_build_info{commit,version} 1from the existingStampCommitstamp, so a scrape says which build answered.Two rules the CSV forces:
because a flat file must stand alone; the exporter emits
_celsiusonly andGrafana converts. Exporting °F too is a redundant series.
hostnamelabel. The scrape target'sinstancealready identifiesthe Mac; a hostname label duplicates it and fights federation.
Dependencies
None new. The repo keeps its single Apple dependency
(
swift-argument-parser), and this ships againstMonitorCore,MonitorSourcesandNetwork.frameworkonly.MonitorPrometheustarget(pure
MonitorCore). The default set is entirely simple gauges, and thegauge text format is small and frozen —
# HELP/# TYPE … gauge/ a valueline — so a golden-file test pins it exactly. A metric library
(
swift-server/swift-prometheus) was considered and set aside: its APIshifts across versions and it pulls swift-nio transitively, which is a lot of
surface for a handful of gauges. Reach for it only if
--alllater addscounters and histograms. (The monitord: --help, --version, and unknown flags all start the daemon instead of printing usage #48 lesson is about parsing input, where
ambiguity bites; this is rendering fixed output, which a golden test nails.)
Network.frameworkNWListener, no dependency. One read-onlyGET /metricsroute, so a thin Apple-native listener is enough. Hummingbirdis the fallback only if hand-rolled HTTP/1.1 response framing proves fiddly.
Correctness and testing
MonitorPrometheusrenderer is pure and lives onMonitorCoreonly: aSampleplus itsMetricDescriptorin, an exposition line out. Golden-filetest for the format, and a mapping test that each sensor
MetricIDproducesthe right metric name, labels and unit — both with fake readings, no hardware.
GET /metricsreturns 200 with thePrometheus content type and the rendered body; any other path returns 404.
Skip the sample and let the scrape omit that series, so an idle machine and a
broken sensor do not read alike.
--bind-port,--bind-address, later--textfile) viaswift-argument-parserwith aCommandLineTestscase, matching howmonitord/monitorctlare covered — monitord: --help, --version, and unknown flags all start the daemon instead of printing usage #48's front-door bug was invisible toevery other suite. Default port 9650: verified unallocated in the
Prometheus default-port-allocations registry and clear of the dense exporter
band at 9100–9130 (node=9100, blackbox=9110). It is a local default, not a
public registration — the registry asks projects not to squat FREE slots — so
it stays overridable. Default bind address is loopback;
--bind-address 0.0.0.0opens it to the LAN for a remote Prometheus.--helpsmoke run in CI, like the other binaries.Deployment
monitord.LaunchAgent.sample rate. node_exporter arriving on the Mac is independent — the exporter
does not need it in own-port mode.
Constraint
Do not run the exporter during a live thermal benchmark — it adds a process and
load to the exact thing under measurement (carried over from #361). It touches
no git state, so it will not void a run through
harness_dirty.Resolved
--allmirrors CPU/memory/disk/network (deferred; disk/network as
_totalcounters).macos_smc_*for the sensors,macos_gpu_*for the GPU, perthe table above. No
node_*reuse — node_exporter owns those.MonitorPrometheustarget,NWListenerfor the single route.--bind-portdefault 9650 (verified free in the Prometheusdefault-port-allocations registry),
--bind-addressdefault loopback,0.0.0.0for a remote Prometheus. Auth is out of scope for a read-onlyendpoint on a trusted network.
Open questions
monitor-exporterbinary, and does the mappingearn its own
MonitorPrometheuslibrary target or fold into an existing one?--allships in the first cut or waits until a host withoutnode_exporter actually needs it (leaning: wait — YAGNI).
--opus