fix(truenas): reject synthesised CPU temperature aggregate

TrueNAS SCALE on AMD reports the bare `cpu` cputemp legend series at roughly 1.5x the hottest per-core reading, and the provider trusted that mapped `cpu_package` value unconditionally, firing an 80C threshold alert at a real ~60C. Compare `cpu_package` against the hottest `cpu*` per-core entry and fall back to the per-core max only when the package value exceeds it by more than 20%. Package-only and per-core-only payloads keep their existing behaviour. Adds focused provider coverage and records the selection contract for #2122.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-09-19 22:42:13 +01:00
parent 9459ec7f26
commit 9a1cfa4c40
3 changed files with 119 additions and 10 deletions
@@ -4120,3 +4120,23 @@ still recorded. Focused proof lives in
(`TestMergeHostAgentSMARTIntoDisks_AgentWearoutDoesNotHideLowPVELife` and
`TestMergeHostAgentSMARTIntoDisks_AgentWearoutFillsUnreportedPVELife`). This is
synthetic merge evidence, not reporter acceptance or hardware confirmation.
### TrueNAS system temperature rejects a synthesised CPU aggregate
TrueNAS reports the `cputemp` series through a bare `cpu` legend entry, which
`canonicalSystemTemperatureKey` maps to the canonical `cpu_package` key. On
SCALE 25.10.7 running AMD hardware that aggregate has been observed at roughly
1.5x the hottest per-core reading (for example an 88C aggregate against a 59C
core max, with the cores matching `k10temp`/`Tctl`). Monitoring previously
trusted `cpu_package` unconditionally in `maxTrueNASSystemTemperature`, so the
false-high value reached the host temperature and fired a configured 80C
threshold alert at a real ~60C. The selector now compares `cpu_package` with
the hottest `cpu*` per-core entry and falls back to the per-core max only when
the package value exceeds it by more than 20%. A real package sensor stays
within a few degrees of the hottest core, so the margin retains genuine
package readings while discarding the synthesised aggregate. Package-only
payloads and per-core-only payloads keep their existing behaviour. Focused
proof lives in `internal/truenas/provider_test.go`
(`TestMaxTrueNASSystemTemperaturePrefersCoresOverSynthesisedAggregate`). This
is source-level proof, not reporter acceptance or appliance confirmation
(#2122).
+28 -10
View File
@@ -1032,31 +1032,49 @@ func agentDataFromTrueNASSystem(connectionID string, system SystemInfo, disks []
return agent
}
// trueNASCPUPackageOutlierRatio bounds how far the aggregated `cpu_package`
// reading may exceed the hottest individual core before Pulse treats it as a
// synthesised appliance aggregate. TrueNAS SCALE on AMD has been observed to
// report a bare `cpu` legend series roughly 1.5x the per-core values, while a
// real package sensor (for example Intel "Package id 0") stays within a few
// degrees of the hottest core. See #2122.
const trueNASCPUPackageOutlierRatio = 1.2
func maxTrueNASSystemTemperature(system SystemInfo) *float64 {
if len(system.TemperatureCelsius) == 0 {
return nil
}
packageValue := 0.0
hasPackage := false
if value, ok := system.TemperatureCelsius["cpu_package"]; ok && value > 0 {
canonical := value
return &canonical
packageValue = value
hasPackage = true
}
var best float64
found := false
var coreMax float64
hasCore := false
for key, value := range system.TemperatureCelsius {
key = strings.TrimSpace(strings.ToLower(key))
if value <= 0 || !strings.HasPrefix(key, "cpu") {
if value <= 0 || key == "cpu_package" || !strings.HasPrefix(key, "cpu") {
continue
}
if !found || value > best {
best = value
found = true
if !hasCore || value > coreMax {
coreMax = value
hasCore = true
}
}
if !found {
if hasPackage {
if hasCore && packageValue > coreMax*trueNASCPUPackageOutlierRatio {
return &coreMax
}
return &packageValue
}
if !hasCore {
return nil
}
return &best
return &coreMax
}
func sensorMetaFromTrueNASSystem(system SystemInfo, disks []Disk) *unifiedresources.HostSensorMeta {
+71
View File
@@ -1434,3 +1434,74 @@ func TestProviderSystemMetricHistoryUsesCanonicalAgentMetricIDs(t *testing.T) {
t.Fatalf("expected netin history points, got %+v", history)
}
}
func TestMaxTrueNASSystemTemperaturePrefersCoresOverSynthesisedAggregate(t *testing.T) {
cases := []struct {
name string
temps map[string]float64
want *float64
}{
{
name: "synthesised cpu aggregate is ignored in favour of per-core max",
temps: map[string]float64{
"cpu0": 59, "cpu1": 58, "cpu2": 57, "cpu3": 56,
"cpu4": 55, "cpu5": 54, "cpu6": 53, "cpu7": 52,
"cpu_package": 88,
},
want: floatPtr(59),
},
{
name: "real package reading near the hottest core is retained",
temps: map[string]float64{"cpu_package": 61.5, "cpu_core_0": 58.0, "cpu_core_1": 59.0},
want: floatPtr(61.5),
},
{
name: "package within the margin is retained even with one core present",
temps: map[string]float64{"cpu_package": 60, "cpu0": 55},
want: floatPtr(60),
},
{
name: "package exactly at the margin is retained",
temps: map[string]float64{"cpu_package": 66, "cpu0": 55},
want: floatPtr(66),
},
{
name: "package-only payload is unchanged",
temps: map[string]float64{"cpu_package": 55},
want: floatPtr(55),
},
{
name: "per-core-only payload still reports the hottest core",
temps: map[string]float64{"cpu0": 41, "cpu1": 47, "cpu2": 44},
want: floatPtr(47),
},
{
name: "empty payload has no temperature",
temps: nil,
want: nil,
},
{
name: "non-positive readings are ignored",
temps: map[string]float64{"cpu_package": 0, "cpu0": 0},
want: nil,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := maxTrueNASSystemTemperature(SystemInfo{TemperatureCelsius: tc.temps})
switch {
case tc.want == nil && got != nil:
t.Fatalf("temperature = %v, want nil", *got)
case tc.want != nil && got == nil:
t.Fatalf("temperature = nil, want %v", *tc.want)
case tc.want != nil && got != nil && *got != *tc.want:
t.Fatalf("temperature = %v, want %v", *got, *tc.want)
}
})
}
}
func floatPtr(value float64) *float64 {
return &value
}