Skip to content

Commit bbcad25

Browse files
committed
Add a workload autoscaling status section
There was no way to tell from `datadog-cluster-agent status` whether workload autoscaling was receiving anything over Remote Configuration. Report the number of DatadogPodAutoscalers, leader state, which Remote Configuration client serves the products, and per product the last config version received, when it arrived, how many configs it carried and the last apply error. The update is recorded before the configs are processed, so one that fails to apply still shows as received and its error is reported separately rather than looking like a connectivity problem. Three states are distinguished so the section is never ambiguous: disabled in configuration, enabled but not started, and running. The section is named "Autoscaling" because sections render in alphabetical order with only "collector" special cased, which places it after "Autodiscovery" and leaves room for cluster autoscaling to join it later. Only the workload products are reported for now. Assisted-by: Claude:claude-opus-5
1 parent 790c2bd commit bbcad25

9 files changed

Lines changed: 444 additions & 4 deletions

File tree

cmd/cluster-agent/subcommands/start/command.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@ func Commands(globalParams *command.GlobalParams) []*cobra.Command {
203203
status.NewInformationProvider(endpointsStatus.Provider{}),
204204
status.NewInformationProvider(pkgclusterchecks.Provider{}),
205205
status.NewInformationProvider(orchestratorStatus.Provider{}),
206+
status.NewInformationProvider(workload.Provider{}),
206207
),
207208
fx.Provide(func(config config.Component, hostname hostnameinterface.Component) status.HeaderInformationProvider {
208209
return status.NewHeaderInformationProvider(hostnameStatus.NewProvider(config, hostname))
@@ -589,6 +590,7 @@ func start(log log.Component,
589590
// Autoscaling Product
590591
var pp workload.PodPatcher
591592
var autoscalingRCClient *rcclient.Client
593+
var autoscalingRCInstance string
592594
if config.GetBool("autoscaling.workload.enabled") || config.GetBool("autoscaling.cluster.enabled") {
593595
if rcClients == nil {
594596
return errors.New("Remote config is disabled or failed to initialize, remote config is a required dependency for autoscaling")
@@ -607,13 +609,14 @@ func start(log log.Component,
607609
if err != nil {
608610
return err
609611
}
612+
autoscalingRCInstance = rcClients.InstanceNameForProducts(autoscalingProducts...)
610613
}
611614
if config.GetBool("autoscaling.workload.enabled") {
612615
if !config.GetBool("admission_controller.enabled") {
613616
log.Error("Admission controller is disabled, vertical autoscaling requires the admission controller to be enabled. Vertical scaling will be disabled.")
614617
}
615618

616-
if patcher, err := provider.StartWorkloadAutoscaling(mainCtx, clusterID, clusterName, le.IsLeader, apiCl, autoscalingRCClient, wmeta, taggerComp, demultiplexer, autoscalingGate); err == nil {
619+
if patcher, err := provider.StartWorkloadAutoscaling(mainCtx, clusterID, clusterName, le.IsLeader, apiCl, autoscalingRCClient, autoscalingRCInstance, wmeta, taggerComp, demultiplexer, autoscalingGate); err == nil {
617620
pp = patcher
618621
} else {
619622
return fmt.Errorf("Error while starting workload autoscaling: %v", err)

cmd/cluster-agent/subcommands/start/remote_config.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,24 @@ func (r *remoteConfigClientRegistry) ClientForProducts(products ...string) (*rcc
210210
return r.clientForInstanceLocked(selectedInstance)
211211
}
212212

213+
// InstanceNameForProducts returns the name of the client serving the given
214+
// products, for reporting in the status output. It mirrors the routing done by
215+
// ClientForProducts but creates nothing, and returns "default" when the
216+
// products are not owned by an additional client.
217+
func (r *remoteConfigClientRegistry) InstanceNameForProducts(products ...string) string {
218+
if r == nil {
219+
return ""
220+
}
221+
for _, product := range products {
222+
if instance, found := r.byProduct[product]; found {
223+
// Matches the key used in the Remote Configuration status section,
224+
// so the two sections can be cross-referenced.
225+
return instance.name
226+
}
227+
}
228+
return remoteconfig.DefaultStatusInstance
229+
}
230+
213231
func (r *remoteConfigClientRegistry) clientForInstanceLocked(instance *remoteConfigClientInstance) (*rcclient.Client, error) {
214232
if instance == nil {
215233
return nil, nil

pkg/clusteragent/autoscaling/workload/BUILD.bazel

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,18 @@ go_library(
2020
"pod_watcher_fake.go",
2121
"scaler.go",
2222
"scaler_fake.go",
23+
"status.go",
2324
"telemetry.go",
2425
"utils.go",
2526
],
27+
embedsrcs = [
28+
"status_templates/workloadautoscaling.tmpl",
29+
"status_templates/workloadautoscalingHTML.tmpl",
30+
],
2631
importpath = "github.com/DataDog/datadog-agent/pkg/clusteragent/autoscaling/workload",
2732
visibility = ["//visibility:public"],
2833
deps = [
34+
"//comp/core/status",
2935
"//comp/core/workloadmeta/def",
3036
"//pkg/aggregator/sender",
3137
"//pkg/clusteragent/autoscaling",
@@ -82,6 +88,7 @@ dd_agent_go_test(
8288
"dump_test.go",
8389
"pod_patcher_test.go",
8490
"pod_watcher_test.go",
91+
"status_test.go",
8592
],
8693
embed = [":workload"],
8794
gotags_sets = [[
@@ -104,6 +111,7 @@ dd_agent_go_test(
104111
"//pkg/clusteragent/autoscaling/workload/common",
105112
"//pkg/clusteragent/autoscaling/workload/model",
106113
"//pkg/clusteragent/patcher",
114+
"//pkg/config/mock",
107115
"//pkg/config/remote/data",
108116
"//pkg/config/setup",
109117
"//pkg/remoteconfig/state",

pkg/clusteragent/autoscaling/workload/config_retriever.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,10 @@ func NewConfigRetriever(ctx context.Context, clock clock.WithTicker, store *stor
5858

5959
// Subscribe to remote config updates
6060
rcClient.SubscribeIgnoreExpiration(data.ProductContainerAutoscalingSettings, func(update map[string]state.RawConfig, applyStateCallback func(string, state.ApplyStatus)) {
61-
cr.processorCallback(&cr.settingsProcessor, update, applyStateCallback)
61+
cr.processorCallback(data.ProductContainerAutoscalingSettings, &cr.settingsProcessor, update, applyStateCallback)
6262
})
6363
rcClient.SubscribeIgnoreExpiration(data.ProductContainerAutoscalingValues, func(update map[string]state.RawConfig, applyStateCallback func(string, state.ApplyStatus)) {
64-
cr.processorCallback(&cr.valuesProcessor, update, applyStateCallback)
64+
cr.processorCallback(data.ProductContainerAutoscalingValues, &cr.valuesProcessor, update, applyStateCallback)
6565
})
6666

6767
// Add a regular reconcile for settings. Several edge cases can happen that would prevent creation or deletion of a PodAutoscaler
@@ -99,16 +99,21 @@ func NewConfigRetriever(ctx context.Context, clock clock.WithTicker, store *stor
9999
return cr, nil
100100
}
101101

102-
func (cr *ConfigRetriever) processorCallback(processor autoscalingProcessor, update map[string]state.RawConfig, applyStateCallback func(string, state.ApplyStatus)) {
102+
func (cr *ConfigRetriever) processorCallback(product string, processor autoscalingProcessor, update map[string]state.RawConfig, applyStateCallback func(string, state.ApplyStatus)) {
103103
timestamp := cr.clock.Now()
104104

105+
// Record what this update carried, for `cluster-agent status`. Done before
106+
// processing so a config that fails to apply is still visible as received.
107+
recordRemoteConfigUpdate(product, timestamp, update)
108+
105109
processor.preProcess()
106110
for configKey, rawConfig := range update {
107111
log.Debugf("Processing config key: %s, product: %s, id: %s, name: %s, version: %d, rawSize: %d, size: %d, leader: %v", configKey, rawConfig.Metadata.Product, rawConfig.Metadata.ID, rawConfig.Metadata.Name, rawConfig.Metadata.Version, rawConfig.Metadata.RawLength, len(rawConfig.Config), cr.isLeader())
108112

109113
err := processor.processItem(timestamp, configKey, rawConfig)
110114
if err != nil {
111115
log.Warnf("Error processing item from product %s for config key %s: %v", rawConfig.Metadata.Product, configKey, err)
116+
recordRemoteConfigError(product, timestamp, err)
112117
applyStateCallback(configKey, state.ApplyStatus{
113118
State: state.ApplyStateError,
114119
Error: err.Error(),

pkg/clusteragent/autoscaling/workload/provider/provider.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ func StartWorkloadAutoscaling(
4949
isLeaderFunc func() bool,
5050
apiCl *apiserver.APIClient,
5151
rcClient workload.RcClient,
52+
rcInstanceName string,
5253
wlm workloadmeta.Component,
5354
taggerComp tagger.Component,
5455
senderManager sender.SenderManager,
@@ -64,6 +65,7 @@ func StartWorkloadAutoscaling(
6465

6566
store := autoscalingstore.NewStore[model.PodAutoscalerInternal]()
6667
workload.InitDumper(store)
68+
workload.InitStatus(store, isLeaderFunc, rcInstanceName)
6769

6870
// Open the gate the first time a DPA enters the store. This catches every
6971
// path that creates a DPA: Kubernetes informer, remote config, and the
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
// Unless explicitly stated otherwise all files in this repository are licensed
2+
// under the Apache License Version 2.0.
3+
// This product includes software developed at Datadog (https://www.datadoghq.com/).
4+
// Copyright 2026-present Datadog, Inc.
5+
6+
//go:build kubeapiserver
7+
8+
package workload
9+
10+
import (
11+
"embed"
12+
"io"
13+
"sort"
14+
"sync"
15+
"time"
16+
17+
"github.com/DataDog/datadog-agent/comp/core/status"
18+
"github.com/DataDog/datadog-agent/pkg/config/remote/data"
19+
pkgconfigsetup "github.com/DataDog/datadog-agent/pkg/config/setup"
20+
"github.com/DataDog/datadog-agent/pkg/remoteconfig/state"
21+
)
22+
23+
//go:embed status_templates
24+
var templatesFS embed.FS
25+
26+
// statusStore is the live autoscaling store, registered by the provider at
27+
// startup. It is nil until workload autoscaling actually starts, which is how
28+
// the status distinguishes "not started" from "started with zero autoscalers".
29+
var statusStore struct {
30+
sync.RWMutex
31+
store *store
32+
isLeader func() bool
33+
rcInstance string
34+
}
35+
36+
// InitStatus registers the live state used by the workload autoscaling status
37+
// section. rcInstance names the Remote Configuration client serving the
38+
// autoscaling products, so the status can point at the right RC instance when
39+
// extra clients are configured.
40+
func InitStatus(store *store, isLeader func() bool, rcInstance string) {
41+
statusStore.Lock()
42+
defer statusStore.Unlock()
43+
statusStore.store = store
44+
statusStore.isLeader = isLeader
45+
statusStore.rcInstance = rcInstance
46+
}
47+
48+
// productStatus tracks what the last Remote Configuration update for a product
49+
// carried. Versions are per-config, so the highest one in an update is the most
50+
// useful single number to show.
51+
type productStatus struct {
52+
LastUpdate time.Time `json:"last_update"`
53+
LastVersion uint64 `json:"last_version"`
54+
ConfigCount int `json:"config_count"`
55+
UpdateCount uint64 `json:"update_count"`
56+
LastError string `json:"last_error,omitempty"`
57+
LastErrorTime time.Time `json:"last_error_time,omitempty"`
58+
}
59+
60+
var rcTracker = struct {
61+
sync.RWMutex
62+
byProduct map[string]*productStatus
63+
}{byProduct: map[string]*productStatus{}}
64+
65+
func trackedProduct(product string) *productStatus {
66+
if existing, found := rcTracker.byProduct[product]; found {
67+
return existing
68+
}
69+
created := &productStatus{}
70+
rcTracker.byProduct[product] = created
71+
return created
72+
}
73+
74+
// recordRemoteConfigUpdate notes a Remote Configuration update for a product.
75+
func recordRemoteConfigUpdate(product string, timestamp time.Time, update map[string]state.RawConfig) {
76+
rcTracker.Lock()
77+
defer rcTracker.Unlock()
78+
79+
tracked := trackedProduct(product)
80+
tracked.LastUpdate = timestamp
81+
tracked.ConfigCount = len(update)
82+
tracked.UpdateCount++
83+
for _, rawConfig := range update {
84+
if rawConfig.Metadata.Version > tracked.LastVersion {
85+
tracked.LastVersion = rawConfig.Metadata.Version
86+
}
87+
}
88+
}
89+
90+
// recordRemoteConfigError notes a config that failed to apply.
91+
func recordRemoteConfigError(product string, timestamp time.Time, err error) {
92+
rcTracker.Lock()
93+
defer rcTracker.Unlock()
94+
95+
tracked := trackedProduct(product)
96+
tracked.LastError = err.Error()
97+
tracked.LastErrorTime = timestamp
98+
}
99+
100+
// autoscalingProducts are the Remote Configuration products backing workload
101+
// autoscaling. Cluster autoscaling is deliberately out of scope here.
102+
var autoscalingProducts = []string{
103+
data.ProductContainerAutoscalingSettings,
104+
data.ProductContainerAutoscalingValues,
105+
}
106+
107+
// Provider populates the workload autoscaling status section.
108+
type Provider struct{}
109+
110+
// Name returns the name
111+
func (Provider) Name() string {
112+
return "Workload Autoscaling"
113+
}
114+
115+
// Section returns the section.
116+
//
117+
// Sections are rendered in alphabetical order (only "collector" is special
118+
// cased), so "Autoscaling" places this group directly after "Autodiscovery".
119+
// It is also the natural group for cluster autoscaling to join later.
120+
func (Provider) Section() string {
121+
return "Autoscaling"
122+
}
123+
124+
// JSON populates the status map
125+
func (Provider) JSON(_ bool, stats map[string]interface{}) error {
126+
populateStatus(stats)
127+
return nil
128+
}
129+
130+
// Text renders the text output
131+
func (Provider) Text(_ bool, buffer io.Writer) error {
132+
return status.RenderText(templatesFS, "workloadautoscaling.tmpl", buffer, getStatusInfo())
133+
}
134+
135+
// HTML renders the html output
136+
func (Provider) HTML(_ bool, buffer io.Writer) error {
137+
return status.RenderHTML(templatesFS, "workloadautoscalingHTML.tmpl", buffer, getStatusInfo())
138+
}
139+
140+
func getStatusInfo() map[string]interface{} {
141+
stats := make(map[string]interface{})
142+
populateStatus(stats)
143+
return stats
144+
}
145+
146+
func populateStatus(stats map[string]interface{}) {
147+
info := map[string]interface{}{}
148+
149+
statusStore.RLock()
150+
liveStore, isLeader, rcInstance := statusStore.store, statusStore.isLeader, statusStore.rcInstance
151+
statusStore.RUnlock()
152+
153+
if !pkgconfigsetup.Datadog().GetBool("autoscaling.workload.enabled") {
154+
info["Disabled"] = "Workload autoscaling is not enabled on the Cluster Agent"
155+
stats["workloadAutoscaling"] = info
156+
return
157+
}
158+
159+
if liveStore == nil {
160+
// Enabled but the store is not registered yet: either still starting, or
161+
// StartWorkloadAutoscaling returned an error (which is logged, not fatal).
162+
info["Started"] = false
163+
stats["workloadAutoscaling"] = info
164+
return
165+
}
166+
167+
info["Started"] = true
168+
info["PodAutoscalerCount"] = liveStore.Count()
169+
if isLeader != nil {
170+
info["IsLeader"] = isLeader()
171+
}
172+
if rcInstance != "" {
173+
info["RemoteConfigInstance"] = rcInstance
174+
}
175+
176+
now := time.Now()
177+
products := make([]map[string]interface{}, 0, len(autoscalingProducts))
178+
connected := false
179+
180+
rcTracker.RLock()
181+
for _, product := range autoscalingProducts {
182+
entry := map[string]interface{}{"Product": product}
183+
tracked, found := rcTracker.byProduct[product]
184+
if !found || tracked.LastUpdate.IsZero() {
185+
// Subscribed, but the backend has not sent anything yet. This is the
186+
// normal state for an org with no autoscalers configured.
187+
entry["Received"] = false
188+
} else {
189+
connected = true
190+
entry["Received"] = true
191+
entry["LastUpdate"] = tracked.LastUpdate.UTC().Format(time.RFC3339)
192+
entry["LastUpdateAge"] = now.Sub(tracked.LastUpdate).Truncate(time.Second).String()
193+
entry["LastVersion"] = tracked.LastVersion
194+
entry["ConfigCount"] = tracked.ConfigCount
195+
entry["UpdateCount"] = tracked.UpdateCount
196+
if tracked.LastError != "" {
197+
entry["LastError"] = tracked.LastError
198+
entry["LastErrorTime"] = tracked.LastErrorTime.UTC().Format(time.RFC3339)
199+
}
200+
}
201+
products = append(products, entry)
202+
}
203+
rcTracker.RUnlock()
204+
205+
sort.Slice(products, func(i, j int) bool {
206+
return products[i]["Product"].(string) < products[j]["Product"].(string)
207+
})
208+
info["RemoteConfigProducts"] = products
209+
// "Connected" means at least one autoscaling product has delivered an update
210+
// to this process. It is subscription-level health, not TCP connectivity --
211+
// see the Remote Configuration section for the transport/auth state.
212+
info["RemoteConfigConnected"] = connected
213+
214+
stats["workloadAutoscaling"] = info
215+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{{- with .workloadAutoscaling }}
2+
{{- if .Disabled }}
3+
{{ .Disabled }}
4+
{{- else if not .Started }}
5+
Workload autoscaling is enabled but has not started yet.
6+
{{- else }}
7+
Workload
8+
--------
9+
DatadogPodAutoscalers: {{ .PodAutoscalerCount }}
10+
Leader: {{ if .IsLeader }}true{{ else }}false{{ end }}
11+
Remote Config connection: {{ if .RemoteConfigConnected }}Receiving updates{{ else }}No update received yet{{ end }}
12+
{{- if .RemoteConfigInstance }}
13+
Remote Config client: {{ .RemoteConfigInstance }}
14+
{{- end }}
15+
{{- range .RemoteConfigProducts }}
16+
17+
{{ .Product }}
18+
{{- if not .Received }}
19+
No update received yet
20+
{{- else }}
21+
Last config version: {{ .LastVersion }}
22+
Last update: {{ .LastUpdate }} ({{ .LastUpdateAge }} ago)
23+
Configs in last update: {{ .ConfigCount }}
24+
Updates received: {{ .UpdateCount }}
25+
{{- if .LastError }}
26+
Last error: {{ .LastError }} (at {{ .LastErrorTime }})
27+
{{- end }}
28+
{{- end }}
29+
{{- end }}
30+
{{- end }}
31+
{{- end }}

0 commit comments

Comments
 (0)