// ABOUTME: Accumulates system metrics samples across a collection run. // ABOUTME: Computes peak, average, and P95 statistics for CPU and memory on demand. package summary import ( "fmt" "sort" "time" "edp.buildth.ing/DevFW-CICD/forgejo-runner-resource-collector/internal/metrics" ) // Accumulator collects metric samples and computes run-level statistics type Accumulator struct { topN int cpuValues []float64 memBytesValues []float64 memPctValues []float64 processPeaks map[string]*ProcessPeak startTime time.Time endTime time.Time sampleCount int } // NewAccumulator creates an accumulator that tracks the top N processes func NewAccumulator(topN int) *Accumulator { return &Accumulator{ topN: topN, processPeaks: make(map[string]*ProcessPeak), } } // Add records a single metrics sample func (a *Accumulator) Add(m *metrics.SystemMetrics) { a.sampleCount++ if a.sampleCount == 1 { a.startTime = m.Timestamp } a.endTime = m.Timestamp a.cpuValues = append(a.cpuValues, m.CPU.TotalPercent) a.memBytesValues = append(a.memBytesValues, float64(m.Memory.UsedBytes)) a.memPctValues = append(a.memPctValues, m.Memory.UsedPercent) for _, p := range m.TopCPU { a.updateProcessPeak(p) } for _, p := range m.TopMemory { a.updateProcessPeak(p) } } // Summarize computes and returns the run summary, or nil if no samples were added func (a *Accumulator) Summarize() *RunSummary { if a.sampleCount == 0 { return nil } return &RunSummary{ StartTime: a.startTime, EndTime: a.endTime, DurationSeconds: a.endTime.Sub(a.startTime).Seconds(), SampleCount: a.sampleCount, CPUTotal: computeStats(a.cpuValues), MemUsedBytes: computeStats(a.memBytesValues), MemUsedPercent: computeStats(a.memPctValues), TopCPUProcesses: a.topProcesses(func(p *ProcessPeak) float64 { return p.PeakCPU }), TopMemProcesses: a.topProcesses(func(p *ProcessPeak) float64 { return float64(p.PeakMem) }), } } // SampleCount returns the number of samples added func (a *Accumulator) SampleCount() int { return a.sampleCount } // computeStats calculates peak, average, and P95 from a sorted copy of the values func computeStats(values []float64) StatSummary { n := len(values) if n == 0 { return StatSummary{} } sorted := make([]float64, n) copy(sorted, values) sort.Float64s(sorted) var sum float64 for _, v := range sorted { sum += v } p95Index := int(float64(n-1) * 0.95) return StatSummary{ Peak: sorted[n-1], Avg: sum / float64(n), P95: sorted[p95Index], } } // updateProcessPeak merges a process observation into the peak tracking map func (a *Accumulator) updateProcessPeak(p metrics.ProcessMetrics) { key := fmt.Sprintf("%d:%s", p.PID, p.Name) existing, ok := a.processPeaks[key] if !ok { a.processPeaks[key] = &ProcessPeak{ PID: p.PID, Name: p.Name, PeakCPU: p.CPUPercent, PeakMem: p.MemRSS, } return } if p.CPUPercent > existing.PeakCPU { existing.PeakCPU = p.CPUPercent } if p.MemRSS > existing.PeakMem { existing.PeakMem = p.MemRSS } } // topProcesses returns the top N processes sorted by the given key function (descending) func (a *Accumulator) topProcesses(keyFn func(*ProcessPeak) float64) []ProcessPeak { all := make([]ProcessPeak, 0, len(a.processPeaks)) for _, p := range a.processPeaks { all = append(all, *p) } sort.Slice(all, func(i, j int) bool { return keyFn(&all[i]) > keyFn(&all[j]) }) if len(all) > a.topN { all = all[:a.topN] } return all }