From 0d0b057b5353e5f48559af996c6f79fb39faf61b Mon Sep 17 00:00:00 2001 From: odubajDT Date: Wed, 4 Feb 2026 08:01:20 +0100 Subject: [PATCH 1/3] [processor/k8sattributes] sync container.image.tag resource attribute to latest semconv Signed-off-by: odubajDT --- .../k8sattributesprocessor/documentation.md | 1 + .../internal/kube/client.go | 16 +- .../internal/kube/client_test.go | 377 ++++++++++++++++++ .../internal/kube/kube.go | 6 +- .../internal/metadata/generated_config.go | 4 + .../metadata/generated_config_test.go | 2 + .../internal/metadata/generated_resource.go | 7 + .../metadata/generated_resource_test.go | 10 +- .../internal/metadata/testdata/config.yaml | 4 + .../k8sattributesprocessor/metadata.yaml | 4 + processor/k8sattributesprocessor/options.go | 12 +- processor/k8sattributesprocessor/processor.go | 10 +- 12 files changed, 443 insertions(+), 10 deletions(-) diff --git a/processor/k8sattributesprocessor/documentation.md b/processor/k8sattributesprocessor/documentation.md index f3d79de7fd1c6..87f4b25d32185 100644 --- a/processor/k8sattributesprocessor/documentation.md +++ b/processor/k8sattributesprocessor/documentation.md @@ -10,6 +10,7 @@ | container.image.name | Name of the image the container was built on. Requires container.id or k8s.container.name. | Any Str | true | | container.image.repo_digests | Repo digests of the container image as provided by the container runtime. | Any Slice | false | | container.image.tag | Container image tag. Defaults to "latest" if not provided (unless digest also in image path) Requires container.id or k8s.container.name. | Any Str | true | +| container.image.tags | Container image tags. | Any Slice | true | | k8s.cluster.uid | Gives cluster uid identified with kube-system namespace | Any Str | false | | k8s.container.name | The name of the Container in a Pod template. Requires container.id. | Any Str | false | | k8s.cronjob.name | The name of the CronJob. | Any Str | false | diff --git a/processor/k8sattributesprocessor/internal/kube/client.go b/processor/k8sattributesprocessor/internal/kube/client.go index 8206251427c77..5e921bcbb2ff3 100644 --- a/processor/k8sattributesprocessor/internal/kube/client.go +++ b/processor/k8sattributesprocessor/internal/kube/client.go @@ -991,7 +991,7 @@ func removeUnnecessaryPodData(pod *api_v1.Pod, rules ExtractionRules) *api_v1.Po removeUnnecessaryContainerData := func(c api_v1.Container) api_v1.Container { transformedContainer := api_v1.Container{} transformedContainer.Name = c.Name // we always need the name, it's used for identification - if rules.ContainerImageName || rules.ContainerImageTag || rules.ServiceVersion { + if rules.ContainerImageName || rules.ContainerImageTag || rules.ContainerImageTags || rules.ServiceVersion { transformedContainer.Image = c.Image } return transformedContainer @@ -1066,7 +1066,11 @@ func (c *WatchClient) extractPodContainersAttributes(pod *api_v1.Pod) PodContain if !needContainerAttributes(c.Rules) { return containers } - if c.Rules.ContainerImageName || c.Rules.ContainerImageTag || + + enableStable := metadata.ProcessorK8sattributesEmitV1K8sConventionsFeatureGate.IsEnabled() + disableLegacy := metadata.ProcessorK8sattributesDontEmitV0K8sConventionsFeatureGate.IsEnabled() + + if c.Rules.ContainerImageName || c.Rules.ContainerImageTag || c.Rules.ContainerImageTags || c.Rules.ServiceVersion || c.Rules.ServiceInstanceID { specs := append(pod.Spec.Containers, pod.Spec.InitContainers...) //nolint:gocritic // appendAssign: append result not assigned to the same slice for i := range specs { @@ -1077,9 +1081,14 @@ func (c *WatchClient) extractPodContainersAttributes(pod *api_v1.Pod) PodContain if c.Rules.ContainerImageName { container.ImageName = imageRef.Repository } - if c.Rules.ContainerImageTag { + // Legacy: container.image.tag (singular, string) + if c.Rules.ContainerImageTag && !disableLegacy { container.ImageTag = imageRef.Tag } + // Stable: container.image.tags (plural, array) + if c.Rules.ContainerImageTags && enableStable { + container.ImageTags = []string{imageRef.Tag} + } if c.Rules.ServiceVersion { serviceVersion, err := parseServiceVersionFromImage(spec.Image) if err == nil { @@ -1726,6 +1735,7 @@ func needContainerAttributes(rules ExtractionRules) bool { return rules.ContainerImageName || rules.ContainerName || rules.ContainerImageTag || + rules.ContainerImageTags || rules.ContainerImageRepoDigests || rules.ContainerID || rules.ServiceVersion || diff --git a/processor/k8sattributesprocessor/internal/kube/client_test.go b/processor/k8sattributesprocessor/internal/kube/client_test.go index 1f4e936c507a9..7c48136c689ca 100644 --- a/processor/k8sattributesprocessor/internal/kube/client_test.go +++ b/processor/k8sattributesprocessor/internal/kube/client_test.go @@ -2579,6 +2579,383 @@ func Test_extractPodContainersAttributes(t *testing.T) { } } +func Test_extractPodContainersAttributes_WithFeatureGates(t *testing.T) { + pod := api_v1.Pod{ + ObjectMeta: meta_v1.ObjectMeta{ + Name: "test-pod", + Namespace: "test-namespace", + }, + Spec: api_v1.PodSpec{ + Containers: []api_v1.Container{ + { + Name: "container1", + Image: "example.com:5000/test/image1:0.1.0", + }, + { + Name: "container2", + Image: "example.com:81/image2@sha256:430ac608abaa332de4ce45d68534447c7a206edc5e98aaff9923ecc12f8a80d9", + }, + { + Name: "container3", + Image: "example-website.com/image3:1.0@sha256:4b0b1b6f6cdd3e5b9e55f74a1e8d19ed93a3f5a04c6b6c3c57c4e6d19f6b7c4d", + }, + }, + InitContainers: []api_v1.Container{ + { + Name: "init_container", + Image: "test/init-image", + }, + }, + }, + Status: api_v1.PodStatus{ + ContainerStatuses: []api_v1.ContainerStatus{ + { + Name: "container1", + ContainerID: "containerd://container1-id-123", + ImageID: "docker.io/otel/collector@sha256:55d008bc28344c3178645d40e7d07df30f9d90abe4b53c3fc4e5e9c0295533da", + RestartCount: 0, + }, + { + Name: "container2", + ContainerID: "containerd://container2-id-456", + RestartCount: 2, + }, + { + Name: "container3", + ContainerID: "containerd://container3-id-abc", + ImageID: "docker.io/otel/collector:2.0.0@sha256:430ac608abaa332de4ce45d68534447c7a206edc5e98aaff9923ecc12f8a80d9", + RestartCount: 2, + }, + }, + InitContainerStatuses: []api_v1.ContainerStatus{ + { + Name: "init_container", + ContainerID: "containerd://init-container-id-789", + ImageID: "ghcr.io/initimage1@sha256:42e8ba40f9f70d604684c3a2a0ed321206b7e2e3509fdb2c8836d34f2edfb57b", + RestartCount: 0, + }, + }, + }, + } + tests := []struct { + name string + rules ExtractionRules + want PodContainers + }{ + { + name: "container-image-tags-stable-enabled", + rules: ExtractionRules{ + ContainerImageTags: true, + }, + want: PodContainers{ + ByID: map[string]*Container{ + "container1-id-123": { + ImageTags: []string{"0.1.0"}, + }, + "container2-id-456": { + ImageTags: []string{"latest"}, + }, + "container3-id-abc": { + ImageTags: []string{"1.0"}, + }, + "init-container-id-789": { + ImageTags: []string{"latest"}, + }, + }, + ByName: map[string]*Container{ + "container1": { + ImageTags: []string{"0.1.0"}, + }, + "container2": { + ImageTags: []string{"latest"}, + }, + "container3": { + ImageTags: []string{"1.0"}, + }, + "init_container": { + ImageTags: []string{"latest"}, + }, + }, + }, + }, + { + name: "container-image-tag-and-tags-both-stable-enabled", + rules: ExtractionRules{ + ContainerImageTag: true, + ContainerImageTags: true, + }, + want: PodContainers{ + ByID: map[string]*Container{ + "container1-id-123": { + ImageTag: "0.1.0", + ImageTags: []string{"0.1.0"}, + }, + "container2-id-456": { + ImageTag: "latest", + ImageTags: []string{"latest"}, + }, + "container3-id-abc": { + ImageTag: "1.0", + ImageTags: []string{"1.0"}, + }, + "init-container-id-789": { + ImageTag: "latest", + ImageTags: []string{"latest"}, + }, + }, + ByName: map[string]*Container{ + "container1": { + ImageTag: "0.1.0", + ImageTags: []string{"0.1.0"}, + }, + "container2": { + ImageTag: "latest", + ImageTags: []string{"latest"}, + }, + "container3": { + ImageTag: "1.0", + ImageTags: []string{"1.0"}, + }, + "init_container": { + ImageTag: "latest", + ImageTags: []string{"latest"}, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Enable stable attributes for these tests + require.NoError(t, featuregate.GlobalRegistry().Set(metadata.ProcessorK8sattributesEmitV1K8sConventionsFeatureGate.ID(), true)) + defer func() { + require.NoError(t, featuregate.GlobalRegistry().Set(metadata.ProcessorK8sattributesEmitV1K8sConventionsFeatureGate.ID(), false)) + }() + + c := WatchClient{Rules: tt.rules} + transformedPod := removeUnnecessaryPodData(&pod, c.Rules) + assert.Equal(t, tt.want, c.extractPodContainersAttributes(transformedPod)) + }) + } +} + +func Test_extractPodContainersAttributes_NoTag(t *testing.T) { + pod := api_v1.Pod{ + Spec: api_v1.PodSpec{ + Containers: []api_v1.Container{ + { + Name: "test-container", + Image: "test/image", + }, + }, + }, + } + tests := []struct { + name string + rules ExtractionRules + want PodContainers + }{ + { + name: "container-image-tags-no-tag", + rules: ExtractionRules{ + ContainerImageTags: true, + }, + want: PodContainers{ + ByID: map[string]*Container{}, + ByName: map[string]*Container{ + "test-container": { + ImageTags: []string{"latest"}, // default tag when none specified + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Enable stable attributes for these tests + require.NoError(t, featuregate.GlobalRegistry().Set(metadata.ProcessorK8sattributesEmitV1K8sConventionsFeatureGate.ID(), true)) + defer func() { + require.NoError(t, featuregate.GlobalRegistry().Set(metadata.ProcessorK8sattributesEmitV1K8sConventionsFeatureGate.ID(), false)) + }() + + c := WatchClient{Rules: tt.rules} + transformedPod := removeUnnecessaryPodData(&pod, c.Rules) + assert.Equal(t, tt.want, c.extractPodContainersAttributes(transformedPod)) + }) + } +} + +func Test_extractPodContainersAttributes_FeatureGates(t *testing.T) { + pod := api_v1.Pod{ + ObjectMeta: meta_v1.ObjectMeta{ + Name: "test-pod", + Namespace: "test-namespace", + }, + Spec: api_v1.PodSpec{ + Containers: []api_v1.Container{ + { + Name: "container1", + Image: "example.com:5000/test/image1:0.1.0", + }, + { + Name: "container2", + Image: "example.com:81/image2@sha256:430ac608abaa332de4ce45d68534447c7a206edc5e98aaff9923ecc12f8a80d9", + }, + }, + }, + Status: api_v1.PodStatus{ + ContainerStatuses: []api_v1.ContainerStatus{ + { + Name: "container1", + ContainerID: "containerd://container1-id-123", + RestartCount: 0, + }, + { + Name: "container2", + ContainerID: "containerd://container2-id-456", + RestartCount: 2, + }, + }, + }, + } + + tests := []struct { + name string + rules ExtractionRules + enableStable bool + disableLegacy bool + wantImageTag bool + wantImageTags bool + expectedContainer1 *Container + expectedContainer2 *Container + }{ + { + name: "legacy-only", + rules: ExtractionRules{ + ContainerImageTag: true, + ContainerImageTags: true, + }, + enableStable: false, + disableLegacy: false, + wantImageTag: true, + wantImageTags: false, + expectedContainer1: &Container{ + ImageTag: "0.1.0", + }, + expectedContainer2: &Container{ + ImageTag: "latest", + }, + }, + { + name: "stable-only", + rules: ExtractionRules{ + ContainerImageTag: true, + ContainerImageTags: true, + }, + enableStable: true, + disableLegacy: true, + wantImageTag: false, + wantImageTags: true, + expectedContainer1: &Container{ + ImageTags: []string{"0.1.0"}, + }, + expectedContainer2: &Container{ + ImageTags: []string{"latest"}, + }, + }, + { + name: "both-legacy-and-stable", + rules: ExtractionRules{ + ContainerImageTag: true, + ContainerImageTags: true, + }, + enableStable: true, + disableLegacy: false, + wantImageTag: true, + wantImageTags: true, + expectedContainer1: &Container{ + ImageTag: "0.1.0", + ImageTags: []string{"0.1.0"}, + }, + expectedContainer2: &Container{ + ImageTag: "latest", + ImageTags: []string{"latest"}, + }, + }, + { + name: "only-imagetag-rule-stable", + rules: ExtractionRules{ + ContainerImageTag: true, + }, + enableStable: true, + disableLegacy: true, + wantImageTag: false, + wantImageTags: false, + expectedContainer1: &Container{}, + expectedContainer2: &Container{}, + }, + { + name: "only-imagetags-rule-legacy", + rules: ExtractionRules{ + ContainerImageTags: true, + }, + enableStable: false, + disableLegacy: false, + wantImageTag: false, + wantImageTags: false, + expectedContainer1: &Container{}, + expectedContainer2: &Container{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.enableStable { + require.NoError(t, featuregate.GlobalRegistry().Set(metadata.ProcessorK8sattributesEmitV1K8sConventionsFeatureGate.ID(), true)) + defer func() { + require.NoError(t, featuregate.GlobalRegistry().Set(metadata.ProcessorK8sattributesEmitV1K8sConventionsFeatureGate.ID(), false)) + }() + } + if tt.disableLegacy { + require.NoError(t, featuregate.GlobalRegistry().Set(metadata.ProcessorK8sattributesDontEmitV0K8sConventionsFeatureGate.ID(), true)) + defer func() { + require.NoError(t, featuregate.GlobalRegistry().Set(metadata.ProcessorK8sattributesDontEmitV0K8sConventionsFeatureGate.ID(), false)) + }() + } + + c := WatchClient{Rules: tt.rules} + transformedPod := removeUnnecessaryPodData(&pod, c.Rules) + got := c.extractPodContainersAttributes(transformedPod) + + // Check container1 + assert.NotNil(t, got.ByName["container1"]) + if tt.wantImageTag { + assert.Equal(t, tt.expectedContainer1.ImageTag, got.ByName["container1"].ImageTag) + } else { + assert.Empty(t, got.ByName["container1"].ImageTag) + } + if tt.wantImageTags { + assert.Equal(t, tt.expectedContainer1.ImageTags, got.ByName["container1"].ImageTags) + } else { + assert.Nil(t, got.ByName["container1"].ImageTags) + } + + // Check container2 + assert.NotNil(t, got.ByName["container2"]) + if tt.wantImageTag { + assert.Equal(t, tt.expectedContainer2.ImageTag, got.ByName["container2"].ImageTag) + } else { + assert.Empty(t, got.ByName["container2"].ImageTag) + } + if tt.wantImageTags { + assert.Equal(t, tt.expectedContainer2.ImageTags, got.ByName["container2"].ImageTags) + } else { + assert.Nil(t, got.ByName["container2"].ImageTags) + } + }) + } +} + func Test_extractField(t *testing.T) { type args struct { v string diff --git a/processor/k8sattributesprocessor/internal/kube/kube.go b/processor/k8sattributesprocessor/internal/kube/kube.go index 0784cd5eed195..61c4170519c27 100644 --- a/processor/k8sattributesprocessor/internal/kube/kube.go +++ b/processor/k8sattributesprocessor/internal/kube/kube.go @@ -145,7 +145,8 @@ type PodContainers struct { type Container struct { Name string ImageName string - ImageTag string + ImageTag string // Legacy: single tag (stable uses ImageTags) + ImageTags []string // Stable: array of tags ServiceInstanceID string ServiceVersion string @@ -252,7 +253,8 @@ type ExtractionRules struct { ContainerID bool ContainerImageName bool ContainerImageRepoDigests bool - ContainerImageTag bool + ContainerImageTag bool // Legacy + ContainerImageTags bool // Stable ClusterUID bool ServiceNamespace bool ServiceName bool diff --git a/processor/k8sattributesprocessor/internal/metadata/generated_config.go b/processor/k8sattributesprocessor/internal/metadata/generated_config.go index 09878e6ccd77f..21436d06c708d 100644 --- a/processor/k8sattributesprocessor/internal/metadata/generated_config.go +++ b/processor/k8sattributesprocessor/internal/metadata/generated_config.go @@ -31,6 +31,7 @@ type ResourceAttributesConfig struct { ContainerImageName ResourceAttributeConfig `mapstructure:"container.image.name"` ContainerImageRepoDigests ResourceAttributeConfig `mapstructure:"container.image.repo_digests"` ContainerImageTag ResourceAttributeConfig `mapstructure:"container.image.tag"` + ContainerImageTags ResourceAttributeConfig `mapstructure:"container.image.tags"` K8sClusterUID ResourceAttributeConfig `mapstructure:"k8s.cluster.uid"` K8sContainerName ResourceAttributeConfig `mapstructure:"k8s.container.name"` K8sCronjobName ResourceAttributeConfig `mapstructure:"k8s.cronjob.name"` @@ -73,6 +74,9 @@ func DefaultResourceAttributesConfig() ResourceAttributesConfig { ContainerImageTag: ResourceAttributeConfig{ Enabled: true, }, + ContainerImageTags: ResourceAttributeConfig{ + Enabled: true, + }, K8sClusterUID: ResourceAttributeConfig{ Enabled: false, }, diff --git a/processor/k8sattributesprocessor/internal/metadata/generated_config_test.go b/processor/k8sattributesprocessor/internal/metadata/generated_config_test.go index f753082c9414d..2dd1d2edaecf4 100644 --- a/processor/k8sattributesprocessor/internal/metadata/generated_config_test.go +++ b/processor/k8sattributesprocessor/internal/metadata/generated_config_test.go @@ -28,6 +28,7 @@ func TestResourceAttributesConfig(t *testing.T) { ContainerImageName: ResourceAttributeConfig{Enabled: true}, ContainerImageRepoDigests: ResourceAttributeConfig{Enabled: true}, ContainerImageTag: ResourceAttributeConfig{Enabled: true}, + ContainerImageTags: ResourceAttributeConfig{Enabled: true}, K8sClusterUID: ResourceAttributeConfig{Enabled: true}, K8sContainerName: ResourceAttributeConfig{Enabled: true}, K8sCronjobName: ResourceAttributeConfig{Enabled: true}, @@ -63,6 +64,7 @@ func TestResourceAttributesConfig(t *testing.T) { ContainerImageName: ResourceAttributeConfig{Enabled: false}, ContainerImageRepoDigests: ResourceAttributeConfig{Enabled: false}, ContainerImageTag: ResourceAttributeConfig{Enabled: false}, + ContainerImageTags: ResourceAttributeConfig{Enabled: false}, K8sClusterUID: ResourceAttributeConfig{Enabled: false}, K8sContainerName: ResourceAttributeConfig{Enabled: false}, K8sCronjobName: ResourceAttributeConfig{Enabled: false}, diff --git a/processor/k8sattributesprocessor/internal/metadata/generated_resource.go b/processor/k8sattributesprocessor/internal/metadata/generated_resource.go index afc7bccd3ad06..97e978d8cf4c1 100644 --- a/processor/k8sattributesprocessor/internal/metadata/generated_resource.go +++ b/processor/k8sattributesprocessor/internal/metadata/generated_resource.go @@ -49,6 +49,13 @@ func (rb *ResourceBuilder) SetContainerImageTag(val string) { } } +// SetContainerImageTags sets provided value as "container.image.tags" attribute. +func (rb *ResourceBuilder) SetContainerImageTags(val []any) { + if rb.config.ContainerImageTags.Enabled { + rb.res.Attributes().PutEmptySlice("container.image.tags").FromRaw(val) + } +} + // SetK8sClusterUID sets provided value as "k8s.cluster.uid" attribute. func (rb *ResourceBuilder) SetK8sClusterUID(val string) { if rb.config.K8sClusterUID.Enabled { diff --git a/processor/k8sattributesprocessor/internal/metadata/generated_resource_test.go b/processor/k8sattributesprocessor/internal/metadata/generated_resource_test.go index 24aaa49ae0ca3..348c0a3780305 100644 --- a/processor/k8sattributesprocessor/internal/metadata/generated_resource_test.go +++ b/processor/k8sattributesprocessor/internal/metadata/generated_resource_test.go @@ -17,6 +17,7 @@ func TestResourceBuilder(t *testing.T) { rb.SetContainerImageName("container.image.name-val") rb.SetContainerImageRepoDigests([]any{"container.image.repo_digests-item1", "container.image.repo_digests-item2"}) rb.SetContainerImageTag("container.image.tag-val") + rb.SetContainerImageTags([]any{"container.image.tags-item1", "container.image.tags-item2"}) rb.SetK8sClusterUID("k8s.cluster.uid-val") rb.SetK8sContainerName("k8s.container.name-val") rb.SetK8sCronjobName("k8s.cronjob.name-val") @@ -49,9 +50,9 @@ func TestResourceBuilder(t *testing.T) { switch tt { case "default": - assert.Equal(t, 8, res.Attributes().Len()) + assert.Equal(t, 9, res.Attributes().Len()) case "all_set": - assert.Equal(t, 30, res.Attributes().Len()) + assert.Equal(t, 31, res.Attributes().Len()) case "none_set": assert.Equal(t, 0, res.Attributes().Len()) return @@ -79,6 +80,11 @@ func TestResourceBuilder(t *testing.T) { if ok { assert.Equal(t, "container.image.tag-val", val.Str()) } + val, ok = res.Attributes().Get("container.image.tags") + assert.True(t, ok) + if ok { + assert.Equal(t, []any{"container.image.tags-item1", "container.image.tags-item2"}, val.Slice().AsRaw()) + } val, ok = res.Attributes().Get("k8s.cluster.uid") assert.Equal(t, tt == "all_set", ok) if ok { diff --git a/processor/k8sattributesprocessor/internal/metadata/testdata/config.yaml b/processor/k8sattributesprocessor/internal/metadata/testdata/config.yaml index 615cdbf8c66dd..7f4328fdf81eb 100644 --- a/processor/k8sattributesprocessor/internal/metadata/testdata/config.yaml +++ b/processor/k8sattributesprocessor/internal/metadata/testdata/config.yaml @@ -9,6 +9,8 @@ all_set: enabled: true container.image.tag: enabled: true + container.image.tags: + enabled: true k8s.cluster.uid: enabled: true k8s.container.name: @@ -71,6 +73,8 @@ none_set: enabled: false container.image.tag: enabled: false + container.image.tags: + enabled: false k8s.cluster.uid: enabled: false k8s.container.name: diff --git a/processor/k8sattributesprocessor/metadata.yaml b/processor/k8sattributesprocessor/metadata.yaml index 9f1d9d1a948ae..fa9f3a60bc412 100644 --- a/processor/k8sattributesprocessor/metadata.yaml +++ b/processor/k8sattributesprocessor/metadata.yaml @@ -28,6 +28,10 @@ resource_attributes: description: Container image tag. Defaults to "latest" if not provided (unless digest also in image path) Requires container.id or k8s.container.name. type: string enabled: true + container.image.tags: + description: Container image tags. + type: slice + enabled: true k8s.cluster.uid: description: Gives cluster uid identified with kube-system namespace type: string diff --git a/processor/k8sattributesprocessor/options.go b/processor/k8sattributesprocessor/options.go index f26ac0a13657e..6c80fdb4adc69 100644 --- a/processor/k8sattributesprocessor/options.go +++ b/processor/k8sattributesprocessor/options.go @@ -26,7 +26,8 @@ const ( specPodHostName = "k8s.pod.hostname" // TODO: Should be migrated to https://github.com/open-telemetry/semantic-conventions/blob/v1.38.0/model/container/registry.yaml#L48-L57 - containerImageTag = "container.image.tag" + containerImageTag = "container.image.tag" + containerImageTags = "container.image.tags" ) // option represents a configuration option that can be passes. @@ -66,9 +67,14 @@ func enabledAttributes() (attributes []string) { if defaultConfig.ContainerImageRepoDigests.Enabled { attributes = append(attributes, string(conventions.ContainerImageRepoDigestsKey)) } - if defaultConfig.ContainerImageTag.Enabled { + enableStable := metadata.ProcessorK8sattributesEmitV1K8sConventionsFeatureGate.IsEnabled() + disableLegacy := metadata.ProcessorK8sattributesDontEmitV0K8sConventionsFeatureGate.IsEnabled() + if !disableLegacy && defaultConfig.ContainerImageTag.Enabled { attributes = append(attributes, containerImageTag) } + if enableStable && defaultConfig.ContainerImageTags.Enabled { + attributes = append(attributes, containerImageTags) + } if defaultConfig.K8sContainerName.Enabled { attributes = append(attributes, string(conventions.K8SContainerNameKey)) } @@ -203,6 +209,8 @@ func withExtractMetadata(fields ...string) option { p.rules.ContainerImageRepoDigests = true case containerImageTag: p.rules.ContainerImageTag = true + case containerImageTags: + p.rules.ContainerImageTags = true case string(conventions.K8SClusterUIDKey): p.rules.ClusterUID = true case string(conventions.ServiceNamespaceKey): diff --git a/processor/k8sattributesprocessor/processor.go b/processor/k8sattributesprocessor/processor.go index 745aaaae67eaf..46809b832ac45 100644 --- a/processor/k8sattributesprocessor/processor.go +++ b/processor/k8sattributesprocessor/processor.go @@ -359,9 +359,17 @@ func (kp *kubernetesprocessor) addContainerAttributes(attrs pcommon.Map, pod *ku if containerSpec.ImageName != "" { setResourceAttribute(attrs, string(conventions.ContainerImageNameKey), containerSpec.ImageName) } - if containerSpec.ImageTag != "" { + enableStable := metadata.ProcessorK8sattributesEmitV1K8sConventionsFeatureGate.IsEnabled() + disableLegacy := metadata.ProcessorK8sattributesDontEmitV0K8sConventionsFeatureGate.IsEnabled() + if !disableLegacy && containerSpec.ImageTag != "" { setResourceAttribute(attrs, containerImageTag, containerSpec.ImageTag) } + if enableStable && len(containerSpec.ImageTags) > 0 { + sliceVal := attrs.PutEmptySlice(containerImageTags) + for _, tag := range containerSpec.ImageTags { + sliceVal.AppendEmpty().SetStr(tag) + } + } if containerSpec.ServiceInstanceID != "" { setResourceAttribute(attrs, string(conventions.ServiceInstanceIDKey), containerSpec.ServiceInstanceID) } From 93f7b8e2c333015bb2efbdb16f4f27ed329a54c6 Mon Sep 17 00:00:00 2001 From: odubajDT Date: Wed, 4 Feb 2026 08:09:14 +0100 Subject: [PATCH 2/3] use semconv + chlog Signed-off-by: odubajDT --- ...tributes-semconv-gates-container-tags.yaml | 27 +++++++++++++++++++ processor/k8sattributesprocessor/options.go | 9 +++---- processor/k8sattributesprocessor/processor.go | 2 +- 3 files changed, 31 insertions(+), 7 deletions(-) create mode 100644 .chloggen/k8sattributes-semconv-gates-container-tags.yaml diff --git a/.chloggen/k8sattributes-semconv-gates-container-tags.yaml b/.chloggen/k8sattributes-semconv-gates-container-tags.yaml new file mode 100644 index 0000000000000..1e975807d9c14 --- /dev/null +++ b/.chloggen/k8sattributes-semconv-gates-container-tags.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. receiver/filelog) +component: "processor/k8sattributes" + +# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`). +note: "Added `container.image.tags` resource attribute with feature gate controls according to OpenTelemetry semantic conventions." + +# Mandatory: One or more tracking issues related to the change. You can use the PR number here if no issue exists. +issues: [45307] + +# (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/options.go b/processor/k8sattributesprocessor/options.go index 6c80fdb4adc69..70d76b9cbb681 100644 --- a/processor/k8sattributesprocessor/options.go +++ b/processor/k8sattributesprocessor/options.go @@ -24,10 +24,7 @@ const ( metadataPodIP = "k8s.pod.ip" metadataPodStartTime = "k8s.pod.start_time" specPodHostName = "k8s.pod.hostname" - - // TODO: Should be migrated to https://github.com/open-telemetry/semantic-conventions/blob/v1.38.0/model/container/registry.yaml#L48-L57 - containerImageTag = "container.image.tag" - containerImageTags = "container.image.tags" + containerImageTag = "container.image.tag" ) // option represents a configuration option that can be passes. @@ -73,7 +70,7 @@ func enabledAttributes() (attributes []string) { attributes = append(attributes, containerImageTag) } if enableStable && defaultConfig.ContainerImageTags.Enabled { - attributes = append(attributes, containerImageTags) + attributes = append(attributes, string(conventions.ContainerImageTagsKey)) } if defaultConfig.K8sContainerName.Enabled { attributes = append(attributes, string(conventions.K8SContainerNameKey)) @@ -209,7 +206,7 @@ func withExtractMetadata(fields ...string) option { p.rules.ContainerImageRepoDigests = true case containerImageTag: p.rules.ContainerImageTag = true - case containerImageTags: + case string(conventions.ContainerImageTagsKey): p.rules.ContainerImageTags = true case string(conventions.K8SClusterUIDKey): p.rules.ClusterUID = true diff --git a/processor/k8sattributesprocessor/processor.go b/processor/k8sattributesprocessor/processor.go index 46809b832ac45..4b87e8b5486a9 100644 --- a/processor/k8sattributesprocessor/processor.go +++ b/processor/k8sattributesprocessor/processor.go @@ -365,7 +365,7 @@ func (kp *kubernetesprocessor) addContainerAttributes(attrs pcommon.Map, pod *ku setResourceAttribute(attrs, containerImageTag, containerSpec.ImageTag) } if enableStable && len(containerSpec.ImageTags) > 0 { - sliceVal := attrs.PutEmptySlice(containerImageTags) + sliceVal := attrs.PutEmptySlice(string(conventions.ContainerImageTagsKey)) for _, tag := range containerSpec.ImageTags { sliceVal.AppendEmpty().SetStr(tag) } From cde2a1e112eb8dbf4530010f41c79555ca9f798a Mon Sep 17 00:00:00 2001 From: odubajDT Date: Wed, 4 Feb 2026 10:30:40 +0100 Subject: [PATCH 3/3] pr review Signed-off-by: odubajDT --- .chloggen/k8sattributes-semconv-gates-container-tags.yaml | 2 +- processor/k8sattributesprocessor/documentation.md | 4 ++-- processor/k8sattributesprocessor/metadata.yaml | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.chloggen/k8sattributes-semconv-gates-container-tags.yaml b/.chloggen/k8sattributes-semconv-gates-container-tags.yaml index 1e975807d9c14..34b49ce13efcb 100644 --- a/.chloggen/k8sattributes-semconv-gates-container-tags.yaml +++ b/.chloggen/k8sattributes-semconv-gates-container-tags.yaml @@ -10,7 +10,7 @@ component: "processor/k8sattributes" note: "Added `container.image.tags` resource attribute with feature gate controls according to OpenTelemetry semantic conventions." # Mandatory: One or more tracking issues related to the change. You can use the PR number here if no issue exists. -issues: [45307] +issues: [44589] # (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. diff --git a/processor/k8sattributesprocessor/documentation.md b/processor/k8sattributesprocessor/documentation.md index 87f4b25d32185..f48fbddd8109a 100644 --- a/processor/k8sattributesprocessor/documentation.md +++ b/processor/k8sattributesprocessor/documentation.md @@ -9,8 +9,8 @@ | container.id | Container ID. Usually a UUID, as for example used to identify Docker containers. The UUID might be abbreviated. Requires k8s.container.restart_count. | Any Str | false | | container.image.name | Name of the image the container was built on. Requires container.id or k8s.container.name. | Any Str | true | | container.image.repo_digests | Repo digests of the container image as provided by the container runtime. | Any Slice | false | -| container.image.tag | Container image tag. Defaults to "latest" if not provided (unless digest also in image path) Requires container.id or k8s.container.name. | Any Str | true | -| container.image.tags | Container image tags. | Any Slice | true | +| container.image.tag | Container image tag. Defaults to "latest" if not provided (unless digest also in image path) Requires container.id or k8s.container.name. Deprecated, use container.image.tags instead. | Any Str | true | +| container.image.tags | Container image tags. Requires container.id or k8s.container.name. | Any Slice | true | | k8s.cluster.uid | Gives cluster uid identified with kube-system namespace | Any Str | false | | k8s.container.name | The name of the Container in a Pod template. Requires container.id. | Any Str | false | | k8s.cronjob.name | The name of the CronJob. | Any Str | false | diff --git a/processor/k8sattributesprocessor/metadata.yaml b/processor/k8sattributesprocessor/metadata.yaml index fa9f3a60bc412..363bb383ab735 100644 --- a/processor/k8sattributesprocessor/metadata.yaml +++ b/processor/k8sattributesprocessor/metadata.yaml @@ -25,11 +25,11 @@ resource_attributes: type: slice enabled: false container.image.tag: - description: Container image tag. Defaults to "latest" if not provided (unless digest also in image path) Requires container.id or k8s.container.name. + description: Container image tag. Defaults to "latest" if not provided (unless digest also in image path) Requires container.id or k8s.container.name. Deprecated, use container.image.tags instead. type: string enabled: true container.image.tags: - description: Container image tags. + description: Container image tags. Requires container.id or k8s.container.name. type: slice enabled: true k8s.cluster.uid: