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",