From 43ad65357f3ba8f0addbaa11588bddbfee502282 Mon Sep 17 00:00:00 2001 From: Orestis Floros Date: Thu, 29 Jan 2026 10:29:00 +0100 Subject: [PATCH 1/5] [cgv2] Add CPU CFS quota, period, and weight metrics Add support for collecting CPU bandwidth control settings from cgroupv2: - cpu.max (quota and period in microseconds) - cpu.weight (relative weight, replaces shares from v1) This brings cgroupv2 to feature parity with cgroupv1 for CPU limit metrics. --- metric/system/cgroup/cgv2/cpu.go | 87 +++++++++++- metric/system/cgroup/cgv2/v2_test.go | 161 ++++++++++++++++++++++- metric/system/cgroup/reader_test.go | 5 + metric/system/cgroup/testdata/docker.zip | Bin 87857 -> 88609 bytes 4 files changed, 250 insertions(+), 3 deletions(-) mode change 100755 => 100644 metric/system/cgroup/testdata/docker.zip diff --git a/metric/system/cgroup/cgv2/cpu.go b/metric/system/cgroup/cgv2/cpu.go index a21ccdf99c..76eada4bd8 100644 --- a/metric/system/cgroup/cgv2/cpu.go +++ b/metric/system/cgroup/cgv2/cpu.go @@ -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" @@ -35,7 +37,21 @@ 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,omitempty" 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. + PeriodMicros opt.Us `json:"period" struct:"period"` + // Quota in microseconds for which all tasks in the cgroup can run during one period. + // A value of 0 indicates unlimited (cpu.max "max"). + QuotaMicros opt.Us `json:"quota" struct:"quota"` + // Relative CPU weight (1-10000, default 100). This replaces cpu.shares from cgroups v1. + Weight uint64 `json:"weight" struct:"weight"` } // CPUStats carries the information from the cpu.stat cgroup file @@ -59,7 +75,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 @@ -77,6 +93,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 } @@ -116,3 +137,65 @@ 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.QuotaMicros.Us = quota + cfs.PeriodMicros.Us = period + } + + // Parse cpu.weight + weight, err := cgcommon.ParseUintFromFile(path, "cpu.weight") + if err != nil { + if !os.IsNotExist(err) { + return cfs, fmt.Errorf("error reading cpu.weight: %w", err) + } + // File doesn't exist - continue without it + } else { + cfs.Weight = weight + } + + return cfs, nil +} diff --git a/metric/system/cgroup/cgv2/v2_test.go b/metric/system/cgroup/cgv2/v2_test.go index 13ee43594c..0007bebcfe 100644 --- a/metric/system/cgroup/cgv2/v2_test.go +++ b/metric/system/cgroup/cgv2/v2_test.go @@ -271,7 +271,7 @@ func TestGetCPU(t *testing.T) { expected CPUSubsystem }{ { - name: "v2 path with pressure", + name: "v2 path with pressure and CFS", setup: func(*testing.T) string { return v2Path }, @@ -302,6 +302,12 @@ func TestGetCPU(t *testing.T) { Us: opt.UintWith(10), }, }, + CFS: CFS{ + // cpu.max: "max 100000" => unlimited quota (0) + QuotaMicros: opt.Us{Us: 0}, + PeriodMicros: opt.Us{Us: 100000}, + Weight: 100, + }, }, }, { @@ -314,6 +320,7 @@ func TestGetCPU(t *testing.T) { Path: "", Pressure: map[string]cgcommon.Pressure{}, Stats: CPUStats{}, + CFS: CFS{}, }, }, { @@ -346,6 +353,28 @@ func TestGetCPU(t *testing.T) { Us: opt.UintWith(10), }, }, + CFS: CFS{}, + }, + }, + { + name: "cpu.max with quota limit", + setup: func(t *testing.T) string { + dir := t.TempDir() + // 50000/100000 => 50% CPU limit + writeFile(t, filepath.Join(dir, "cpu.max"), "50000 100000") + writeFile(t, filepath.Join(dir, "cpu.weight"), "200") + return dir + }, + expected: CPUSubsystem{ + ID: "", + Path: "", + Pressure: map[string]cgcommon.Pressure{}, + Stats: CPUStats{}, + CFS: CFS{ + QuotaMicros: opt.Us{Us: 50000}, + PeriodMicros: opt.Us{Us: 100000}, + Weight: 200, + }, }, }, } @@ -359,3 +388,133 @@ func TestGetCPU(t *testing.T) { }) } } + +func TestParseCPUMax(t *testing.T) { + tests := []struct { + name string + content string + expectedQuota uint64 + expectedPeriod uint64 + expectError assert.ErrorAssertionFunc + }{ + { + name: "unlimited quota", + content: "max 100000", + expectedQuota: 0, // 0 represents unlimited + expectedPeriod: 100000, + expectError: assert.NoError, + }, + { + name: "limited quota", + content: "50000 100000", + expectedQuota: 50000, + expectedPeriod: 100000, + expectError: assert.NoError, + }, + { + name: "small quota", + content: "1000 10000", + expectedQuota: 1000, + expectedPeriod: 10000, + expectError: assert.NoError, + }, + { + name: "invalid format - single value", + content: "100000", + expectError: assert.Error, + }, + { + name: "invalid format - too many values", + content: "50000 100000 extra", + expectError: assert.Error, + }, + { + name: "invalid quota value", + content: "invalid 100000", + expectError: assert.Error, + }, + { + name: "invalid period value", + content: "50000 invalid", + expectError: assert.Error, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "cpu.max"), test.content) + + quota, period, err := parseCPUMax(dir) + + test.expectError(t, err) + assert.Equal(t, test.expectedQuota, quota) + assert.Equal(t, test.expectedPeriod, period) + }) + } +} + +func TestGetCFS(t *testing.T) { + tests := []struct { + name string + files map[string]string // filename -> content + expected CFS + }{ + { + name: "both files present", + files: map[string]string{ + "cpu.max": "25000 100000", + "cpu.weight": "150", + }, + expected: CFS{ + QuotaMicros: opt.Us{Us: 25000}, + PeriodMicros: opt.Us{Us: 100000}, + Weight: 150, + }, + }, + { + name: "only cpu.max present", + files: map[string]string{ + "cpu.max": "max 100000", + }, + expected: CFS{ + QuotaMicros: opt.Us{Us: 0}, + PeriodMicros: opt.Us{Us: 100000}, + Weight: 0, + }, + }, + { + name: "only cpu.weight present", + files: map[string]string{ + "cpu.weight": "500", + }, + expected: CFS{ + QuotaMicros: opt.Us{Us: 0}, + PeriodMicros: opt.Us{Us: 0}, + Weight: 500, + }, + }, + { + name: "no files present", + files: nil, + expected: CFS{}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + dir := t.TempDir() + for filename, content := range test.files { + writeFile(t, filepath.Join(dir, filename), content) + } + cfs, err := getCFS(dir) + require.NoError(t, err) + assert.Equal(t, test.expected, cfs) + }) + } +} + +func writeFile(t testing.TB, path, content string) { + t.Helper() + require.NoError(t, os.WriteFile(path, []byte(content), 0644)) +} diff --git a/metric/system/cgroup/reader_test.go b/metric/system/cgroup/reader_test.go index 4fba71d22a..e44f107374 100644 --- a/metric/system/cgroup/reader_test.go +++ b/metric/system/cgroup/reader_test.go @@ -156,6 +156,11 @@ func TestReaderGetStatsV2(t *testing.T) { require.NotZero(t, stats.Memory.Mem.Usage.Bytes) require.NotZero(t, stats.IO.Pressure["some"].Sixty.Pct) + // CFS from cpu.max/cpu.weight in testdata: "max 100000" (unlimited quota) and "100" (default weight). + require.NotZero(t, stats.CPU.CFS.PeriodMicros.Us, "CFS period should be set from cpu.max") + require.Equal(t, uint64(100000), stats.CPU.CFS.PeriodMicros.Us) + require.Zero(t, stats.CPU.CFS.QuotaMicros.Us, "CFS quota should be 0 for unlimited (max)") + require.Equal(t, uint64(100), stats.CPU.CFS.Weight, "CFS weight should be default 100") } func TestReaderGetStatsHierarchyOverride(t *testing.T) { diff --git a/metric/system/cgroup/testdata/docker.zip b/metric/system/cgroup/testdata/docker.zip old mode 100755 new mode 100644 index d18b9958bcc5609fec57fcab1650c24df8f614ac..8fdaa511f74db08b12de49831b660024c07cc0ce GIT binary patch delta 14924 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

eV7~aUG?NyP=8Qcq6 zpXsl3-!pT=ZzAmE%t44BZu=ymt-OAakd0BUovsJC2zi15hdJJGLP_e-%DE74&mi9P z6YeP!46=6)t2_rsx{p#qBu*D1XKTz$Y0ly?2h+munqvnRVV%ZS_^VTa3{l#U}n;vGL-I@jj2Kc z2JZL|v%SW{yqoa2>E_+oFON?Zl0ey_BT}ms2jCYEq8q6NbucX~KJH;K^xZ?9EG)cq z(>@2r=b{gBJ{pL-4uw77uw0i4?JG>2TQa+PFzCYi>#m!)@v!XLO=$0Cg-1pfVGxs_ MzinaB`3S!KFAELhZ2$lO delta 14108 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 From 442a6db78ec605e94b5c7973df6ee3cf7d66d3fb Mon Sep 17 00:00:00 2001 From: Orestis Floros Date: Wed, 18 Feb 2026 17:25:54 +0100 Subject: [PATCH 2/5] remove redundant test --- metric/system/cgroup/cgv2/v2_test.go | 7 ------- 1 file changed, 7 deletions(-) diff --git a/metric/system/cgroup/cgv2/v2_test.go b/metric/system/cgroup/cgv2/v2_test.go index 8363e792c1..f9c1ab7fda 100644 --- a/metric/system/cgroup/cgv2/v2_test.go +++ b/metric/system/cgroup/cgv2/v2_test.go @@ -406,13 +406,6 @@ func TestParseCPUMax(t *testing.T) { expectedPeriod: 100000, expectError: assert.NoError, }, - { - name: "small quota", - content: "1000 10000", - expectedQuota: 1000, - expectedPeriod: 10000, - expectError: assert.NoError, - }, { name: "invalid format - single value", content: "100000", From 684dcfe33561a6e03524bf2ae2ed4bc9bed63179 Mon Sep 17 00:00:00 2001 From: Orestis Floros Date: Wed, 18 Feb 2026 17:46:02 +0100 Subject: [PATCH 3/5] [cgv2] Use optional type for CFS quota/period Replace opt.Us with a new UsOpt wrapper (analogous to opt.BytesOpt) so that missing cpu.max files produce omitted fields rather than misleading zeros. This distinguishes "file absent" (field omitted) from "unlimited quota" (quota.us: 0). Add TestCFSSerialization to verify the structform output for all cases. --- metric/system/cgroup/cgv2/cpu.go | 34 +++++++---- metric/system/cgroup/cgv2/v2_test.go | 89 +++++++++++++++++++++++----- metric/system/cgroup/reader_test.go | 10 ++-- 3 files changed, 102 insertions(+), 31 deletions(-) diff --git a/metric/system/cgroup/cgv2/cpu.go b/metric/system/cgroup/cgv2/cpu.go index 76eada4bd8..7171a6f93f 100644 --- a/metric/system/cgroup/cgv2/cpu.go +++ b/metric/system/cgroup/cgv2/cpu.go @@ -46,12 +46,23 @@ type CPUSubsystem struct { // 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. - PeriodMicros opt.Us `json:"period" struct:"period"` + Period UsOpt `json:"period,omitempty" 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"). - QuotaMicros opt.Us `json:"quota" struct:"quota"` + Quota UsOpt `json:"quota,omitempty" struct:"quota,omitempty"` // Relative CPU weight (1-10000, default 100). This replaces cpu.shares from cgroups v1. - Weight uint64 `json:"weight" struct:"weight"` + Weight opt.Uint `json:"weight,omitempty" 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 @@ -182,19 +193,20 @@ func getCFS(path string) (CFS, error) { } // File doesn't exist - continue without it } else { - cfs.QuotaMicros.Us = quota - cfs.PeriodMicros.Us = period + cfs.Quota = UsOpt{Us: opt.UintWith(quota)} + cfs.Period = UsOpt{Us: opt.UintWith(period)} } // Parse cpu.weight - weight, err := cgcommon.ParseUintFromFile(path, "cpu.weight") - if err != nil { - if !os.IsNotExist(err) { + 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) } - // File doesn't exist - continue without it - } else { - cfs.Weight = weight + cfs.Weight = opt.UintWith(weight) + } else if !os.IsNotExist(statErr) { + return cfs, fmt.Errorf("error reading cpu.weight: %w", statErr) } return cfs, nil diff --git a/metric/system/cgroup/cgv2/v2_test.go b/metric/system/cgroup/cgv2/v2_test.go index f9c1ab7fda..e71bee6db7 100644 --- a/metric/system/cgroup/cgv2/v2_test.go +++ b/metric/system/cgroup/cgv2/v2_test.go @@ -27,7 +27,9 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/elastic/elastic-agent-libs/mapstr" "github.com/elastic/elastic-agent-libs/opt" + "github.com/elastic/elastic-agent-libs/transform/typeconv" "github.com/elastic/elastic-agent-system-metrics/metric/system/cgroup/cgcommon" "github.com/elastic/elastic-agent-system-metrics/metric/system/cgroup/testhelpers" ) @@ -299,9 +301,9 @@ func TestGetCPU(t *testing.T) { }, CFS: CFS{ // cpu.max: "max 100000" => unlimited quota (0) - QuotaMicros: opt.Us{Us: 0}, - PeriodMicros: opt.Us{Us: 100000}, - Weight: 100, + Quota: UsOpt{Us: opt.UintWith(0)}, + Period: UsOpt{Us: opt.UintWith(100000)}, + Weight: opt.UintWith(100), }, }, }, @@ -366,9 +368,9 @@ func TestGetCPU(t *testing.T) { Pressure: map[string]cgcommon.Pressure{}, Stats: CPUStats{}, CFS: CFS{ - QuotaMicros: opt.Us{Us: 50000}, - PeriodMicros: opt.Us{Us: 100000}, - Weight: 200, + Quota: UsOpt{Us: opt.UintWith(50000)}, + Period: UsOpt{Us: opt.UintWith(100000)}, + Weight: opt.UintWith(200), }, }, }, @@ -455,9 +457,9 @@ func TestGetCFS(t *testing.T) { "cpu.weight": "150", }, expected: CFS{ - QuotaMicros: opt.Us{Us: 25000}, - PeriodMicros: opt.Us{Us: 100000}, - Weight: 150, + Quota: UsOpt{Us: opt.UintWith(25000)}, + Period: UsOpt{Us: opt.UintWith(100000)}, + Weight: opt.UintWith(150), }, }, { @@ -466,20 +468,17 @@ func TestGetCFS(t *testing.T) { "cpu.max": "max 100000", }, expected: CFS{ - QuotaMicros: opt.Us{Us: 0}, - PeriodMicros: opt.Us{Us: 100000}, - Weight: 0, + Quota: UsOpt{Us: opt.UintWith(0)}, + Period: UsOpt{Us: opt.UintWith(100000)}, }, }, { - name: "only cpu.weight present", + name: "only cpu.weight present - quota/period unset", files: map[string]string{ "cpu.weight": "500", }, expected: CFS{ - QuotaMicros: opt.Us{Us: 0}, - PeriodMicros: opt.Us{Us: 0}, - Weight: 500, + Weight: opt.UintWith(500), }, }, { @@ -502,6 +501,64 @@ func TestGetCFS(t *testing.T) { } } +func TestCFSSerialization(t *testing.T) { + tests := []struct { + name string + cfs CFS + expected mapstr.M + }{ + { + name: "unlimited quota (cpu.max 'max 100000')", + cfs: CFS{ + Quota: UsOpt{Us: opt.UintWith(0)}, + Period: UsOpt{Us: opt.UintWith(100000)}, + Weight: opt.UintWith(100), + }, + expected: mapstr.M{ + "quota": map[string]any{"us": uint64(0)}, + "period": map[string]any{"us": uint64(100000)}, + "weight": uint64(100), + }, + }, + { + name: "limited quota (cpu.max '50000 100000')", + cfs: CFS{ + Quota: UsOpt{Us: opt.UintWith(50000)}, + Period: UsOpt{Us: opt.UintWith(100000)}, + Weight: opt.UintWith(200), + }, + expected: mapstr.M{ + "quota": map[string]any{"us": uint64(50000)}, + "period": map[string]any{"us": uint64(100000)}, + "weight": uint64(200), + }, + }, + { + name: "cpu.max missing - quota/period must be absent", + cfs: CFS{ + Weight: opt.UintWith(100), + }, + expected: mapstr.M{ + "weight": uint64(100), + }, + }, + { + name: "all files missing - everything omitted", + cfs: CFS{}, + expected: nil, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var got mapstr.M + err := typeconv.Convert(&got, test.cfs) + require.NoError(t, err) + assert.Equal(t, test.expected, got) + }) + } +} + func TestFillStatStructZswap(t *testing.T) { // Based on /sys/fs/cgroup/user.slice/memory.stat (kernel 6.12.67-2). // Zero values changed to non-zero, ensuring complete field coverage. diff --git a/metric/system/cgroup/reader_test.go b/metric/system/cgroup/reader_test.go index 9fb6e342fc..3ec5a02350 100644 --- a/metric/system/cgroup/reader_test.go +++ b/metric/system/cgroup/reader_test.go @@ -160,10 +160,12 @@ func TestReaderGetStatsV2(t *testing.T) { require.NotZero(t, stats.IO.Pressure["some"].Sixty.Pct) // CFS from cpu.max/cpu.weight in testdata: "max 100000" (unlimited quota) and "100" (default weight). - require.NotZero(t, stats.CPU.CFS.PeriodMicros.Us, "CFS period should be set from cpu.max") - require.Equal(t, uint64(100000), stats.CPU.CFS.PeriodMicros.Us) - require.Zero(t, stats.CPU.CFS.QuotaMicros.Us, "CFS quota should be 0 for unlimited (max)") - require.Equal(t, uint64(100), stats.CPU.CFS.Weight, "CFS weight should be default 100") + require.True(t, stats.CPU.CFS.Period.Us.Exists(), "CFS period should be set from cpu.max") + require.Equal(t, uint64(100000), stats.CPU.CFS.Period.Us.ValueOr(0)) + require.True(t, stats.CPU.CFS.Quota.Us.Exists(), "CFS quota should exist for unlimited (max)") + require.Equal(t, uint64(0), stats.CPU.CFS.Quota.Us.ValueOr(1), "CFS quota should be 0 for unlimited (max)") + require.True(t, stats.CPU.CFS.Weight.Exists(), "CFS weight should be set from cpu.weight") + require.Equal(t, uint64(100), stats.CPU.CFS.Weight.ValueOr(0), "CFS weight should be default 100") } func TestReaderGetStatsHierarchyOverride(t *testing.T) { From 81fe4d70b8429003a8094f5e1fe0cbce462e6086 Mon Sep 17 00:00:00 2001 From: Orestis Floros Date: Thu, 26 Feb 2026 09:40:54 +0100 Subject: [PATCH 4/5] Use json omitzero for new CFS struct-typed fields The CFS, UsOpt, and opt.Uint fields added on this branch used omitempty, which is silently ignored for structs by encoding/json. Switch to omitzero (Go 1.24) consistent with the fix in #288. Refactor omitzero_test.go to table-driven style with exact key equality via assert.Equal instead of Contains/NotContains. --- metric/system/cgroup/cgv2/cpu.go | 8 +- metric/system/cgroup/cgv2/omitzero_test.go | 157 ++++++++++++--------- 2 files changed, 96 insertions(+), 69 deletions(-) diff --git a/metric/system/cgroup/cgv2/cpu.go b/metric/system/cgroup/cgv2/cpu.go index 5e1712f9b8..0997421817 100644 --- a/metric/system/cgroup/cgv2/cpu.go +++ b/metric/system/cgroup/cgv2/cpu.go @@ -39,19 +39,19 @@ type CPUSubsystem struct { // Stats shows overall counters for the CPU controller Stats CPUStats `json:"stats" struct:"stats"` // CFS contains CPU bandwidth control settings. - CFS CFS `json:"cfs,omitempty" struct:"cfs,omitempty"` + 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,omitempty" struct:"period,omitempty"` + 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,omitempty" struct:"quota,omitempty"` + 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,omitempty" struct:"weight,omitempty"` + Weight opt.Uint `json:"weight,omitzero" struct:"weight,omitempty"` } // UsOpt wraps opt.Uint for optional microsecond values. diff --git a/metric/system/cgroup/cgv2/omitzero_test.go b/metric/system/cgroup/cgv2/omitzero_test.go index 4e1019f82b..67e42e62b8 100644 --- a/metric/system/cgroup/cgv2/omitzero_test.go +++ b/metric/system/cgroup/cgv2/omitzero_test.go @@ -19,6 +19,8 @@ package cgv2 import ( "encoding/json" + "maps" + "slices" "testing" "github.com/stretchr/testify/assert" @@ -28,78 +30,103 @@ 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"}, + }, + } - 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)) } From 5287b3d4db9b4fe64ea91883fd157a804d6bde63 Mon Sep 17 00:00:00 2001 From: Orestis Floros Date: Thu, 26 Feb 2026 10:10:25 +0100 Subject: [PATCH 5/5] Extra 0 test --- metric/system/cgroup/cgv2/omitzero_test.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/metric/system/cgroup/cgv2/omitzero_test.go b/metric/system/cgroup/cgv2/omitzero_test.go index 67e42e62b8..e118ee4c86 100644 --- a/metric/system/cgroup/cgv2/omitzero_test.go +++ b/metric/system/cgroup/cgv2/omitzero_test.go @@ -113,6 +113,15 @@ func TestOmitZeroJSON(t *testing.T) { }, 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"}, + }, } for _, test := range tests {