Skip to content

fix(collector): keep collecting temperatures without CPU power status - #3767

Open
SaiPisey2 wants to merge 2 commits into
prometheus:masterfrom
SaiPisey2:fix/thermal-darwin-no-cpu-power-status
Open

SaiPisey2 wants to merge 2 commits into
prometheus:masterfrom
SaiPisey2:fix/thermal-darwin-no-cpu-power-status

Conversation

@SaiPisey2

Copy link
Copy Markdown
Contributor

Addresses #2906.

Apple Silicon does not implement IOPMCopyCPUPowerStatus, so fetchCPUPowerStatus gets back kIOReturnNotFound. Update returned that error straight away, which meant it never reached updateTemperatures, so no temperature metrics were collected at all. The error also isn't ErrNoData, so it was logged at error level on every scrape.

Measured on an M5 Pro (darwin/arm64) against master:

fetchCPUPowerStatus()      -> status=map[] err=no CPU power status has been recorded
Update()                   -> err=no CPU power status has been recorded ; metrics emitted=0
IsNoDataError(err)         -> false
updateTemperatures() alone -> err=<nil> ; temperature metrics available=52

52 usable temperature sensors on that machine, none of them exported, because an expected and unrelated condition aborted the collector.

Change

Treat kIOReturnNotFound as "this system does not report CPU power status" rather than a failure: skip the three CPU power metrics, log at debug level, and continue on to the temperature sensors. Any other non-success return code is still returned as an error, exactly as before, so systems that do report CPU power status are unaffected.

After the change, on the same machine, Update() returns no error and emits all 52 temperature metrics.

Scope

This does not make the CPU power metrics appear on Apple Silicon and does not resolve #2218 — the underlying API provides no data there, as @rexagod already established on #2906. It only stops their absence from suppressing the temperature metrics. I've left Closes #2906 out of the commit deliberately, since whether that issue is fully answered by this is a maintainer call.

Testing

Added collector/thermal_darwin_test.go, which fails on master:

--- FAIL: TestThermalUpdateWithoutCPUPowerStatus
    thermal_darwin_test.go:44: Update returned errNoCPUPowerStatus; a system
    without CPU power status must still collect temperatures

and passes with the change. Also verified locally:

  • go build ./...
  • go vet ./collector/
  • gofmt -l clean on both touched files
  • go test ./collector/ full package
  • go build -tags notherm ./collector/

@nicolastakashi

Copy link
Copy Markdown
Contributor

/workflow-approve

@nicolastakashi

Copy link
Copy Markdown
Contributor

The Darwin/macOS e2e job is failing on a fixture mismatch, node_scrape_collector_success{collector="thermal"} changed from 0 to 1. Can you regenerate the golden output on a Darwin/arm64 box and push it?

./end-to-end-test.sh -u

Then commit the updated collector/fixtures/e2e-output-darwin.txt.

Apple Silicon does not implement IOPMCopyCPUPowerStatus, so
fetchCPUPowerStatus returns kIOReturnNotFound there. Update returned that
error straight away, which aborted the collector before updateTemperatures
ran, so no temperature metrics were collected at all. The error was also
not ErrNoData, so it was logged at error level on every scrape.

On an M5 Pro, Update emitted 0 metrics and failed, while updateTemperatures
on its own returned 52 temperature metrics.

Treat kIOReturnNotFound as a system that does not report CPU power status:
skip the three CPU power metrics, log at debug level and carry on to the
temperature sensors. Any other non-success return code is still returned as
an error. Systems that do report CPU power status are unaffected.

This does not make the CPU power metrics available on Apple Silicon, since
the underlying API provides no data. It only stops their absence from
suppressing the temperature metrics.

Adds a regression test covering the case.

Signed-off-by: SaiPisey2 <piseysai0202@gmail.com>
@SaiPisey2
SaiPisey2 force-pushed the fix/thermal-darwin-no-cpu-power-status branch from 8d1e633 to 907bf2f Compare August 6, 2026 10:22
@SaiPisey2

Copy link
Copy Markdown
Contributor Author

Thanks for the review. Pushed, but regenerating the fixture turned up two things worth flagging rather than just committing the output.

The fixture change itself

I did not commit a regenerated file. Running ./end-to-end-test.sh -u on real hardware produced a 469-line diff, because this machine reports actual sensors while the CI runner reports none, and the values are live temperatures. So the fixture now carries only the single line the CI diff asked for:

-node_scrape_collector_success{collector="thermal"} 0
+node_scrape_collector_success{collector="thermal"} 1

./end-to-end-test.sh -u overwrites the Linux fixture on macOS

Worth knowing before anyone else follows the same instruction. The Darwin fixture path is built with ${fixture_metrics::-4}, and a negative substring length needs bash 4.2. macOS still ships bash 3.2, where that expansion fails, fixture_metrics keeps pointing at e2e-output.txt, and the update copies Darwin output over the Linux fixture. My first run rewrote 5330 lines of collector/fixtures/e2e-output.txt before I noticed. ${fixture_metrics%.txt} is equivalent and works on both, so I've changed it.

I also added node_thermal_temperature_celsius to non_deterministic_metrics. It is per-machine in both its sensor names and its values, so without it anyone running the suite on real hardware sees the whole sensor list as a diff. It makes no difference in CI, which reports no sensors.

(Unrelated and left alone: the sed -i /pattern/d calls in that loop need an extension argument on BSD sed, so the non-deterministic stripping aborts on stock macOS. It behaves the same on master, so it is not something this PR introduces.)

A real bug the fixture regeneration exposed

This is the part I would not have caught otherwise. Once temperatures are actually collected, the collector emits duplicate label sets:

emitted=52  uniqueSensorNames=17  duplicateSeries=35

and the scrape logs 35 error(s) occurred: ... was collected before with the same name and label values every time, with those samples dropped.

A sensor is identified only by its IOHID Product name, and several services report the same one. Probing the services directly, 76 temperature-capable services carry just 25 distinct Product values, and there is nothing to tell them apart: RegistryID, UniqueID, SerialNumber and Manufacturer are all unset, VendorID/ProductID/PrimaryUsage are constant across every service, and two services can share both Product and LocationID (PMU tdie2 at 1414541922 appears twice). There are also genuinely distinct sensors sharing a name — two gas gauge battery services with different LocationIDs.

Since there is no property that makes them addressable, I report the first reading per name and count the rest in a debug line. The endpoint now returns 200 with 17 unique series and no gather errors. TestThermalTemperaturesAreUnique covers it and fails without the change.

CI never sees this, since the runner has no sensors, but every Apple Silicon machine would have.

Verified locally: go build ./..., go vet ./collector/, gofmt clean, go test ./collector/ including 20 repeats of the thermal tests, and a -tags notherm build.

Happy to split the two end-to-end-test.sh changes into their own PR if you would rather keep this one to the collector.

@nicolastakashi

Copy link
Copy Markdown
Contributor

Thanks for the fixes. Let's split the end-to-end-test.sh changes into a separate PR.

On the duplicate sensors: dropping readings loses data. #3646 hit the same colliding-label problem for hwmon and fixed it by disambiguating the label instead (suffix it, falling back to something always unique when the first suffix still collides). Take a look there for the approach.

Collecting temperatures on Apple Silicon surfaced a second problem that
was previously unreachable, because Update returned before
updateTemperatures ever ran.

A sensor is labelled with its IOHID "Product" property, which is not
unique. On an M5 Pro, 52 temperature-reporting services carry only 17
distinct product names, so the registry rejects the colliding samples and
logs an error on every scrape.

The collisions are of two kinds. Six "gas gauge battery" services are
distinct sensors that merely share a name; they have distinct
LocationIDs. The "PMU tdie*" services come in threes that share both the
name and the LocationID, and report readings that differ by a few tenths
of a degree, so they are separate sensing elements rather than the same
one listed repeatedly. Dropping either kind loses readings.

Following the approach taken for hwmon in prometheus#3646, read all sensors first,
then qualify a name shared by several services with its location, falling
back to the service registry ID when the location does not separate them
either. Registry IDs are unique per service, which the OS guarantees and
which held for every service observed here. Names that do not collide are
left untouched.

The endpoint now reports all 52 readings with unique label sets and no
gather errors, where before the change 35 of them were rejected.

Also update collector/fixtures/e2e-output-darwin.txt for
node_scrape_collector_success{collector="thermal"}, which is now 1
because the collector no longer fails.

resolveSensorNames is kept free of cgo so that the labelling rules are
covered by table-driven tests, which matters because the CI runner
reports no thermal sensors at all.

Signed-off-by: SaiPisey2 <piseysai0202@gmail.com>
@SaiPisey2

Copy link
Copy Markdown
Contributor Author

Both done. The end-to-end-test.sh changes are now #3798, and this PR is back to the collector plus the one fixture line.

You were right that dropping loses data — more than I realised before looking properly. Probing the services directly, the collisions turn out to be two different things:

  • Six gas gauge battery services with six distinct LocationIDs. Genuinely different sensors that only share a product name.
  • Fifteen PMU * names, each reported by three services that share the product name and the LocationID. Those three are not the same reading twice: they have distinct registry IDs and report values a few tenths of a degree apart, so they are separate sensing elements.

So both kinds carry real readings, and the earlier "first one wins" would have thrown away 35 of 52.

Following #3646: read all sensors first, then qualify a colliding name with its location, falling back to the registry ID when the location does not separate them either. Names that do not collide are untouched.

IOHIDServiceClientGetRegistryID is the always-unique part — 52 distinct IDs for 52 services here, and 76 for 76 before the sub-absolute-zero readings are filtered. I had missed it earlier because I was looking for a RegistryID property, which is unset; it is a function on the service client.

Result on the same machine, where before the change 35 of 52 samples were rejected:

http=200  thermalSeries=52  uniqueLabelSets=52  gatherErrors=0
node_scrape_collector_success{collector="thermal"} 1
sensor="NAND CH0 temp"                        # unique, untouched
sensor="gas gauge battery_1413951554"         # ... and five more, by location
sensor="PMU tdie2_12724010287800361880"       # ... and two more, by registry ID

One deliberate choice worth flagging: the label resolution lives in resolveSensorNames, which takes a plain slice and has no cgo in it, so the rules are covered by table-driven tests — the collision-by-location case, the fallback, a missing location, a mix of both within one name, and the empty case. That matters here because the CI runner reports no thermal sensors, so anything that depends on real hardware does not actually get exercised there. The hardware-level check that no two label sets collide is still present as a separate test.

Registry IDs are not stable across reboots, so those labels will change if the machine restarts. That is the same trade-off as the hwmonX fallback in #3646, and it only affects sensors that cannot be told apart any other way.

Verified: go build ./..., go vet ./collector/, gofmt clean, go test ./collector/, -race, 30 repeats against live hardware, and a -tags notherm build.

@nicolastakashi

Copy link
Copy Markdown
Contributor

/workflow-approve

@SaiPisey2

Copy link
Copy Markdown
Contributor Author

Thanks for running the workflows.

The Darwin e2e failure is the runner rather than this change — the diff is only:

-node_disk_read_errors_total{device="disk4"} 0
-node_disk_write_errors_total{device="disk4"} 0

That runner has a disk4 the committed fixture does not, so the disk collector emits two extra series. Nothing in this PR touches the disk collector; the change is confined to the thermal collector and one line in the Darwin fixture.

Worth noting the same class of problem is why the fixture needs disk* treated as non-deterministic, the way node_thermal_temperature_celsius now is. Happy to send that separately if you would like it.

@SaiPisey2

Copy link
Copy Markdown
Contributor Author

@nicolastakashi anything you need from me here? The only failure is the Darwin e2e picking up the runner's extra disk4 series. The offer above stands: I can send the disk* non-determinism fix as its own PR first if that is the cleaner order.

fortmarek added a commit to tuist/tuist that referenced this pull request Sep 23, 2026
The spec said two lines (enable the thermal collector, widen the keep regex)
would get the throttle ratios fleet-wide on the pinned 1.8.2. Measured on
2026-09-23, that is wrong. The darwin thermal collector asks macOS for the
CPU power status before reading any sensor and returns when none is
recorded, which is the normal state of a healthy machine, so 1.8.2 and the
latest release, 1.12.1, both emit zero series. Apple Silicon does not
implement that API at all, so the ratios never exist on this fleet. The fix
is upstream and unmerged (prometheus/node_exporter#3767); built from source
it reported 45 sensors on an M3 Pro.

Mac temperatures therefore wait on that release, the bench test reads die
temperatures from a node_exporter built with the fix instead of ratios that
do not exist, and the scope no longer promises the two lines. The x86 nodes'
open question is answered: they join the cluster, the edge node reads through
its egress Service after #13546, and the switches report through the Omada
controller after #13545.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fortmarek added a commit to tuist/tuist that referenced this pull request Sep 23, 2026
…mal pressure

No released node_exporter reports a temperature on a healthy Apple silicon
host. Its darwin thermal collector asks macOS for the CPU power status first
and returns before reading any sensor when none is recorded, which is the
normal state of a machine that has never throttled. Measured on 1.8.2 (the
fleet's version) and 1.12.1 (the latest): zero thermal series. The upstream
fix, prometheus/node_exporter#3767, is unmerged, and shipping a patched build
would mean building node_exporter with cgo and the Apple SDK on a Mac, since
the operator image cross-builds everything from Linux. node_exporter also has
no fan or power reading on macOS, and a dead fan is the failure the rack
cares most about.

infra/macos-host-sensors is a small binary that calls IOKit and the SMC through
purego rather than cgo, so it cross-builds with CGO_ENABLED=0 in the operator
image like tart-kubelet and the log shipper. It samples once and writes
node_exporter's textfile collector a file with:

- macos_sensor_temperature_celsius{sensor}, the HID temperature services,
  hottest reading per name (Apple silicon lists each die sensor three times),
  so a name is one stable series across reboots
- macos_fan_speed_rpm, macos_fan_target_rpm and macos_fan_max_rpm per fan; a
  fan well below its target is failing
- macos_system_power_watts (SMC PSTR)
- macos_thermal_pressure_level, Apple's own 0-4 throttling summary

The bootstrap installs it as a launchd job that runs every 30 seconds, and
node_exporter now runs its textfile collector over /var/lib/tuist-host-sensors.
The readings ride the existing tuist-macos-node-exporter scrape: no port, no
egress Service, no tailnet grant. The step follows the log shipper's pattern:
re-signed in place, the plist compared before reloading, convergent so that a
missing binary uninstalls the job and its stale readings, run last on both the
bootstrap and drift paths, and hashed (script and binary) so the drift loop
delivers every change. The provider takes --host-sensors-binary-path, which the
chart passes beside node_exporter's under macosFleet.tailscale.enabled.

The macOS scrape keeps the new families and node_textfile_mtime_seconds (the
sample's age). macos_* is outside the families the non-production filter
drops, so every mini keeps it in every environment; rack minis also keep the
textfile series through the rack_host exemption.

This rolls to every Mac mini in every environment on the next drift pass,
production included: node_exporter restarts with the textfile collector on,
and the job is installed.

Validation, on an Apple silicon Mac:
- the binary built exactly as the image builds it (darwin/arm64,
  CGO_ENABLED=0) read 46 temperature services as 21 names, two fans, power and
  thermal pressure, and exited 0
- node_exporter 1.8.2 served the file through its textfile collector: 29
  series, node_textfile_scrape_error 0
- the eight served metric families pass the rendered macOS keep regex, and the
  replayed destination filter keeps them for staging rack minis and production
- go test and go vet for macos-host-sensors on darwin and linux, and for
  macos-host-bootstrap (new tests for the install, uninstall, gating, hash and
  the node_exporter flags; the per-host and hash-installer guards updated) and
  the provider's controllers
- helm template for staging and production renders the flag; the rendered
  Alloy configs parse with alloy fmt; the workflow gains a test job that vets
  and cross-builds the darwin reader

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SIGTRAP: trace trap on M1

2 participants