From b9ba568701f624ee26b17f6917cb176532aaa1df Mon Sep 17 00:00:00 2001 From: jeffluoo Date: Thu, 4 Sep 2025 00:55:11 -0400 Subject: [PATCH 1/2] [processor/k8sattributes] Add feature to extract deployment name from replicaset name Fixes https://github.com/open-telemetry/opentelemetry-collector-contrib/issues/42530 --- ...cessor-fetch-deployment-name-from-ref.yaml | 27 ++ processor/k8sattributesprocessor/README.md | 22 +- processor/k8sattributesprocessor/config.go | 4 + .../k8sattributesprocessor/config_test.go | 12 + processor/k8sattributesprocessor/factory.go | 1 + .../internal/kube/client.go | 56 ++- .../internal/kube/client_test.go | 385 ++++++++++++++++++ .../internal/kube/kube.go | 5 +- processor/k8sattributesprocessor/options.go | 7 + .../testdata/config.yaml | 3 + 10 files changed, 506 insertions(+), 16 deletions(-) create mode 100644 .chloggen/k8sattributesprocessor-fetch-deployment-name-from-ref.yaml diff --git a/.chloggen/k8sattributesprocessor-fetch-deployment-name-from-ref.yaml b/.chloggen/k8sattributesprocessor-fetch-deployment-name-from-ref.yaml new file mode 100644 index 0000000000000..7242423b74fd5 --- /dev/null +++ b/.chloggen/k8sattributesprocessor-fetch-deployment-name-from-ref.yaml @@ -0,0 +1,27 @@ +# Use this changelog template to create an entry for release notes. + +# One of 'breaking', 'deprecation', 'new_component', 'enhancement', 'bug_fix' +change_type: enhancement + +# The name of the component, or a single word describing the area of concern, (e.g. filelogreceiver) +component: processor/k8sattributes + +# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`). +note: "Support extracting deployment name purely from the owner reference" + +# Mandatory: One or more tracking issues related to the change. You can use the PR number here if no issue exists. +issues: [42530] + +# (Optional) One or more lines of additional information to render under the primary note. +# These lines will be padded with 2 spaces and then inserted directly into the document. +# Use pipe (|) for multiline entries. +subtext: + +# If your change doesn't affect end users or the exported elements of any package, +# you should instead start your pull request title with [chore] or use the "Skip Changelog" label. +# Optional: The change log or logs in which this entry should be included. +# e.g. '[user]' or '[user, api]' +# Include 'user' if the change is relevant to end users. +# Include 'api' if there is a change to a library API. +# Default: '[user]' +change_logs: [] diff --git a/processor/k8sattributesprocessor/README.md b/processor/k8sattributesprocessor/README.md index 69fd0c1b4640c..9498060a27f5c 100644 --- a/processor/k8sattributesprocessor/README.md +++ b/processor/k8sattributesprocessor/README.md @@ -268,10 +268,28 @@ The processor can be configured to set the [recommended resource attributes](https://opentelemetry.io/docs/specs/semconv/non-normative/k8s-attributes/): - `otel_annotations` will translate `resource.opentelemetry.io/foo` to the `foo` resource attribute, etc. +- `deployment_name_from_replicaset` allows extracting deployment name from replicaset name by trimming pod template hash. This will disable watching for replicaset resources, which can be useful in environments with limited RBAC permissions as the processor will not need `get`, `watch`, and `list` permissions for `replicasets`. It also reduces memory consumption of the processor. When enabled, this feature works automatically with the existing deployment name extraction. Take the following ownerReference of a pod managed by deployment for example: + +```yaml + ownerReferences: + - apiVersion: apps/v1 + blockOwnerDeletion: true + controller: true + kind: ReplicaSet + name: opentelemetry-collector-6c45f8d6f6 + uid: ee75293d-14ec-42a0-9548-a768d9e07c48 +``` + +The Extracted deployment name is: `opentelemetry-collector`. + +> Please note, if your pods are managed by a replicaset but not by a deployment the `k8s.deployment.name` will be set incorrectly. For example, if the replicaset is named `opentelemetry-collector-6c45f8d6f6`, the feature will still set the deployment name of the pod to `opentelemetry-collector` because it skips watching for the deployment and has no context if the pod is managed by a deployment or a replicaset. + +Example: ```yaml extract: otel_annotations: true + deployment_name_from_replicaset: true metadata: - service.namespace - service.name @@ -325,7 +343,7 @@ k8sattributes: ## Cluster-scoped RBAC -If you'd like to set up the k8sattributesprocessor to receive telemetry from across namespaces, it will need `get`, `watch` and `list` permissions on both `pods` and `namespaces` resources, for all namespaces and pods included in the configured filters. Additionally, when using `k8s.deployment.name` (which is enabled by default) or `k8s.deployment.uid` the processor also needs `get`, `watch` and `list` permissions for `replicasets` resources. When using `k8s.node.uid` or extracting metadata from `node`, the processor needs `get`, `watch` and `list` permissions for `nodes` resources. When using `k8s.cronjob.uid` the processor also needs `get`, `watch` and `list` permissions for `jobs` resources. +If you'd like to set up the k8sattributesprocessor to receive telemetry from across namespaces, it will need `get`, `watch` and `list` permissions on both `pods` and `namespaces` resources, for all namespaces and pods included in the configured filters. Additionally, when using `k8s.deployment.name` (which is enabled by default) or `k8s.deployment.uid` the processor also needs `get`, `watch` and `list` permissions for `replicasets` resources (unless `deployment_name_from_replicaset` is enabled). When using `k8s.node.uid` or extracting metadata from `node`, the processor needs `get`, `watch` and `list` permissions for `nodes` resources. When using `k8s.cronjob.uid` the processor also needs `get`, `watch` and `list` permissions for `jobs` resources. Here is an example of a `ClusterRole` to give a `ServiceAccount` the necessary permissions for all pods, nodes, and namespaces in the cluster (replace `` with a namespace where collector is deployed): @@ -375,7 +393,7 @@ k8sattributes: filter: namespace: ``` -With the namespace filter set, the processor will only look up pods and replicasets in the selected namespace. Note that with just a role binding, the processor cannot query metadata such as labels and annotations from k8s `nodes` and `namespaces` which are cluster-scoped objects. This also means that the processor cannot set the value for `k8s.cluster.uid` attribute if enabled, since the `k8s.cluster.uid` attribute is set to the uid of the namespace `kube-system` which is not queryable with namespaced rbac. +With the namespace filter set, the processor will only look up pods and replicasets (if `deployment_name_from_replicaset` is not enabled) in the selected namespace. Note that with just a role binding, the processor cannot query metadata such as labels and annotations from k8s `nodes` and `namespaces` which are cluster-scoped objects. This also means that the processor cannot set the value for `k8s.cluster.uid` attribute if enabled, since the `k8s.cluster.uid` attribute is set to the uid of the namespace `kube-system` which is not queryable with namespaced rbac. Please note, when extracting the workload related attributes, these workloads need to be present in the `Role` with the correct permissions. For example, an extraction of `k8s.deployment.label.*` attributes, `deployments` need to be present in `Role`. diff --git a/processor/k8sattributesprocessor/config.go b/processor/k8sattributesprocessor/config.go index 50a369b43ef7f..cbf1d191304b0 100644 --- a/processor/k8sattributesprocessor/config.go +++ b/processor/k8sattributesprocessor/config.go @@ -174,6 +174,10 @@ type ExtractConfig struct { // OtelAnnotations extracts all pod annotations with the prefix "resource.opentelemetry.io" as resource attributes // E.g. "resource.opentelemetry.io/foo" becomes "foo" OtelAnnotations bool `mapstructure:"otel_annotations"` + + // DeploymentNameFromReplicaSet allows extracting deployment name from replicaset name by trimming pod template hash. + // This will disable watching for replicaset resources. + DeploymentNameFromReplicaSet bool `mapstructure:"deployment_name_from_replicaset"` } // FieldExtractConfig allows specifying an extraction rule to extract a resource attribute from pod (or namespace) diff --git a/processor/k8sattributesprocessor/config_test.go b/processor/k8sattributesprocessor/config_test.go index c07b971d9ac71..8c24f5723e891 100644 --- a/processor/k8sattributesprocessor/config_test.go +++ b/processor/k8sattributesprocessor/config_test.go @@ -130,6 +130,18 @@ func TestLoadConfig(t *testing.T) { WaitForMetadataTimeout: 10 * time.Second, }, }, + { + id: component.NewIDWithName(metadata.Type, "deployment_name_from_replicaset"), + expected: &Config{ + APIConfig: k8sconfig.APIConfig{AuthType: k8sconfig.AuthTypeServiceAccount}, + Extract: ExtractConfig{ + Metadata: enabledAttributes(), + DeploymentNameFromReplicaSet: true, + }, + Exclude: defaultExcludes, + WaitForMetadataTimeout: 10 * time.Second, + }, + }, { id: component.NewIDWithName(metadata.Type, "too_many_sources"), }, diff --git a/processor/k8sattributesprocessor/factory.go b/processor/k8sattributesprocessor/factory.go index d61305ac6d85d..351f429d1c8c4 100644 --- a/processor/k8sattributesprocessor/factory.go +++ b/processor/k8sattributesprocessor/factory.go @@ -194,6 +194,7 @@ func createProcessorOpts(cfg component.Config) []option { withExtractLabels(oCfg.Extract.Labels...), withExtractAnnotations(oCfg.Extract.Annotations...), withOtelAnnotations(oCfg.Extract.OtelAnnotations), + withDeploymentNameFromReplicaSet(oCfg.Extract.DeploymentNameFromReplicaSet), // filters withFilterNode(oCfg.Filter.Node, oCfg.Filter.NodeFromEnvVar), withFilterNamespace(oCfg.Filter.Namespace), diff --git a/processor/k8sattributesprocessor/internal/kube/client.go b/processor/k8sattributesprocessor/internal/kube/client.go index 34ab33b5b2627..aafe458b12d38 100644 --- a/processor/k8sattributesprocessor/internal/kube/client.go +++ b/processor/k8sattributesprocessor/internal/kube/client.go @@ -140,6 +140,10 @@ var rRegex = regexp.MustCompile(`^(.*)-[0-9a-zA-Z]+$`) // format: [cronjob-name]-[time-hash-int] var cronJobRegex = regexp.MustCompile(`^(.*)-\d+$`) +// Extract Deployment name from the ReplicaSet name. Deployment name is created using +// format: [deployment-name]-[hash] +var deploymentHashSuffixPattern = regexp.MustCompile(`^[a-z0-9]{10}$`) + var errCannotRetrieveImage = errors.New("cannot retrieve image name") type InformersFactoryList struct { @@ -291,7 +295,9 @@ func (c *WatchClient) Start() error { synced := make([]cache.InformerSynced, 0) // start the replicaSet informer first, as the replica sets need to be // present at the time the pods are handled, to correctly establish the connection between pods and deployments - if c.Rules.DeploymentName || c.Rules.DeploymentUID { + // The replicaset informer is needed to get the deployment UID. + // It is also needed to get the deployment name if the feature gate is not enabled. + if c.Rules.DeploymentUID || (c.Rules.DeploymentName && !c.Rules.DeploymentNameFromReplicaSet) { reg, err := c.replicasetInformer.AddEventHandler(cache.ResourceEventHandlerFuncs{ AddFunc: c.handleReplicaSetAdd, UpdateFunc: c.handleReplicaSetUpdate, @@ -829,22 +835,25 @@ func (c *WatchClient) extractPodAttributes(pod *api_v1.Pod) map[string]string { tags[string(conventions.ServiceNameKey)] = ref.Name } if c.Rules.DeploymentName || c.Rules.ServiceName { - if replicaset, ok := c.GetReplicaSet(string(ref.UID)); ok { - name := replicaset.Deployment.Name - if name != "" { - if c.Rules.DeploymentName { - tags[string(conventions.K8SDeploymentNameKey)] = name - } - if c.Rules.ServiceName { - // deployment name wins over replicaset name - tags[string(conventions.ServiceNameKey)] = name - } + var deploymentName string + if c.Rules.DeploymentNameFromReplicaSet { + deploymentName = extractDeploymentNameFromReplicaSet(ref.Name) + } else if replicaset, ok := c.GetReplicaSet(string(ref.UID)); ok { + deploymentName = replicaset.Deployment.Name + } + if deploymentName != "" { + if c.Rules.DeploymentName { + tags[string(conventions.K8SDeploymentNameKey)] = deploymentName + } + if c.Rules.ServiceName { + // deployment name wins over replicaset name + tags[string(conventions.ServiceNameKey)] = deploymentName } } } if c.Rules.DeploymentUID { if replicaset, ok := c.GetReplicaSet(string(ref.UID)); ok { - if replicaset.Deployment.Name != "" { + if replicaset.Deployment.UID != "" { tags[string(conventions.K8SDeploymentUIDKey)] = replicaset.Deployment.UID } } @@ -1847,3 +1856,26 @@ func automaticServiceInstanceID(pod *api_v1.Pod, containerName string) string { resNames := []string{pod.Namespace, pod.Name, containerName} return strings.Join(resNames, ".") } + +// extractDeploymentNameFromReplicaSet attempts to extract deployment name from replicaset name +// by trimming the pod template hash suffix. ReplicaSets created by Deployments follow the pattern: +// - where pod-template-hash is a 10-character alphanumeric string. +func extractDeploymentNameFromReplicaSet(replicasetName string) string { + if replicasetName == "" { + return "" + } + + parts := strings.Split(replicasetName, "-") + if len(parts) < 2 { + return "" + } + + // Check if the last part is a valid 10-character alphanumeric hash using the pre-compiled regex. + lastPart := parts[len(parts)-1] + if deploymentHashSuffixPattern.MatchString(lastPart) { + // Return everything except the last part (the hash), joined back by hyphens. + return strings.Join(parts[:len(parts)-1], "-") + } + + return "" +} diff --git a/processor/k8sattributesprocessor/internal/kube/client_test.go b/processor/k8sattributesprocessor/internal/kube/client_test.go index a87653427f90f..8e6eb6d1b37e5 100644 --- a/processor/k8sattributesprocessor/internal/kube/client_test.go +++ b/processor/k8sattributesprocessor/internal/kube/client_test.go @@ -7,6 +7,7 @@ import ( "errors" "maps" "regexp" + "sync" "testing" "time" @@ -733,6 +734,15 @@ func TestExtractionRules(t *testing.T) { "k8s.deployment.uid": "ffff-gggg-hhhh-iiii-eeeeeeeeeeee", }, }, + { + name: "deployment-from-replicaset-name", + rules: ExtractionRules{ + DeploymentName: true, + }, + attributes: map[string]string{ + "k8s.deployment.name": "auth-service", + }, + }, { name: "replicasetId", rules: ExtractionRules{ @@ -1732,6 +1742,47 @@ func TestDeploymentExtractionRules(t *testing.T) { } } +func TestDeploymentNameFromReplicaSet(t *testing.T) { + c, _ := newTestClientWithRulesAndFilters(t, Filters{}) + c.Rules = ExtractionRules{ + DeploymentName: true, + DeploymentNameFromReplicaSet: true, + } + + pod := &api_v1.Pod{ + ObjectMeta: meta_v1.ObjectMeta{ + Name: "auth-service-abc12-xyz3", + UID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + Namespace: "ns1", + OwnerReferences: []meta_v1.OwnerReference{ + { + APIVersion: "apps/v1", + Kind: "ReplicaSet", + Name: "auth-service-66f5996c7c", + UID: "207ea729-c779-401d-8347-008ecbc137e3", + }, + }, + }, + Status: api_v1.PodStatus{ + PodIP: "1.1.1.1", + }, + } + + c.handlePodAdd(pod) + p, ok := c.GetPod(newPodIdentifier("connection", "", pod.Status.PodIP)) + require.True(t, ok) + + attributes := map[string]string{ + "k8s.deployment.name": "auth-service", + } + assert.Len(t, p.Attributes, len(attributes)) + for k, v := range attributes { + got, ok := p.Attributes[k] + assert.True(t, ok) + assert.Equal(t, v, got) + } +} + func TestStatefulSetExtractionRules(t *testing.T) { c, _ := newTestClientWithRulesAndFilters(t, Filters{}) @@ -3430,3 +3481,337 @@ func TestCronJobExtractionRules_FromJobOwner(t *testing.T) { }) } } + +func TestExtractDeploymentNameFromReplicaSet(t *testing.T) { + tests := []struct { + name string + replicaSetName string + expected string + }{ + { + name: "valid replicaset name with pod template hash", + replicaSetName: "my-deployment-7b9f4c8d5e", + expected: "my-deployment", + }, + { + name: "complex deployment name with dashes", + replicaSetName: "my-complex-deployment-name-7b9f4c8d5e", + expected: "my-complex-deployment-name", + }, + { + name: "single word deployment", + replicaSetName: "nginx-7b9f4c8d5e", + expected: "nginx", + }, + { + name: "replicaset name without valid pod template hash", + replicaSetName: "my-deployment-invalidhash", + expected: "", + }, + { + name: "replicaset name with short hash", + replicaSetName: "my-deployment-7b9f4c", + expected: "", + }, + { + name: "replicaset name with long hash", + replicaSetName: "my-deployment-7b9f4c8d5e123", + expected: "", + }, + { + name: "replicaset name with uppercase in hash", + replicaSetName: "my-deployment-7B9F4C8D5E", + expected: "", + }, + { + name: "replicaset name with special characters in hash", + replicaSetName: "my-deployment-7b9f4c8d5_", + expected: "", + }, + { + name: "empty replicaset name", + replicaSetName: "", + expected: "", + }, + { + name: "replicaset name without dashes", + replicaSetName: "deployment7b9f4c8d5e", + expected: "", + }, + { + name: "replicaset name with only hash part", + replicaSetName: "7b9f4c8d5e", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractDeploymentNameFromReplicaSet(tt.replicaSetName) + assert.Equal(t, tt.expected, result) + }) + } +} + +// trackableInformer is a mock informer that tracks if Run has been called. +type trackableInformer struct { + cache.SharedInformer + runCalled bool + mutex sync.Mutex +} + +func (i *trackableInformer) Run(stopCh <-chan struct{}) { + i.mutex.Lock() + i.runCalled = true + i.mutex.Unlock() + i.SharedInformer.Run(stopCh) +} + +func (i *trackableInformer) hasRun() bool { + i.mutex.Lock() + defer i.mutex.Unlock() + return i.runCalled +} + +func newTrackableInformer(client kubernetes.Interface, namespace string, labelSelector labels.Selector, fieldSelector fields.Selector) cache.SharedInformer { + return &trackableInformer{ + SharedInformer: NewFakeInformer(client, namespace, labelSelector, fieldSelector), + } +} + +func TestReplicaSetInformerConditionalStart(t *testing.T) { + tests := []struct { + name string + rules ExtractionRules + expectRun bool + }{ + { + name: "start informer if deployment UID is requested", + rules: ExtractionRules{DeploymentUID: true}, + expectRun: true, + }, + { + name: "start informer if deployment name is requested", + rules: ExtractionRules{DeploymentName: true}, + expectRun: true, + }, + { + name: "don't start informer if deployment name from replicaset is requested", + rules: ExtractionRules{DeploymentName: true, DeploymentNameFromReplicaSet: true}, + expectRun: false, + }, + { + name: "start informer if deployment UID and name are requested", + rules: ExtractionRules{DeploymentName: true, DeploymentUID: true}, + expectRun: true, + }, + { + name: "start informer if deployment UID and name from replicaset are requested", + rules: ExtractionRules{DeploymentName: true, DeploymentUID: true, DeploymentNameFromReplicaSet: true}, + expectRun: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + factory := InformersFactoryList{ + newInformer: NewFakeInformer, + newNamespaceInformer: NewFakeNamespaceInformer, + newReplicaSetInformer: func(kc kubernetes.Interface, ns string) cache.SharedInformer { + return newTrackableInformer(kc, ns, labels.Everything(), fields.Everything()) + }, + } + + c, err := New(componenttest.NewNopTelemetrySettings(), k8sconfig.APIConfig{}, tt.rules, Filters{}, []Association{}, Excludes{}, newFakeAPIClientset, factory, false, 10*time.Second) + require.NoError(t, err) + wc := c.(*WatchClient) + + err = wc.Start() + require.NoError(t, err) + defer wc.Stop() + + // Allow time for the informer goroutine to start + time.Sleep(100 * time.Millisecond) + + informer := wc.replicasetInformer.(*trackableInformer) + assert.Equal(t, tt.expectRun, informer.hasRun()) + }) + } +} + +func TestDeploymentNameFromReplicaSetFeature(t *testing.T) { + // Test the DeploymentNameFromReplicaSet flag functionality with extractPodAttributes + + tests := []struct { + name string + deploymentNameFromReplicaSetEnabled bool + replicaSetInCache bool + deploymentInRS bool + replicaSetName string + expectedDeploymentName string + }{ + { + name: "flag disabled - no deployment name extraction from replicaset name", + deploymentNameFromReplicaSetEnabled: false, + replicaSetInCache: false, + deploymentInRS: false, + replicaSetName: "my-deployment-7b9f4c8d5e", + expectedDeploymentName: "", + }, + { + name: "flag enabled - replicaset not in cache", + deploymentNameFromReplicaSetEnabled: true, + replicaSetInCache: false, + deploymentInRS: false, + replicaSetName: "my-deployment-7b9f4c8d5e", + expectedDeploymentName: "my-deployment", + }, + { + name: "flag enabled - replicaset in cache but no deployment", + deploymentNameFromReplicaSetEnabled: true, + replicaSetInCache: true, + deploymentInRS: false, + replicaSetName: "my-deployment-7b9f4c8d5e", + expectedDeploymentName: "my-deployment", + }, + { + name: "flag enabled - replicaset in cache with deployment (should prefer existing)", + deploymentNameFromReplicaSetEnabled: true, + replicaSetInCache: true, + deploymentInRS: true, + replicaSetName: "my-deployment-7b9f4c8d5e", + expectedDeploymentName: "my-deployment", + }, + { + name: "flag enabled - invalid replicaset name", + deploymentNameFromReplicaSetEnabled: true, + replicaSetInCache: false, + deploymentInRS: false, + replicaSetName: "invalid-name", + expectedDeploymentName: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c, _ := newTestClientWithRulesAndFilters(t, Filters{}) + c.Rules.DeploymentName = true + if tt.deploymentNameFromReplicaSetEnabled { + c.Rules.DeploymentNameFromReplicaSet = true + } + + // Create a replicaset if needed + if tt.replicaSetInCache { + replicaset := &apps_v1.ReplicaSet{ + ObjectMeta: meta_v1.ObjectMeta{ + Name: tt.replicaSetName, + Namespace: "default", + UID: "rs-uid-123", + }, + } + + if tt.deploymentInRS { + isController := true + replicaset.OwnerReferences = []meta_v1.OwnerReference{ + { + Kind: "Deployment", + Name: "real-deployment-name", + UID: "deploy-uid-123", + Controller: &isController, + }, + } + } + + c.handleReplicaSetAdd(replicaset) + } + + // Create a pod with replicaset owner reference + pod := &api_v1.Pod{ + ObjectMeta: meta_v1.ObjectMeta{ + Name: "test-pod", + Namespace: "default", + UID: "pod-uid-123", + OwnerReferences: []meta_v1.OwnerReference{ + { + Kind: "ReplicaSet", + Name: tt.replicaSetName, + UID: "rs-uid-123", + }, + }, + }, + Status: api_v1.PodStatus{ + PodIP: "1.2.3.4", + }, + } + + // Extract attributes + attributes := c.extractPodAttributes(pod) + + // Check the result + if tt.expectedDeploymentName != "" { + deploymentName, exists := attributes[string(conventions.K8SDeploymentNameKey)] + assert.True(t, exists, "Expected deployment name to be extracted") + assert.Equal(t, tt.expectedDeploymentName, deploymentName) + } else { + _, exists := attributes[string(conventions.K8SDeploymentNameKey)] + assert.False(t, exists, "Expected no deployment name to be extracted") + } + }) + } +} + +func TestDeploymentHashSuffixPattern(t *testing.T) { + tests := []struct { + name string + input string + expected bool + }{ + { + name: "valid pod template hash", + input: "7b9f4c8d5e", + expected: true, + }, + { + name: "valid all digits", + input: "1234567890", + expected: true, + }, + { + name: "valid all letters", + input: "abcdefghij", + expected: true, + }, + { + name: "too short", + input: "7b9f4c8d5", + expected: false, + }, + { + name: "too long", + input: "7b9f4c8d5e1", + expected: false, + }, + { + name: "contains uppercase", + input: "7B9F4C8D5E", + expected: false, + }, + { + name: "contains special character", + input: "7b9f4c8d5-", + expected: false, + }, + { + name: "empty string", + input: "", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := deploymentHashSuffixPattern.MatchString(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/processor/k8sattributesprocessor/internal/kube/kube.go b/processor/k8sattributesprocessor/internal/kube/kube.go index 3135d08d19fbc..0472ddd545f43 100644 --- a/processor/k8sattributesprocessor/internal/kube/kube.go +++ b/processor/k8sattributesprocessor/internal/kube/kube.go @@ -262,8 +262,9 @@ type ExtractionRules struct { ServiceVersion bool ServiceInstanceID bool - Annotations []FieldExtractionRule - Labels []FieldExtractionRule + Annotations []FieldExtractionRule + Labels []FieldExtractionRule + DeploymentNameFromReplicaSet bool } // IncludesOwnerMetadata determines whether the ExtractionRules include metadata about Pod Owners diff --git a/processor/k8sattributesprocessor/options.go b/processor/k8sattributesprocessor/options.go index 76e732b1dbf0c..153c47505d477 100644 --- a/processor/k8sattributesprocessor/options.go +++ b/processor/k8sattributesprocessor/options.go @@ -232,6 +232,13 @@ func withOtelAnnotations(enabled bool) option { } } +func withDeploymentNameFromReplicaSet(enabled bool) option { + return func(p *kubernetesprocessor) error { + p.rules.DeploymentNameFromReplicaSet = enabled + return nil + } +} + // withExtractLabels allows specifying options to control extraction of pod labels. func withExtractLabels(labels ...FieldExtractConfig) option { return func(p *kubernetesprocessor) error { diff --git a/processor/k8sattributesprocessor/testdata/config.yaml b/processor/k8sattributesprocessor/testdata/config.yaml index 6a6a355c8fe69..acab910dba835 100644 --- a/processor/k8sattributesprocessor/testdata/config.yaml +++ b/processor/k8sattributesprocessor/testdata/config.yaml @@ -153,6 +153,9 @@ k8sattributes/bad_filter_label_op: value: v1 op: "unknown" +k8sattributes/deployment_name_from_replicaset: + extract: + deployment_name_from_replicaset: true k8sattributes/bad_filter_field_op: filter: fields: From 68aaa24d7d7023a1ebb204937369d322a848756a Mon Sep 17 00:00:00 2001 From: Tyler Helmuth <12352919+TylerHelmuth@users.noreply.github.com> Date: Fri, 24 Oct 2025 13:23:15 -0600 Subject: [PATCH 2/2] Apply suggestion from @jinja2 Co-authored-by: Jina Jain --- processor/k8sattributesprocessor/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/processor/k8sattributesprocessor/README.md b/processor/k8sattributesprocessor/README.md index 9498060a27f5c..9b9d93f1853cf 100644 --- a/processor/k8sattributesprocessor/README.md +++ b/processor/k8sattributesprocessor/README.md @@ -283,6 +283,7 @@ The processor can be configured to set the The Extracted deployment name is: `opentelemetry-collector`. > Please note, if your pods are managed by a replicaset but not by a deployment the `k8s.deployment.name` will be set incorrectly. For example, if the replicaset is named `opentelemetry-collector-6c45f8d6f6`, the feature will still set the deployment name of the pod to `opentelemetry-collector` because it skips watching for the deployment and has no context if the pod is managed by a deployment or a replicaset. +Another edge case to be aware of is when the deployment name is long. Kubernetes may truncate it in the ReplicaSet name to ensure there is enough space for the pod template hash suffix, so the full name fits within the DNS subdomain limit (253 characters). In such cases, the extracted k8s.deployment.name will be the truncated form, not the original full deployment name. Example: