Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 97 additions & 2 deletions metric/system/cgroup/cgv2/cpu.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"

"github.com/elastic/elastic-agent-libs/opt"
"github.com/elastic/elastic-agent-system-metrics/metric/system/cgroup/cgcommon"
Expand All @@ -35,7 +37,32 @@ type CPUSubsystem struct {
// Shows pressure stall information for CPU.
Pressure map[string]cgcommon.Pressure `json:"pressure,omitempty" struct:"pressure,omitempty"`
// Stats shows overall counters for the CPU controller
Stats CPUStats
Stats CPUStats `json:"stats" struct:"stats"`
// CFS contains CPU bandwidth control settings.
CFS CFS `json:"cfs,omitzero" struct:"cfs,omitempty"`
}

// CFS contains CPU bandwidth control settings from cgroup v2 (cpu.max and cpu.weight).
// This is equivalent to the CFS struct in cgroups v1, but uses Weight instead of Shares.
type CFS struct {
// Period in microseconds for how regularly the cgroup's access to CPU resources is reallocated.
Period UsOpt `json:"period,omitzero" struct:"period,omitempty"`
// Quota in microseconds for which all tasks in the cgroup can run during one period.
// A value of 0 indicates unlimited (cpu.max "max").
Quota UsOpt `json:"quota,omitzero" struct:"quota,omitempty"`
// Relative CPU weight (1-10000, default 100). This replaces cpu.shares from cgroups v1.
Weight opt.Uint `json:"weight,omitzero" struct:"weight,omitempty"`
}

// UsOpt wraps opt.Uint for optional microsecond values.
// Analogous to opt.BytesOpt; when unset, serializes as nil/omitted rather than 0.
type UsOpt struct {
Us opt.Uint `json:"us" struct:"us"`
}

// IsZero returns true when the value has not been set.
func (u UsOpt) IsZero() bool {
return u.Us.IsZero()
}

// CPUStats carries the information from the cpu.stat cgroup file
Expand All @@ -59,7 +86,7 @@ func (t ThrottledField) IsZero() bool {
return t.Us.IsZero() && t.Periods.IsZero()
}

// Get fetches memory subsystem metrics for V2 cgroups
// Get fetches CPU subsystem metrics for V2 cgroups
func (cpu *CPUSubsystem) Get(path string) error {

var err error
Expand All @@ -77,6 +104,11 @@ func (cpu *CPUSubsystem) Get(path string) error {
return fmt.Errorf("error fetching CPU stat data: %w", err)
}

cpu.CFS, err = getCFS(path)
if err != nil {
return fmt.Errorf("error fetching CFS data: %w", err)
}

return nil
}

Expand Down Expand Up @@ -116,3 +148,66 @@ func getStats(path string) (CPUStats, error) {

return data, nil
}

// parseCPUMax parses "quota period" from cpu.max.
// quota may be "max" (unlimited) or an integer in microseconds; "max" maps to 0.
func parseCPUMax(path string) (quota, period uint64, err error) {
contents, err := os.ReadFile(filepath.Join(path, "cpu.max"))
if err != nil {
return 0, 0, err
}

fields := strings.Fields(strings.TrimSpace(string(contents)))
if len(fields) != 2 {
return 0, 0, fmt.Errorf("unexpected format in cpu.max: expected 2 fields, got %d", len(fields))
}

// quota can be "max" (unlimited) or an integer
if fields[0] == "max" {
quota = 0 // 0 indicates unlimited
} else {
quota, err = strconv.ParseUint(fields[0], 10, 64)
if err != nil {
return 0, 0, fmt.Errorf("error parsing quota from cpu.max: %w", err)
}
}

// period is always an integer
period, err = strconv.ParseUint(fields[1], 10, 64)
if err != nil {
return 0, 0, fmt.Errorf("error parsing period from cpu.max: %w", err)
}

return quota, period, nil
}

// getCFS reads cpu.max/cpu.weight into CFS. Missing files are treated as soft errors.
func getCFS(path string) (CFS, error) {
cfs := CFS{}

// Parse cpu.max for quota and period
quota, period, err := parseCPUMax(path)
if err != nil {
if !os.IsNotExist(err) {
return cfs, fmt.Errorf("error reading cpu.max: %w", err)
}
// File doesn't exist - continue without it
} else {
cfs.Quota = UsOpt{Us: opt.UintWith(quota)}
cfs.Period = UsOpt{Us: opt.UintWith(period)}
}

// Parse cpu.weight
weightPath := filepath.Join(path, "cpu.weight")
if _, statErr := os.Stat(weightPath); statErr == nil {
weight, err := cgcommon.ParseUintFromFile(weightPath)
if err != nil {
return cfs, fmt.Errorf("error reading cpu.weight: %w", err)
}
cfs.Weight = opt.UintWith(weight)
} else if !os.IsNotExist(statErr) {
return cfs, fmt.Errorf("error reading cpu.weight: %w", statErr)
}

return cfs, nil
}
166 changes: 101 additions & 65 deletions metric/system/cgroup/cgv2/omitzero_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ package cgv2

import (
"encoding/json"
"maps"
"slices"
"testing"

"github.com/stretchr/testify/assert"
Expand All @@ -28,78 +30,112 @@ import (
)

func TestOmitZeroJSON(t *testing.T) {
t.Run("CPUStats/zero struct fields omitted", func(t *testing.T) {
m := marshalToMap(t, CPUStats{})
assert.NotContains(t, m, "throttled")
assert.NotContains(t, m, "periods")
})
tests := []struct {
name string
value any
expected []string
}{
{
name: "CPUStats/zero struct fields omitted",
value: CPUStats{},
expected: []string{"system", "usage", "user"},
},
{
name: "CPUStats/non-zero struct fields present",
value: CPUStats{
Periods: opt.UintWith(1),
Throttled: ThrottledField{Us: opt.UintWith(10)},
},
expected: []string{"periods", "system", "throttled", "usage", "user"},
},
{
name: "ThrottledField/zero struct fields omitted",
value: ThrottledField{},
expected: nil,
},
{
name: "ThrottledField/non-zero struct fields present",
value: ThrottledField{
Us: opt.UintWith(10),
Periods: opt.UintWith(4),
},
expected: []string{"periods", "us"},
},
{
name: "MemoryData/zero BytesOpt omitted",
value: MemoryData{},
expected: []string{"events", "low", "usage"},
},
{
name: "MemoryData/non-zero BytesOpt present",
value: MemoryData{
High: opt.BytesOpt{Bytes: opt.UintWith(1024)},
Max: opt.BytesOpt{Bytes: opt.UintWith(4096)},
},
expected: []string{"events", "high", "low", "max", "usage"},
},
{
name: "Events/zero opt.Uint omitted",
value: Events{},
expected: []string{"high", "max"},
},
{
name: "Events/non-zero opt.Uint present",
value: Events{
Low: opt.UintWith(1),
OOM: opt.UintWith(2),
OOMKill: opt.UintWith(3),
Fail: opt.UintWith(4),
},
expected: []string{"fail", "high", "low", "max", "oom", "oom_kill"},
},
{
name: "CPUSubsystem/zero CFS omitted",
value: CPUSubsystem{},
expected: []string{"stats"},
},
{
name: "CPUSubsystem/non-zero CFS present",
value: CPUSubsystem{CFS: CFS{Weight: opt.UintWith(100)}},
expected: []string{"cfs", "stats"},
},
{
name: "CFS/zero UsOpt fields omitted",
value: CFS{},
expected: nil,
},
{
name: "CFS/non-zero UsOpt fields present",
value: CFS{
Period: UsOpt{Us: opt.UintWith(100000)},
Quota: UsOpt{Us: opt.UintWith(50000)},
Weight: opt.UintWith(200),
},
expected: []string{"period", "quota", "weight"},
},
{
name: "CFS/unlimited quota 0 (max) still present",
value: CFS{
Period: UsOpt{Us: opt.UintWith(100000)},
Quota: UsOpt{Us: opt.UintWith(0)},
Weight: opt.UintWith(200),
},
expected: []string{"period", "quota", "weight"},
},
}

t.Run("CPUStats/non-zero struct fields present", func(t *testing.T) {
m := marshalToMap(t, CPUStats{
Periods: opt.UintWith(1),
Throttled: ThrottledField{Us: opt.UintWith(10)},
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
assert.Equal(t, test.expected, marshalKeys(t, test.value))
})
assert.Contains(t, m, "throttled")
assert.Contains(t, m, "periods")
})

t.Run("ThrottledField/zero struct fields omitted", func(t *testing.T) {
m := marshalToMap(t, ThrottledField{})
assert.NotContains(t, m, "us")
assert.NotContains(t, m, "periods")
})

t.Run("ThrottledField/non-zero struct fields present", func(t *testing.T) {
m := marshalToMap(t, ThrottledField{
Us: opt.UintWith(10),
Periods: opt.UintWith(4),
})
assert.Contains(t, m, "us")
assert.Contains(t, m, "periods")
})

t.Run("MemoryData/zero BytesOpt omitted", func(t *testing.T) {
m := marshalToMap(t, MemoryData{})
assert.NotContains(t, m, "high")
assert.NotContains(t, m, "max")
})

t.Run("MemoryData/non-zero BytesOpt present", func(t *testing.T) {
m := marshalToMap(t, MemoryData{
High: opt.BytesOpt{Bytes: opt.UintWith(1024)},
Max: opt.BytesOpt{Bytes: opt.UintWith(4096)},
})
assert.Contains(t, m, "high")
assert.Contains(t, m, "max")
})

t.Run("Events/zero opt.Uint omitted", func(t *testing.T) {
m := marshalToMap(t, Events{})
assert.NotContains(t, m, "low")
assert.NotContains(t, m, "oom")
assert.NotContains(t, m, "oom_kill")
assert.NotContains(t, m, "fail")
})

t.Run("Events/non-zero opt.Uint present", func(t *testing.T) {
m := marshalToMap(t, Events{
Low: opt.UintWith(1),
OOM: opt.UintWith(2),
OOMKill: opt.UintWith(3),
Fail: opt.UintWith(4),
})
assert.Contains(t, m, "low")
assert.Contains(t, m, "oom")
assert.Contains(t, m, "oom_kill")
assert.Contains(t, m, "fail")
})
}
}

func marshalToMap(t *testing.T, v any) map[string]json.RawMessage {
func marshalKeys(t *testing.T, v any) []string {
t.Helper()
data, err := json.Marshal(v)
require.NoError(t, err)
var m map[string]json.RawMessage
require.NoError(t, json.Unmarshal(data, &m))
return m
return slices.Sorted(maps.Keys(m))
}
Loading
Loading