Files
ITWorx Pulse release export bd774932d5
Public source validation / validate (push) Failing after 3m8s
Publish ITWorx Pulse source
2026-09-03 02:09:19 +02:00

181 lines
4.9 KiB
Go

package hostcollect
import (
"sort"
"strconv"
"strings"
"time"
"github.com/itworx/pulse/internal/host"
)
// cpuTimes holds the three aggregates a utilisation delta needs, all in USER_HZ ticks.
type cpuTimes struct {
total uint64
idle uint64
iowait uint64
}
type cpuSample struct {
at time.Time
all cpuTimes
cores []cpuTimes
valid bool
}
// parseProcStat reads the cpu aggregate and per-core lines of /proc/stat.
//
// Column layout (proc(5)): user nice system idle iowait irq softirq steal guest
// guest_nice. guest and guest_nice are deliberately excluded from the total: the
// kernel already counts guest time inside user and nice, so including them again
// inflates the denominator and understates utilisation. Old kernels publish fewer
// columns, so anything past idle is optional.
func parseProcStat(data []byte) (cpuSample, error) {
sample := cpuSample{}
type indexedCore struct {
index int
times cpuTimes
}
cores := make([]indexedCore, 0, 8)
for _, line := range lines(data) {
if !strings.HasPrefix(line, "cpu") {
continue
}
fields := strings.Fields(line)
if len(fields) < 5 {
continue
}
times, ok := parseCPUTimes(fields[1:])
if !ok {
continue
}
if fields[0] == "cpu" {
sample.all = times
sample.valid = true
continue
}
index, err := strconv.Atoi(strings.TrimPrefix(fields[0], "cpu"))
if err != nil || index < 0 {
continue
}
cores = append(cores, indexedCore{index: index, times: times})
}
if !sample.valid {
return cpuSample{}, errNoCPUAggregate
}
sort.Slice(cores, func(i, j int) bool { return cores[i].index < cores[j].index })
sample.cores = make([]cpuTimes, 0, len(cores))
for _, core := range cores {
sample.cores = append(sample.cores, core.times)
}
return sample, nil
}
// parseCPUTimes sums the first eight jiffy columns; a non-numeric column makes the
// whole line untrustworthy.
func parseCPUTimes(fields []string) (cpuTimes, bool) {
times := cpuTimes{}
limit := len(fields)
if limit > 8 {
limit = 8
}
for index := 0; index < limit; index++ {
value, ok := parseUint(fields[index])
if !ok {
return cpuTimes{}, false
}
times.total += value
switch index {
case 3:
times.idle = value
case 4:
times.iowait = value
}
}
return times, true
}
// cpuUsage samples /proc/stat and reports utilisation relative to the previous sample.
// The first call after start (or after a counter reset) reports no percentages at all
// rather than a fabricated value: an absolute counter carries no utilisation.
func (c *Collector) cpuUsage(now time.Time, warnings *warningSet) host.RawCPU {
data, err := readLimited(c.procPath("stat"), maxFileBytes)
if err != nil {
warnings.add(WarningCPUUnavailable)
return host.RawCPU{}
}
current, err := parseProcStat(data)
if err != nil {
warnings.add(WarningCPUUnavailable)
return host.RawCPU{}
}
current.at = now
c.mu.Lock()
previous := c.cpu
c.cpu = &current
c.mu.Unlock()
if previous == nil || !previous.valid {
warnings.add(WarningCPUFirstSample)
return host.RawCPU{}
}
busy, iowait, ok := utilisation(previous.all, current.all)
if !ok {
warnings.add(WarningCPUCounterReset)
return host.RawCPU{}
}
result := host.RawCPU{TotalPercent: &busy, IOWaitPercent: &iowait}
if len(previous.cores) != len(current.cores) {
warnings.add(WarningCPUTopologyChanged)
return result
}
cores := make([]float64, 0, len(current.cores))
for index := range current.cores {
corePercent, _, coreOK := utilisation(previous.cores[index], current.cores[index])
if !coreOK {
warnings.add(WarningCPUCounterReset)
return result
}
cores = append(cores, corePercent)
}
if len(cores) > c.options.HostLimits.MaxCores {
cores = cores[:c.options.HostLimits.MaxCores]
warnings.add(WarningCPUCoresTruncated)
}
result.PerCore = cores
return result
}
// utilisation converts two counter readings into busy and iowait percentages. Any
// counter going backwards means the counter wrapped or the source was replaced (a
// container restart, a CPU hot-unplug, a fixture root swap); the delta is then
// meaningless and is reported as unusable instead of as a huge or negative spike.
func utilisation(previous, current cpuTimes) (busyPercent, iowaitPercent float64, ok bool) {
if current.total < previous.total || current.idle < previous.idle || current.iowait < previous.iowait {
return 0, 0, false
}
totalDelta := current.total - previous.total
if totalDelta == 0 {
return 0, 0, false
}
idleDelta := current.idle - previous.idle
iowaitDelta := current.iowait - previous.iowait
if idleDelta+iowaitDelta > totalDelta {
return 0, 0, false
}
busy := float64(totalDelta-idleDelta-iowaitDelta) / float64(totalDelta) * 100
iowait := float64(iowaitDelta) / float64(totalDelta) * 100
return clampPercent(busy), clampPercent(iowait), true
}
func clampPercent(value float64) float64 {
if value < 0 {
return 0
}
if value > 100 {
return 100
}
return value
}