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 = ¤t 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 }