diff --git a/pkg/instrumentation/auto/language_detector.go b/pkg/instrumentation/auto/language_detector.go new file mode 100644 index 000000000..38a12925d --- /dev/null +++ b/pkg/instrumentation/auto/language_detector.go @@ -0,0 +1,211 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package auto + +import ( + "strings" + + "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" + + "github.com/aws/amazon-cloudwatch-agent-operator/pkg/instrumentation" +) + +// languageDetector determines the application runtime language from container +// metadata available in the pod spec: image name, env vars, and command/args. +type languageDetector struct { + logger logr.Logger +} + +func newLanguageDetector(logger logr.Logger) *languageDetector { + return &languageDetector{ + logger: logger, + } +} + +// detectLanguages inspects all containers in a pod template and returns the set of +// detected languages. Returns an empty set if no language can be confidently determined. +func (d *languageDetector) detectLanguages(podSpec *corev1.PodTemplateSpec) instrumentation.TypeSet { + detected := make(instrumentation.TypeSet) + + for _, container := range podSpec.Spec.Containers { + if lang := d.detectContainer(container); lang != "" { + d.logger.V(2).Info("detected language from container", + "container", container.Name, "image", container.Image, "language", lang) + detected[lang] = nil + } + } + + return detected +} + +// detectContainer determines the language from pod-spec metadata only: +// image name patterns, env vars, and command/args. +func (d *languageDetector) detectContainer(container corev1.Container) instrumentation.Type { + if lang := d.detectFromImageName(container.Image); lang != "" { + return lang + } + if lang := d.detectFromEnvVars(container.Env); lang != "" { + return lang + } + if lang := d.detectFromCommand(container.Command, container.Args); lang != "" { + return lang + } + return "" +} + +// detectFromImageName checks the container image reference string for language indicators. +func (d *languageDetector) detectFromImageName(image string) instrumentation.Type { + lower := strings.ToLower(image) + + javaPatterns := []string{ + "openjdk", "jdk", "jre", "eclipse-temurin", "amazoncorretto", + "corretto", "adoptopenjdk", "ibm-semeru", "graalvm", + "tomcat", "jetty", "wildfly", "quarkus", "springboot", + "spring-boot", "maven", "gradle", "libertycore", "payara", + } + for _, p := range javaPatterns { + if strings.Contains(lower, p) { + return instrumentation.TypeJava + } + } + if strings.Contains(lower, "java") && !strings.Contains(lower, "javascript") { + return instrumentation.TypeJava + } + + pythonPatterns := []string{ + "python", "django", "flask", "fastapi", "uvicorn", + "gunicorn", "celery", "conda", "miniconda", "anaconda", + } + for _, p := range pythonPatterns { + if strings.Contains(lower, p) { + return instrumentation.TypePython + } + } + + nodePatterns := []string{ + "node:", "/node:", "nodejs", "node-", "-node", + "express", "nextjs", "next.js", "nestjs", + } + for _, p := range nodePatterns { + if strings.Contains(lower, p) { + return instrumentation.TypeNodeJS + } + } + + dotnetPatterns := []string{ + "dotnet", "aspnet", "asp.net", "mcr.microsoft.com/dotnet", + } + for _, p := range dotnetPatterns { + if strings.Contains(lower, p) { + return instrumentation.TypeDotNet + } + } + + return "" +} + + +// detectFromEnvVars checks environment variables from the pod spec (corev1.EnvVar format). +func (d *languageDetector) detectFromEnvVars(envVars []corev1.EnvVar) instrumentation.Type { + for _, env := range envVars { + envName := strings.ToUpper(env.Name) + envValue := strings.ToLower(env.Value) + + if lang := d.classifyEnv(envName, envValue); lang != "" { + return lang + } + } + return "" +} + +// classifyEnv determines the language from an env var name and value. +func (d *languageDetector) classifyEnv(name, value string) instrumentation.Type { + switch name { + case "JAVA_HOME", "JAVA_TOOL_OPTIONS", "JAVA_OPTS", + "JVM_OPTS", "CATALINA_HOME", "CATALINA_OPTS", + "MAVEN_HOME", "GRADLE_HOME": + return instrumentation.TypeJava + } + + switch name { + case "PYTHONPATH", "PYTHONHOME", "PYTHONDONTWRITEBYTECODE", + "PYTHONUNBUFFERED", "PIP_NO_CACHE_DIR", + "PYTHON_VERSION", "PYTHON_SHA256", "PYTHON_PIP_VERSION", + "DJANGO_SETTINGS_MODULE", "FLASK_APP": + return instrumentation.TypePython + } + + switch name { + case "NODE_PATH", "NODE_ENV", "NODE_OPTIONS", + "NPM_CONFIG_PREFIX", "YARN_CACHE_FOLDER", + "NODE_VERSION", "YARN_VERSION": + return instrumentation.TypeNodeJS + } + + switch name { + case "DOTNET_ROOT", "ASPNETCORE_URLS", "ASPNETCORE_ENVIRONMENT", + "DOTNET_RUNNING_IN_CONTAINER", "DOTNET_SYSTEM_GLOBALIZATION_INVARIANT", + "NUGET_PACKAGES", "CORECLR_ENABLE_PROFILING": + return instrumentation.TypeDotNet + } + + if name == "PATH" { + if strings.Contains(value, "/usr/lib/jvm") || strings.Contains(value, "java") { + return instrumentation.TypeJava + } + if strings.Contains(value, "python") { + return instrumentation.TypePython + } + if strings.Contains(value, "/usr/local/lib/node") || strings.Contains(value, "nodejs") { + return instrumentation.TypeNodeJS + } + if strings.Contains(value, "dotnet") { + return instrumentation.TypeDotNet + } + } + return "" +} + +// detectFromCommand checks entrypoint and command args for language indicators. +func (d *languageDetector) detectFromCommand(command []string, args []string) instrumentation.Type { + allParts := append(command, args...) + if len(allParts) == 0 { + return "" + } + + for _, part := range allParts { + lower := strings.ToLower(part) + + if lower == "java" || strings.HasSuffix(lower, "/java") || + strings.HasSuffix(lower, ".jar") || + strings.Contains(lower, "-javaagent:") || + strings.Contains(lower, "org.apache.catalina") || + strings.Contains(lower, "org.springframework") { + return instrumentation.TypeJava + } + + if lower == "python" || lower == "python3" || lower == "python2" || + strings.HasSuffix(lower, "/python") || strings.HasSuffix(lower, "/python3") || + strings.HasSuffix(lower, ".py") || + lower == "gunicorn" || lower == "uvicorn" || lower == "celery" || + lower == "django-admin" || lower == "flask" { + return instrumentation.TypePython + } + + if lower == "node" || lower == "nodejs" || + strings.HasSuffix(lower, "/node") || + strings.HasSuffix(lower, ".js") || strings.HasSuffix(lower, ".mjs") || + lower == "npm" || lower == "yarn" || lower == "npx" || lower == "pnpm" { + return instrumentation.TypeNodeJS + } + + if lower == "dotnet" || strings.HasSuffix(lower, "/dotnet") || + strings.HasSuffix(lower, ".dll") { + return instrumentation.TypeDotNet + } + } + + return "" +} diff --git a/pkg/instrumentation/auto/language_detector_test.go b/pkg/instrumentation/auto/language_detector_test.go new file mode 100644 index 000000000..7eceb3cd6 --- /dev/null +++ b/pkg/instrumentation/auto/language_detector_test.go @@ -0,0 +1,212 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package auto + +import ( + "testing" + + "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" + + "github.com/aws/amazon-cloudwatch-agent-operator/pkg/instrumentation" +) + +func newTestDetector() *languageDetector { + return &languageDetector{logger: logr.Discard()} +} + +func TestClassifyEnv(t *testing.T) { + d := newTestDetector() + + tests := []struct { + name string + envName string + envValue string + expected instrumentation.Type + }{ + {"JAVA_HOME", "JAVA_HOME", "/usr/lib/jvm/java-17", instrumentation.TypeJava}, + {"JAVA_OPTS", "JAVA_OPTS", "-Xmx512m", instrumentation.TypeJava}, + {"CATALINA_HOME", "CATALINA_HOME", "/opt/tomcat", instrumentation.TypeJava}, + {"PYTHONPATH", "PYTHONPATH", "/app", instrumentation.TypePython}, + {"DJANGO_SETTINGS", "DJANGO_SETTINGS_MODULE", "myapp.settings", instrumentation.TypePython}, + {"FLASK_APP", "FLASK_APP", "app.py", instrumentation.TypePython}, + {"PYTHONUNBUFFERED", "PYTHONUNBUFFERED", "1", instrumentation.TypePython}, + {"NODE_ENV", "NODE_ENV", "production", instrumentation.TypeNodeJS}, + {"NODE_OPTIONS", "NODE_OPTIONS", "--max-old-space-size=4096", instrumentation.TypeNodeJS}, + {"NODE_VERSION", "NODE_VERSION", "20.11.0", instrumentation.TypeNodeJS}, + {"DOTNET_ROOT", "DOTNET_ROOT", "/usr/share/dotnet", instrumentation.TypeDotNet}, + {"ASPNETCORE_URLS", "ASPNETCORE_URLS", "http://+:8080", instrumentation.TypeDotNet}, + {"ASPNETCORE_ENVIRONMENT", "ASPNETCORE_ENVIRONMENT", "production", instrumentation.TypeDotNet}, + {"PATH with java", "PATH", "/usr/lib/jvm/bin:/usr/bin", instrumentation.TypeJava}, + {"PATH with python", "PATH", "/usr/local/bin/python:/usr/bin", instrumentation.TypePython}, + {"PATH with dotnet", "PATH", "/usr/share/dotnet:/usr/bin", instrumentation.TypeDotNet}, + {"PYTHON_VERSION", "PYTHON_VERSION", "3.11.15", instrumentation.TypePython}, + {"PYTHON_SHA256", "PYTHON_SHA256", "abc123", instrumentation.TypePython}, + {"PYTHON_PIP_VERSION", "PYTHON_PIP_VERSION", "23.0.1", instrumentation.TypePython}, + {"YARN_VERSION", "YARN_VERSION", "1.22.22", instrumentation.TypeNodeJS}, + {"generic env", "APP_PORT", "8080", ""}, + {"empty", "", "", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := d.classifyEnv(tt.envName, tt.envValue) + if result != tt.expected { + t.Errorf("classifyEnv(%q, %q) = %q, want %q", tt.envName, tt.envValue, result, tt.expected) + } + }) + } +} + +func TestDetectFromImageName(t *testing.T) { + d := newTestDetector() + + tests := []struct { + name string + image string + expected instrumentation.Type + }{ + {"openjdk", "public.ecr.aws/docker/library/openjdk:17-slim", instrumentation.TypeJava}, + {"corretto", "amazoncorretto:17", instrumentation.TypeJava}, + {"tomcat", "tomcat:10-jdk17", instrumentation.TypeJava}, + {"java in ecr path", "978751493859.dkr.ecr.us-east-1.amazonaws.com/java-sample-app:latest", instrumentation.TypeJava}, + {"python", "public.ecr.aws/docker/library/python:3.11-slim", instrumentation.TypePython}, + {"django", "mycompany/django-app:latest", instrumentation.TypePython}, + {"node official", "node:20-alpine", instrumentation.TypeNodeJS}, + {"nodejs in name", "mycompany/nodejs-api:v2", instrumentation.TypeNodeJS}, + {"dotnet sdk", "mcr.microsoft.com/dotnet/sdk:8.0", instrumentation.TypeDotNet}, + {"aspnet", "mcr.microsoft.com/dotnet/aspnet:8.0", instrumentation.TypeDotNet}, + {"javascript not java", "mycompany/javascript-tools:latest", ""}, + {"ecr image with python in name", "978751493859.dkr.ecr.us-east-1.amazonaws.com/test-custom-python:latest", instrumentation.TypePython}, + {"truly opaque ecr image", "978751493859.dkr.ecr.us-east-1.amazonaws.com/service-abc:v2.3.1", ""}, + {"alpine", "alpine:3.19", ""}, + {"nginx", "nginx:1.25", ""}, + {"busybox", "busybox:latest", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := d.detectFromImageName(tt.image) + if result != tt.expected { + t.Errorf("detectFromImageName(%q) = %q, want %q", tt.image, result, tt.expected) + } + }) + } +} + +func TestDetectFromCommand(t *testing.T) { + d := newTestDetector() + + tests := []struct { + name string + command []string + args []string + expected instrumentation.Type + }{ + {"java command", []string{"java"}, []string{"-jar", "app.jar"}, instrumentation.TypeJava}, + {"java full path", []string{"/usr/bin/java"}, []string{"-jar", "app.jar"}, instrumentation.TypeJava}, + {"jar in args", []string{"sh", "-c"}, []string{"java -jar /app/service.jar"}, instrumentation.TypeJava}, + {"python command", []string{"python3"}, []string{"app.py"}, instrumentation.TypePython}, + {"python full path", []string{"/usr/local/bin/python"}, []string{"manage.py"}, instrumentation.TypePython}, + {"gunicorn", []string{"gunicorn"}, []string{"app:app"}, instrumentation.TypePython}, + {"uvicorn", []string{"uvicorn"}, []string{"main:app", "--host", "0.0.0.0"}, instrumentation.TypePython}, + {"flask", []string{"flask"}, []string{"run"}, instrumentation.TypePython}, + {".py file", []string{"python3"}, []string{"/app/main.py"}, instrumentation.TypePython}, + {"node command", []string{"node"}, []string{"server.js"}, instrumentation.TypeNodeJS}, + {"node full path", []string{"/usr/local/bin/node"}, []string{"index.js"}, instrumentation.TypeNodeJS}, + {"npm start", []string{"npm"}, []string{"start"}, instrumentation.TypeNodeJS}, + {"yarn", []string{"yarn"}, []string{"serve"}, instrumentation.TypeNodeJS}, + {".js file", []string{"node"}, []string{"/app/dist/main.js"}, instrumentation.TypeNodeJS}, + {".mjs file", []string{"node"}, []string{"app.mjs"}, instrumentation.TypeNodeJS}, + {"dotnet command", []string{"dotnet"}, []string{"MyApp.dll"}, instrumentation.TypeDotNet}, + {"dotnet full path", []string{"/usr/share/dotnet/dotnet"}, []string{"run"}, instrumentation.TypeDotNet}, + {".dll file", []string{"dotnet"}, []string{"/app/MyService.dll"}, instrumentation.TypeDotNet}, + {"sleep command", []string{"sleep"}, []string{"infinity"}, ""}, + {"shell command", []string{"sh", "-c"}, []string{"echo hello"}, ""}, + {"empty", []string{}, []string{}, ""}, + {"nginx", []string{"nginx"}, []string{"-g", "daemon off;"}, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := d.detectFromCommand(tt.command, tt.args) + if result != tt.expected { + t.Errorf("detectFromCommand(%v, %v) = %q, want %q", tt.command, tt.args, result, tt.expected) + } + }) + } +} + +func TestDetectFromEnvVars_PodSpec(t *testing.T) { + d := newTestDetector() + + tests := []struct { + name string + env []corev1.EnvVar + expected instrumentation.Type + }{ + {"JAVA_HOME", []corev1.EnvVar{{Name: "JAVA_HOME", Value: "/usr/lib/jvm/java-17"}}, instrumentation.TypeJava}, + {"PYTHONPATH", []corev1.EnvVar{{Name: "PYTHONPATH", Value: "/app"}}, instrumentation.TypePython}, + {"NODE_ENV", []corev1.EnvVar{{Name: "NODE_ENV", Value: "production"}}, instrumentation.TypeNodeJS}, + {"ASPNETCORE_URLS", []corev1.EnvVar{{Name: "ASPNETCORE_URLS", Value: "http://+:8080"}}, instrumentation.TypeDotNet}, + {"generic env", []corev1.EnvVar{{Name: "APP_PORT", Value: "8080"}}, ""}, + {"empty", []corev1.EnvVar{}, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := d.detectFromEnvVars(tt.env) + if result != tt.expected { + t.Errorf("detectFromEnvVars() = %q, want %q", result, tt.expected) + } + }) + } +} + +func TestDetectContainer(t *testing.T) { + d := newTestDetector() + + tests := []struct { + name string + container corev1.Container + expected instrumentation.Type + }{ + { + name: "detected from image name", + container: corev1.Container{Image: "amazoncorretto:17"}, + expected: instrumentation.TypeJava, + }, + { + name: "detected from env var", + container: corev1.Container{ + Image: "123456789.dkr.ecr.us-east-1.amazonaws.com/my-app:latest", + Env: []corev1.EnvVar{{Name: "JAVA_HOME", Value: "/usr/lib/jvm/java-17"}}, + }, + expected: instrumentation.TypeJava, + }, + { + name: "detected from command", + container: corev1.Container{ + Image: "123456789.dkr.ecr.us-east-1.amazonaws.com/my-app:latest", + Command: []string{"python3"}, + Args: []string{"app.py"}, + }, + expected: instrumentation.TypePython, + }, + { + name: "opaque image, no signals", + container: corev1.Container{Image: "123456789.dkr.ecr.us-east-1.amazonaws.com/my-app:latest"}, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := d.detectContainer(tt.container) + if result != tt.expected { + t.Errorf("detectContainer() = %q, want %q", result, tt.expected) + } + }) + } +} diff --git a/pkg/instrumentation/auto/monitor.go b/pkg/instrumentation/auto/monitor.go index 82eaef7ce..ceb0dc505 100644 --- a/pkg/instrumentation/auto/monitor.go +++ b/pkg/instrumentation/auto/monitor.go @@ -95,6 +95,7 @@ type Monitor struct { deploymentInformer cache.SharedIndexInformer daemonsetInformer cache.SharedIndexInformer statefulsetInformer cache.SharedIndexInformer + langDetector *languageDetector } func (m *Monitor) MutateAndPatchAll(ctx context.Context) { @@ -179,6 +180,7 @@ func NewMonitor(ctx context.Context, config MonitorConfig, k8sClient kubernetes. deploymentInformer: deploymentInformer, daemonsetInformer: daemonsetInformer, statefulsetInformer: statefulSetInformer, + langDetector: newLanguageDetector(logger), } _, err = serviceInformer.AddEventHandler(cache.ResourceEventHandlerFuncs{ @@ -450,7 +452,9 @@ func getTemplateSpecLabels(obj metav1.Object) labels.Set { } } -// MutateObject adds all enabled languages in config. Should only be run if selected by auto monitor or custom selector +// MutateObject adds detected or configured languages. When auto-monitor is active, it first +// attempts to detect the application language from container image, env vars, and commands. +// Falls back to all configured languages only if detection yields no results. func (m *Monitor) MutateObject(oldObj client.Object, obj client.Object) any { if !safeToMutate(oldObj, obj, m.config.RestartPods) { return map[string]string{} @@ -458,8 +462,22 @@ func (m *Monitor) MutateObject(oldObj client.Object, obj client.Object) any { languagesToAnnotate := m.config.CustomSelector.LanguagesOf(obj, false) if m.isWorkloadAutoMonitored(obj) { - for l := range m.config.Languages { - languagesToAnnotate[l] = nil + // Attempt to detect the language from container spec before falling back to all languages + detected := m.detectLanguagesFromWorkload(obj) + if len(detected) > 0 { + m.logger.V(1).Info("auto-monitor detected language(s) from container spec", + "objName", obj.GetName(), "detected", detected) + for l := range detected { + if _, ok := m.config.Languages[l]; ok { + languagesToAnnotate[l] = nil + } + } + } else { + m.logger.V(1).Info("auto-monitor could not detect language, falling back to all configured languages", + "objName", obj.GetName(), "languages", m.config.Languages) + for l := range m.config.Languages { + languagesToAnnotate[l] = nil + } } } @@ -471,6 +489,15 @@ func (m *Monitor) MutateObject(oldObj client.Object, obj client.Object) any { return mutate(obj, languagesToAnnotate) } +// detectLanguagesFromWorkload extracts the pod template from a workload and runs language detection. +func (m *Monitor) detectLanguagesFromWorkload(obj client.Object) instrumentation.TypeSet { + podTemplate := getPodTemplate(obj) + if podTemplate == nil { + return nil + } + return m.langDetector.detectLanguages(podTemplate) +} + // returns if workload is auto monitored (does not include custom selector) func (m *Monitor) isWorkloadAutoMonitored(obj client.Object) bool { if isNamespace(obj) {