From 1e7cbdccb3c5ae1c80776d2258b8760af7cd5325 Mon Sep 17 00:00:00 2001 From: Jonathan Lee Date: Wed, 8 Jul 2026 15:18:20 -0700 Subject: [PATCH] feat(instrumentation): inject k8s.cluster.name (+ cloud.platform on EKS) into instrumented pods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ADOT SDKs resolve aws.local.environment (eks:/) for ServiceEvents / Dynamic Instrumentation, but the in-pod EKS detector is blocked by RBAC (HTTP 403), so the SDK can't discover the cluster name on its own. The operator already has the cluster name (from the agent config's hosted_in) and the K8S_MODE, so it injects them into every instrumented pod's default Instrumentation resource attributes: - k8s.cluster.name from the agent config's application_signals.hosted_in. - cloud.platform=aws_eks only when K8S_MODE==EKS, so the SDK yields the "eks:" prefix (vs the generic "k8s:" for ROSA / native K8s) — mirroring the agent's KubernetesMode-driven prefix. K8S_MODE is read from the operator env (set by the Helm chart from .Values.k8sMode). Returns nil when no cluster name is available, leaving resolution unchanged. --- .../collector/adapters/config_from.go | 20 +++++ pkg/instrumentation/defaultinstrumentation.go | 45 ++++++++++- .../defaultinstrumentation_test.go | 76 ++++++++++++++++++- pkg/instrumentation/podmutator.go | 7 +- pkg/instrumentation/podmutator_test.go | 2 +- 5 files changed, 144 insertions(+), 6 deletions(-) diff --git a/internal/manifests/collector/adapters/config_from.go b/internal/manifests/collector/adapters/config_from.go index e35e37fb8..94ef42da5 100644 --- a/internal/manifests/collector/adapters/config_from.go +++ b/internal/manifests/collector/adapters/config_from.go @@ -87,6 +87,11 @@ type collectD struct { type AppSignals struct { TLS *TLS `json:"tls,omitempty"` + // HostedIn is the Application Signals environment / cluster name the CloudWatch agent + // resolves into (set by the Helm chart from .Values.clusterName). Read-only here: the + // operator uses it to inject k8s.cluster.name into instrumented pods so the SDK resolves + // the same Environment the agent does. Does NOT affect the agent config itself. + HostedIn string `json:"hosted_in,omitempty"` } type emf struct { @@ -159,3 +164,18 @@ func (c *CwaConfig) GetApplicationSignalsTracesConfig() *AppSignals { } return nil } + +// GetClusterName returns the cluster name the CloudWatch agent resolves Application Signals +// into, taken from application_signals.hosted_in (logs or traces) in the agent config — the +// SAME value the agent uses. Returns "" if not present. Read-only; does not modify the agent +// config. The operator injects this as k8s.cluster.name on instrumented pods so the SDK +// (ServiceEvents / Dynamic Instrumentation) resolves the same Environment as AppSignals. +func (c *CwaConfig) GetClusterName() string { + if as := c.GetApplicationSignalsMetricsConfig(); as != nil && as.HostedIn != "" { + return as.HostedIn + } + if as := c.GetApplicationSignalsTracesConfig(); as != nil && as.HostedIn != "" { + return as.HostedIn + } + return "" +} diff --git a/pkg/instrumentation/defaultinstrumentation.go b/pkg/instrumentation/defaultinstrumentation.go index 0c7783dc0..c17fab936 100644 --- a/pkg/instrumentation/defaultinstrumentation.go +++ b/pkg/instrumentation/defaultinstrumentation.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "os" + "strings" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/resource" @@ -32,6 +33,11 @@ const ( nodeJS = "NODEJS" limit = "LIMIT" request = "REQUEST" + + // k8sModeEKS is the operator's k8s-mode value indicating an EKS cluster (mirrors the + // CloudWatch agent's KubernetesMode == ModeEKS); other modes (ROSA, native K8S) use the + // generic "k8s:" Environment prefix. + k8sModeEKS = "EKS" ) func getInstrumentationConfigForResource(langStr string, resourceStr string) corev1.ResourceList { @@ -50,7 +56,7 @@ func getInstrumentationConfigForResource(langStr string, resourceStr string) cor return instrumentationConfigForResource } -func getDefaultInstrumentation(agentConfig *adapters.CwaConfig, additionalEnvs map[Type]map[string]string, isWindowsPod bool) (*v1alpha1.Instrumentation, error) { +func getDefaultInstrumentation(agentConfig *adapters.CwaConfig, k8sMode string, additionalEnvs map[Type]map[string]string, isWindowsPod bool) (*v1alpha1.Instrumentation, error) { javaInstrumentationImage, ok := os.LookupEnv("AUTO_INSTRUMENTATION_JAVA") if !ok { return nil, errors.New("unable to determine java instrumentation image") @@ -85,6 +91,15 @@ func getDefaultInstrumentation(agentConfig *adapters.CwaConfig, additionalEnvs m } } + // Inject the cluster name (and, on EKS, cloud.platform=aws_eks) into instrumented pods so + // the SDK resolves the same Environment the CloudWatch agent does. The cluster name comes + // from the agent config's application_signals.hosted_in (the SAME value the agent uses) — + // the in-pod OTel EKS detector is RBAC-gated and the operator does not otherwise inject the + // cluster name. The eks-vs-k8s prefix is driven by k8sMode, mirroring the agent's + // KubernetesMode == ModeEKS ? "eks:" : "k8s:" logic (so AppSignals and + // ServiceEvents/DynamicInstrumentation stay consistent). See EKS_OPERATOR_CHANGE.md. + resourceAttributes := defaultResourceAttributes(agentConfig, k8sMode) + return &v1alpha1.Instrumentation{ Status: v1alpha1.InstrumentationStatus{}, TypeMeta: metav1.TypeMeta{ @@ -101,6 +116,9 @@ func getDefaultInstrumentation(agentConfig *adapters.CwaConfig, additionalEnvs m v1alpha1.Baggage, v1alpha1.XRay, }, + Resource: v1alpha1.Resource{ + Attributes: resourceAttributes, + }, Java: v1alpha1.Java{ Image: javaInstrumentationImage, Env: getJavaEnvs(isApplicationSignalsEnabled, cloudwatchAgentServiceEndpoint, exporterPrefix, additionalEnvs[TypeJava]), @@ -137,6 +155,31 @@ func getDefaultInstrumentation(agentConfig *adapters.CwaConfig, additionalEnvs m }, nil } +// defaultResourceAttributes builds the resource attributes the operator injects into every +// instrumented pod's default Instrumentation. It sets k8s.cluster.name (from the agent +// config's hosted_in) so the SDK can resolve the platform-scoped Environment, and on EKS +// also cloud.platform=aws_eks so the SDK yields the "eks:" prefix (vs the generic "k8s:" for +// ROSA / native K8s) — mirroring the agent's KubernetesMode-driven prefix. Returns nil when +// no cluster name is available (e.g. EC2/on-prem-no-cluster), leaving resolution unchanged. +func defaultResourceAttributes(agentConfig *adapters.CwaConfig, k8sMode string) map[string]string { + if agentConfig == nil { + return nil + } + clusterName := agentConfig.GetClusterName() + if clusterName == "" { + return nil + } + attrs := map[string]string{ + "k8s.cluster.name": clusterName, + } + // Only claim the EKS platform when the deployment is actually EKS; ROSA / native K8s must + // keep the generic "k8s:" prefix, matching the agent (KubernetesMode == ModeEKS ? eks : k8s). + if strings.EqualFold(k8sMode, k8sModeEKS) { + attrs["cloud.platform"] = "aws_eks" + } + return attrs +} + // getSharedOtlpEndpointEnvs returns the OTLP logs/metrics endpoints routed to the CloudWatch Agent. // Shared by Service Events and Dynamic Instrumentation func getSharedOtlpEndpointEnvs(cloudwatchAgentServiceEndpoint, exporterPrefix string) []corev1.EnvVar { diff --git a/pkg/instrumentation/defaultinstrumentation_test.go b/pkg/instrumentation/defaultinstrumentation_test.go index d9041a2df..14d9208b4 100644 --- a/pkg/instrumentation/defaultinstrumentation_test.go +++ b/pkg/instrumentation/defaultinstrumentation_test.go @@ -352,7 +352,7 @@ func Test_getDefaultInstrumentationLinux(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := getDefaultInstrumentation(tt.args.agentConfig, nil, false) + got, err := getDefaultInstrumentation(tt.args.agentConfig, "", nil, false) if (err != nil) != tt.wantErr { t.Errorf("getDefaultInstrumentation() error = %v, wantErr %v", err, tt.wantErr) return @@ -365,6 +365,76 @@ func Test_getDefaultInstrumentationLinux(t *testing.T) { } +func Test_defaultResourceAttributes(t *testing.T) { + withHostedIn := func(cluster string) *adapters.CwaConfig { + return &adapters.CwaConfig{ + Logs: &adapters.Logs{ + LogMetricsCollected: &adapters.LogMetricsCollected{ + ApplicationSignals: &adapters.AppSignals{HostedIn: cluster}, + }, + }, + } + } + + tests := []struct { + name string + agentConfig *adapters.CwaConfig + k8sMode string + want map[string]string + }{ + { + name: "nil agent config -> nil", + agentConfig: nil, + k8sMode: "EKS", + want: nil, + }, + { + name: "no cluster name (no hosted_in) -> nil", + agentConfig: &adapters.CwaConfig{Logs: &adapters.Logs{LogMetricsCollected: &adapters.LogMetricsCollected{ApplicationSignals: &adapters.AppSignals{}}}}, + k8sMode: "EKS", + want: nil, + }, + { + name: "EKS -> cluster name + cloud.platform=aws_eks", + agentConfig: withHostedIn("my-cluster"), + k8sMode: "EKS", + want: map[string]string{"k8s.cluster.name": "my-cluster", "cloud.platform": "aws_eks"}, + }, + { + name: "EKS case-insensitive -> cloud.platform set", + agentConfig: withHostedIn("my-cluster"), + k8sMode: "eks", + want: map[string]string{"k8s.cluster.name": "my-cluster", "cloud.platform": "aws_eks"}, + }, + { + name: "native K8s -> cluster name only (k8s: prefix)", + agentConfig: withHostedIn("my-cluster"), + k8sMode: "K8S", + want: map[string]string{"k8s.cluster.name": "my-cluster"}, + }, + { + name: "ROSA -> cluster name only (k8s: prefix)", + agentConfig: withHostedIn("my-cluster"), + k8sMode: "ROSA", + want: map[string]string{"k8s.cluster.name": "my-cluster"}, + }, + { + name: "empty k8sMode -> cluster name only", + agentConfig: withHostedIn("my-cluster"), + k8sMode: "", + want: map[string]string{"k8s.cluster.name": "my-cluster"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := defaultResourceAttributes(tt.agentConfig, tt.k8sMode) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("defaultResourceAttributes() got = %v, want %v", got, tt.want) + } + }) + } +} + func Test_getDefaultInstrumentationWindows(t *testing.T) { _ = os.Setenv("AUTO_INSTRUMENTATION_JAVA", defaultJavaInstrumentationImage) _ = os.Setenv("AUTO_INSTRUMENTATION_PYTHON", defaultPythonInstrumentationImage) @@ -700,7 +770,7 @@ func Test_getDefaultInstrumentationWindows(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := getDefaultInstrumentation(tt.args.agentConfig, nil, true) + got, err := getDefaultInstrumentation(tt.args.agentConfig, "", nil, true) if (err != nil) != tt.wantErr { t.Errorf("getDefaultInstrumentation() error = %v, wantErr %v", err, tt.wantErr) return @@ -939,7 +1009,7 @@ func Test_getDefaultInstrumentationLinuxWithApplicationSignalsDisabled(t *testin for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := getDefaultInstrumentation(tt.args.agentConfig, tt.args.additionalEnvs, false) + got, err := getDefaultInstrumentation(tt.args.agentConfig, "", tt.args.additionalEnvs, false) if (err != nil) != tt.wantErr { t.Errorf("getDefaultInstrumentation() error = %v, wantErr %v", err, tt.wantErr) return diff --git a/pkg/instrumentation/podmutator.go b/pkg/instrumentation/podmutator.go index 16ea40e94..f17bc1790 100644 --- a/pkg/instrumentation/podmutator.go +++ b/pkg/instrumentation/podmutator.go @@ -7,6 +7,7 @@ import ( "context" "errors" "fmt" + "os" "strings" "github.com/go-logr/logr" @@ -414,7 +415,11 @@ func (pm *instPodMutator) selectInstrumentationInstanceFromNamespace(ctx context pm.Logger.Error(err, "unable to retrieve cloudwatch agent config for instrumentation") } - return getDefaultInstrumentation(config, additionalEnvs, isWindowsPod) + // K8S_MODE (EKS | ROSA | K8S) is set on the operator by the Helm chart from + // .Values.k8sMode; it drives the eks: vs k8s: Environment prefix, mirroring the + // CloudWatch agent's KubernetesMode. Empty (e.g. non-K8s) → no platform claimed. + k8sMode := os.Getenv("K8S_MODE") + return getDefaultInstrumentation(config, k8sMode, additionalEnvs, isWindowsPod) case s > 1: return nil, errMultipleInstancesPossible default: diff --git a/pkg/instrumentation/podmutator_test.go b/pkg/instrumentation/podmutator_test.go index a9a809c80..502532156 100644 --- a/pkg/instrumentation/podmutator_test.go +++ b/pkg/instrumentation/podmutator_test.go @@ -32,7 +32,7 @@ const ( ) func TestGetInstrumentationInstanceFromNameSpaceDefault(t *testing.T) { - defaultInst, _ := getDefaultInstrumentation(&adapters.CwaConfig{}, nil, false) + defaultInst, _ := getDefaultInstrumentation(&adapters.CwaConfig{}, "", nil, false) namespace := corev1.Namespace{ ObjectMeta: metav1.ObjectMeta{ Name: "default-namespace",