Skip to content

monitor-exporter: native Prometheus /metrics for SMC/power (Swift, reuses MonitorSources) #58

Description

@evandhoffman

Goal

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.framework NWListener, 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

  1. Scope: the macOS gap (SMC + GPU) by default; --all mirrors CPU/memory/
    disk/network (deferred; disk/network as _total counters).
  2. Namespace: macos_smc_* for the sensors, macos_gpu_* for the GPU, per
    the table above. No node_* reuse — node_exporter owns those.
  3. Dependency + HTTP: no new dependency — exposition rendered in-house in a
    MonitorPrometheus target, NWListener for the single route.
  4. 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

  1. Target and library names: monitor-exporter binary, and does the mapping
    earn its own MonitorPrometheus library target or fold into an existing one?
  2. Whether --all ships in the first cut or waits until a host without
    node_exporter actually needs it (leaning: wait — YAGNI).

--opus

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

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions