Skip to content

Commit da2385c

Browse files
committed
[kubeletstats] Calculate cpu usage on the fly behind feature gate
Signed-off-by: ChrsMark <chrismarkou92@gmail.com>
1 parent 41e24cd commit da2385c

17 files changed

Lines changed: 7519 additions & 16 deletions
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Use this changelog template to create an entry for release notes.
2+
3+
# One of 'breaking', 'deprecation', 'new_component', 'enhancement', 'bug_fix'
4+
change_type: enhancement
5+
6+
# The name of the component, or a single word describing the area of concern, (e.g. receiver/filelog)
7+
component: receiver/kubelet_stats
8+
9+
# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`).
10+
note: Add the `receiver.kubeletstats.cpuUsageScrapeBased` feature gate. When enabled, `container.cpu.usage`, `k8s.pod.cpu.usage` and `k8s.node.cpu.usage` (and the cpu utilization metrics derived from them) are calculated as the rate of the corresponding `*.cpu.time` counter between consecutive scrapes, instead of being read directly from the kubelet's `UsageNanoCores` value.
11+
12+
# Mandatory: One or more tracking issues related to the change. You can use the PR number here if no issue exists.
13+
issues: [49477]
14+
15+
# (Optional) One or more lines of additional information to render under the primary note.
16+
# These lines will be padded with 2 spaces and then inserted directly into the document.
17+
# Use pipe (|) for multiline entries.
18+
subtext:
19+
20+
# If your change doesn't affect end users or the exported elements of any package,
21+
# you should instead start your pull request title with [chore] or use the "Skip Changelog" label.
22+
# Optional: The change log or logs in which this entry should be included.
23+
# e.g. '[user]' or '[user, api]'
24+
# Include 'user' if the change is relevant to end users.
25+
# Include 'api' if there is a change to a library API.
26+
# Default: '[user]'
27+
change_logs: []

receiver/kubeletstatsreceiver/documentation.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -580,3 +580,13 @@ The number of used bytes in the pod volume.
580580
| k8s.volume.name | The name of the Volume | Any Str | true | - | - |
581581
| k8s.volume.type | The type of the Volume | Any Str | true | - | - |
582582
| partition | [DEPRECATED] The partition in the Volume | Any Str | false | - | - |
583+
584+
## Feature Gates
585+
586+
This component has the following feature gates:
587+
588+
| Feature Gate | Stage | Description | From Version | To Version | Reference |
589+
| ------------ | ----- | ----------- | ------------ | ---------- | --------- |
590+
| `receiver.kubeletstats.cpuUsageScrapeBased` | alpha | When enabled, container.cpu.usage, k8s.pod.cpu.usage and k8s.node.cpu.usage (and the cpu utilization metrics derived from them) are calculated by the receiver as the rate of the corresponding *.cpu.time counter between consecutive scrapes, instead of being read directly from the kubelet's UsageNanoCores value. | v0.156.0 | N/A | [Link](https://github.com/open-telemetry/opentelemetry-collector-contrib/issues/49477) |
591+
592+
For more information about feature gates, see the [Feature Gates](https://github.com/open-telemetry/opentelemetry-collector/blob/main/featuregate/README.md) documentation.

receiver/kubeletstatsreceiver/go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ go 1.25.0
55
require (
66
github.com/google/go-cmp v0.7.0
77
github.com/google/uuid v1.6.0
8+
github.com/open-telemetry/opentelemetry-collector-contrib/internal/common v0.156.0
89
github.com/open-telemetry/opentelemetry-collector-contrib/internal/k8sconfig v0.156.0
910
github.com/open-telemetry/opentelemetry-collector-contrib/internal/kubelet v0.156.0
1011
github.com/open-telemetry/opentelemetry-collector-contrib/pkg/golden v0.156.0
@@ -20,6 +21,7 @@ require (
2021
go.opentelemetry.io/collector/confmap/xconfmap v0.156.0
2122
go.opentelemetry.io/collector/consumer v1.62.0
2223
go.opentelemetry.io/collector/consumer/consumertest v0.156.0
24+
go.opentelemetry.io/collector/featuregate v1.62.0
2325
go.opentelemetry.io/collector/filter v0.156.0
2426
go.opentelemetry.io/collector/pdata v1.62.0
2527
go.opentelemetry.io/collector/pipeline v1.62.0
@@ -88,7 +90,6 @@ require (
8890
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
8991
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
9092
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
91-
github.com/open-telemetry/opentelemetry-collector-contrib/internal/common v0.156.0 // indirect
9293
github.com/open-telemetry/opentelemetry-collector-contrib/pkg/pdatautil v0.156.0 // indirect
9394
github.com/opencontainers/go-digest v1.0.0 // indirect
9495
github.com/opencontainers/image-spec v1.1.1 // indirect
@@ -113,7 +114,6 @@ require (
113114
go.opentelemetry.io/collector/consumer/xconsumer v0.156.0 // indirect
114115
go.opentelemetry.io/collector/extension/extensionauth v1.62.0 // indirect
115116
go.opentelemetry.io/collector/extension/extensionmiddleware v0.156.0 // indirect
116-
go.opentelemetry.io/collector/featuregate v1.62.0 // indirect
117117
go.opentelemetry.io/collector/internal/componentalias v0.156.0 // indirect
118118
go.opentelemetry.io/collector/internal/sharedcomponent v0.156.0 // indirect
119119
go.opentelemetry.io/collector/internal/telemetry v0.156.0 // indirect

receiver/kubeletstatsreceiver/internal/kubelet/accumulator.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ type metricDataAccumulator struct {
4141
allNetworkInterfaces map[MetricGroup]bool
4242
time time.Time
4343
mbs *metadata.MetricsBuilders
44+
cpuUsageCalculator *CPUUsageCalculator
4445
}
4546

4647
func addUptimeMetric(mb *metadata.MetricsBuilder, uptimeMetric metadata.RecordIntDataPointFunc, startTime v1.Time, currentTime pcommon.Timestamp) {
@@ -57,7 +58,7 @@ func (a *metricDataAccumulator) nodeStats(s stats.NodeStats) {
5758

5859
currentTime := pcommon.NewTimestampFromTime(a.time)
5960
addUptimeMetric(a.mbs.NodeMetricsBuilder, metadata.NodeUptimeMetrics.Uptime, s.StartTime, currentTime)
60-
addCPUMetrics(a.mbs.NodeMetricsBuilder, metadata.NodeCPUMetrics, s.CPU, currentTime, resources{}, 0)
61+
addCPUMetrics(a.mbs.NodeMetricsBuilder, metadata.NodeCPUMetrics, s.CPU, currentTime, resources{}, 0, a.cpuUsageCalculator, "node/"+s.NodeName)
6162
addMemoryMetrics(a.mbs.NodeMetricsBuilder, metadata.NodeMemoryMetrics, s.Memory, currentTime, resources{}, 0)
6263
addFilesystemMetrics(a.mbs.NodeMetricsBuilder, metadata.NodeFilesystemMetrics, s.Fs, currentTime)
6364
addNetworkMetrics(a.mbs.NodeMetricsBuilder, metadata.NodeNetworkMetrics, s.Network, currentTime, a.allNetworkInterfaces[NodeMetricGroup])
@@ -75,7 +76,7 @@ func (a *metricDataAccumulator) nodeStats(s stats.NodeStats) {
7576
rb.SetK8sNodeName(s.NodeName)
7677
rb.SetK8sNodeSystemContainerName(cs.Name)
7778

78-
addCPUMetrics(a.mbs.NodeMetricsBuilder, metadata.SystemContainerCPUMetrics, cs.CPU, currentTime, resources{}, 0)
79+
addCPUMetrics(a.mbs.NodeMetricsBuilder, metadata.SystemContainerCPUMetrics, cs.CPU, currentTime, resources{}, 0, a.cpuUsageCalculator, "node-system-container/"+s.NodeName+"/"+cs.Name)
7980
addMemoryMetrics(a.mbs.NodeMetricsBuilder, metadata.SystemContainerMemoryMetrics, cs.Memory, currentTime, resources{}, 0)
8081

8182
a.m = append(a.m, a.mbs.NodeMetricsBuilder.Emit(
@@ -92,7 +93,7 @@ func (a *metricDataAccumulator) podStats(s *stats.PodStats) {
9293

9394
currentTime := pcommon.NewTimestampFromTime(a.time)
9495
addUptimeMetric(a.mbs.PodMetricsBuilder, metadata.PodUptimeMetrics.Uptime, s.StartTime, currentTime)
95-
addCPUMetrics(a.mbs.PodMetricsBuilder, metadata.PodCPUMetrics, s.CPU, currentTime, a.metadata.podResources[s.PodRef.UID], a.metadata.nodeInfo.CPUCapacity)
96+
addCPUMetrics(a.mbs.PodMetricsBuilder, metadata.PodCPUMetrics, s.CPU, currentTime, a.metadata.podResources[s.PodRef.UID], a.metadata.nodeInfo.CPUCapacity, a.cpuUsageCalculator, "pod/"+s.PodRef.UID)
9697
addMemoryMetrics(a.mbs.PodMetricsBuilder, metadata.PodMemoryMetrics, s.Memory, currentTime, a.metadata.podResources[s.PodRef.UID], a.metadata.nodeInfo.MemoryCapacity)
9798
addFilesystemMetrics(a.mbs.PodMetricsBuilder, metadata.PodFilesystemMetrics, s.EphemeralStorage, currentTime)
9899
addNetworkMetrics(a.mbs.PodMetricsBuilder, metadata.PodNetworkMetrics, s.Network, currentTime, a.allNetworkInterfaces[PodMetricGroup])
@@ -126,7 +127,7 @@ func (a *metricDataAccumulator) containerStats(sPod *stats.PodStats, s *stats.Co
126127
currentTime := pcommon.NewTimestampFromTime(a.time)
127128
resourceKey := sPod.PodRef.UID + s.Name
128129
addUptimeMetric(a.mbs.ContainerMetricsBuilder, metadata.ContainerUptimeMetrics.Uptime, s.StartTime, currentTime)
129-
addCPUMetrics(a.mbs.ContainerMetricsBuilder, metadata.ContainerCPUMetrics, s.CPU, currentTime, a.metadata.containerResources[resourceKey], a.metadata.nodeInfo.CPUCapacity)
130+
addCPUMetrics(a.mbs.ContainerMetricsBuilder, metadata.ContainerCPUMetrics, s.CPU, currentTime, a.metadata.containerResources[resourceKey], a.metadata.nodeInfo.CPUCapacity, a.cpuUsageCalculator, "container/"+resourceKey)
130131
addMemoryMetrics(a.mbs.ContainerMetricsBuilder, metadata.ContainerMemoryMetrics, s.Memory, currentTime, a.metadata.containerResources[resourceKey], a.metadata.nodeInfo.MemoryCapacity)
131132
addFilesystemMetrics(a.mbs.ContainerMetricsBuilder, metadata.ContainerFilesystemMetrics, s.Rootfs, currentTime)
132133

receiver/kubeletstatsreceiver/internal/kubelet/cpu.go

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,18 +17,42 @@ func addCPUMetrics(
1717
currentTime pcommon.Timestamp,
1818
r resources,
1919
nodeCPULimit float64,
20+
calc *CPUUsageCalculator,
21+
cpuUsageKey string,
2022
) {
2123
if s == nil {
2224
return
2325
}
24-
if s.UsageNanoCores != nil {
25-
usageCores := float64(*s.UsageNanoCores) / 1_000_000_000
26+
27+
var usageCores float64
28+
var ok bool
29+
if metadata.ReceiverKubeletstatsCPUUsageScrapeBasedFeatureGate.IsEnabled() {
30+
usageCores, ok = calculateScrapeBasedCPUUsage(s, calc, cpuUsageKey)
31+
} else if s.UsageNanoCores != nil {
32+
usageCores, ok = float64(*s.UsageNanoCores)/1_000_000_000, true
33+
}
34+
if ok {
2635
cpuMetrics.Usage(mb, currentTime, usageCores)
2736
addCPUUtilizationMetrics(mb, cpuMetrics, usageCores, currentTime, r, nodeCPULimit)
2837
}
38+
2939
addCPUTimeMetric(mb, cpuMetrics.Time, s, currentTime)
3040
}
3141

42+
// calculateScrapeBasedCPUUsage calculates CPU usage in cores as the rate of change of
43+
// the cumulative CPU time (UsageCoreNanoSeconds) between this scrape and the previous
44+
// one, instead of using the value reported directly by the kubelet (UsageNanoCores).
45+
// This is gated behind the kubeletstats.cpuUsageScrapeBased feature gate. It returns
46+
// ok=false when usage cannot be calculated this scrape (see CPUUsageCalculator).
47+
func calculateScrapeBasedCPUUsage(s *stats.CPUStats, calc *CPUUsageCalculator, cpuUsageKey string) (usageCores float64, ok bool) {
48+
if s.UsageCoreNanoSeconds == nil || s.Time.IsZero() {
49+
return 0, false
50+
}
51+
cpuTimeSeconds := float64(*s.UsageCoreNanoSeconds) / 1_000_000_000
52+
sampleTime := pcommon.NewTimestampFromTime(s.Time.Time)
53+
return calc.calculateUsage(cpuUsageKey, cpuTimeSeconds, sampleTime)
54+
}
55+
3256
func addCPUUtilizationMetrics(
3357
mb *metadata.MetricsBuilder,
3458
cpuMetrics metadata.CPUMetrics,
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
// Copyright The OpenTelemetry Authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package kubelet
5+
6+
import (
7+
"testing"
8+
"time"
9+
10+
"github.com/stretchr/testify/require"
11+
"go.opentelemetry.io/collector/receiver/receivertest"
12+
"go.uber.org/zap"
13+
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
14+
stats "k8s.io/kubelet/pkg/apis/stats/v1alpha1"
15+
16+
"github.com/open-telemetry/opentelemetry-collector-contrib/internal/common/testutil"
17+
"github.com/open-telemetry/opentelemetry-collector-contrib/receiver/kubeletstatsreceiver/internal/metadata"
18+
)
19+
20+
// nodeSummary builds a minimal stats.Summary for a single node with the given cumulative
21+
// CPU time (in nanocore-seconds) sampled at the given time.
22+
func nodeSummary(usageCoreNanoSeconds uint64, sampleTime time.Time) *stats.Summary {
23+
return &stats.Summary{
24+
Node: stats.NodeStats{
25+
NodeName: "worker-42",
26+
StartTime: v1.Time{Time: sampleTime.Add(-time.Hour)},
27+
CPU: &stats.CPUStats{
28+
Time: v1.Time{Time: sampleTime},
29+
UsageCoreNanoSeconds: &usageCoreNanoSeconds,
30+
// UsageNanoCores is deliberately left unset: with the feature gate
31+
// enabled it must not be consulted at all.
32+
},
33+
},
34+
}
35+
}
36+
37+
func TestScrapeBasedCPUUsage(t *testing.T) {
38+
defer testutil.SetFeatureGateForTest(t, metadata.ReceiverKubeletstatsCPUUsageScrapeBasedFeatureGate, true)()
39+
40+
mbs := &metadata.MetricsBuilders{
41+
NodeMetricsBuilder: metadata.NewMetricsBuilder(metadata.NewDefaultMetricsBuilderConfig(), receivertest.NewNopSettings(metadata.Type)),
42+
}
43+
nodeGroup := map[MetricGroup]bool{NodeMetricGroup: true}
44+
calc := NewCPUUsageCalculator()
45+
46+
base := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
47+
48+
// First scrape: no previous sample, so no usage should be recorded, but cpu.time
49+
// (the cumulative counter) is always recorded regardless of the gate.
50+
firstMetrics := indexedFakeMetrics(MetricsData(zap.NewNop(), nodeSummary(10_000_000_000, base), Metadata{}, nodeGroup, nil, mbs, calc))
51+
_, found := firstMetrics["k8s.node.cpu.usage"]
52+
require.False(t, found)
53+
requireContains(t, firstMetrics, "k8s.node.cpu.time")
54+
require.InDelta(t, 10.0, firstMetrics["k8s.node.cpu.time"][0].Sum().DataPoints().At(0).DoubleValue(), 1e-9)
55+
56+
// Second scrape, 2s later: cumulative CPU time advanced by 4 CPU-seconds ->
57+
// (14-10)/2 = 2 cores of usage.
58+
secondMetrics := indexedFakeMetrics(MetricsData(zap.NewNop(), nodeSummary(14_000_000_000, base.Add(2*time.Second)), Metadata{}, nodeGroup, nil, mbs, calc))
59+
usageMetrics, found := secondMetrics["k8s.node.cpu.usage"]
60+
require.True(t, found)
61+
require.InDelta(t, 2.0, usageMetrics[0].Gauge().DataPoints().At(0).DoubleValue(), 1e-9)
62+
}
63+
64+
func TestScrapeBasedCPUUsage_DisabledGateUsesUsageNanoCores(t *testing.T) {
65+
// Gate defaults to disabled; explicitly assert the default (legacy) behavior is
66+
// unaffected by the new code path.
67+
require.False(t, metadata.ReceiverKubeletstatsCPUUsageScrapeBasedFeatureGate.IsEnabled())
68+
69+
mbs := &metadata.MetricsBuilders{
70+
NodeMetricsBuilder: metadata.NewMetricsBuilder(metadata.NewDefaultMetricsBuilderConfig(), receivertest.NewNopSettings(metadata.Type)),
71+
}
72+
nodeGroup := map[MetricGroup]bool{NodeMetricGroup: true}
73+
calc := NewCPUUsageCalculator()
74+
75+
usageNanoCores := uint64(1_500_000_000) // 1.5 cores
76+
usageCoreNanoSeconds := uint64(10_000_000_000)
77+
summary := &stats.Summary{
78+
Node: stats.NodeStats{
79+
NodeName: "worker-42",
80+
StartTime: v1.Time{Time: time.Now().Add(-time.Hour)},
81+
CPU: &stats.CPUStats{
82+
Time: v1.Time{Time: time.Now()},
83+
UsageNanoCores: &usageNanoCores,
84+
UsageCoreNanoSeconds: &usageCoreNanoSeconds,
85+
},
86+
},
87+
}
88+
89+
metrics := indexedFakeMetrics(MetricsData(zap.NewNop(), summary, Metadata{}, nodeGroup, nil, mbs, calc))
90+
usageMetrics, found := metrics["k8s.node.cpu.usage"]
91+
require.True(t, found)
92+
require.InDelta(t, 1.5, usageMetrics[0].Gauge().DataPoints().At(0).DoubleValue(), 1e-9)
93+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
// Copyright The OpenTelemetry Authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package kubelet // import "github.com/open-telemetry/opentelemetry-collector-contrib/receiver/kubeletstatsreceiver/internal/kubelet"
5+
6+
import (
7+
"time"
8+
9+
"go.opentelemetry.io/collector/pdata/pcommon"
10+
)
11+
12+
// cpuUsageSample stores the cumulative CPU time (in seconds) reported for a resource
13+
// at a given point in time, as observed on a previous scrape.
14+
type cpuUsageSample struct {
15+
cpuTimeSeconds float64
16+
sampleTime pcommon.Timestamp
17+
}
18+
19+
// CPUUsageCalculator calculates CPU usage (in cores) as the rate of change of a
20+
// cumulative CPU time counter between two consecutive scrapes:
21+
//
22+
// usageCores = (cpuTimeEnd - cpuTimeStart) / elapsedSeconds
23+
//
24+
// This mirrors the approach used by the hostmetrics receiver to calculate CPU
25+
// utilization from cumulative CPU time. Samples are keyed by an arbitrary
26+
// per-resource key (e.g. node name, pod UID, or container UID+name) so that a single
27+
// calculator instance can be shared across all node/pod/container/system-container
28+
// resources scraped by the receiver.
29+
type CPUUsageCalculator struct {
30+
previous map[string]cpuUsageSample
31+
touched map[string]bool
32+
}
33+
34+
// NewCPUUsageCalculator creates a new, empty CPUUsageCalculator.
35+
func NewCPUUsageCalculator() *CPUUsageCalculator {
36+
return &CPUUsageCalculator{
37+
previous: make(map[string]cpuUsageSample),
38+
}
39+
}
40+
41+
// startScrape prepares the calculator for a new scrape. It must be called once before
42+
// any calls to calculateUsage for a given scrape, and paired with a following call to
43+
// endScrape once the scrape completes.
44+
func (c *CPUUsageCalculator) startScrape() {
45+
c.touched = make(map[string]bool)
46+
}
47+
48+
// endScrape prunes stored samples for any resource key that was not observed during
49+
// the scrape (e.g. because the pod/container no longer exists), to avoid unbounded
50+
// growth of the underlying map as resources come and go over time.
51+
func (c *CPUUsageCalculator) endScrape() {
52+
for key := range c.previous {
53+
if !c.touched[key] {
54+
delete(c.previous, key)
55+
}
56+
}
57+
c.touched = nil
58+
}
59+
60+
// calculateUsage returns the CPU usage in cores for the resource identified by key,
61+
// computed as the rate of change of cpuTimeSeconds (a cumulative CPU-seconds counter)
62+
// between sampleTime and the previous sample's time for the same key.
63+
//
64+
// It returns ok=false when there is no previous sample for key (e.g. on the first
65+
// scrape after startup, or after the resource disappeared and reappeared), when the
66+
// elapsed time between samples is not strictly positive (e.g. the kubelet reported a
67+
// stale or duplicate sample time), or when the counter decreased (e.g. a container
68+
// restart reset it). In all of these cases no usage value should be recorded for this
69+
// scrape.
70+
func (c *CPUUsageCalculator) calculateUsage(key string, cpuTimeSeconds float64, sampleTime pcommon.Timestamp) (usageCores float64, ok bool) {
71+
c.touched[key] = true
72+
73+
prev, hasPrev := c.previous[key]
74+
c.previous[key] = cpuUsageSample{cpuTimeSeconds: cpuTimeSeconds, sampleTime: sampleTime}
75+
if !hasPrev {
76+
return 0, false
77+
}
78+
79+
elapsedSeconds := time.Duration(sampleTime - prev.sampleTime).Seconds()
80+
if elapsedSeconds <= 0 {
81+
return 0, false
82+
}
83+
84+
delta := cpuTimeSeconds - prev.cpuTimeSeconds
85+
if delta < 0 {
86+
return 0, false
87+
}
88+
89+
return delta / elapsedSeconds, true
90+
}

0 commit comments

Comments
 (0)