diff --git a/.chloggen/add-namespace-entity-metadata.yaml b/.chloggen/add-namespace-entity-metadata.yaml new file mode 100644 index 000000000000..7d272d5bdff6 --- /dev/null +++ b/.chloggen/add-namespace-entity-metadata.yaml @@ -0,0 +1,32 @@ +# 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: k8sclusterreceiver + +# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`). +note: Adds new descriptive attributes/metadata to the k8s.namespace and the container entity emitted from k8sclusterreceiver. + +# Mandatory: One or more tracking issues related to the change. You can use the PR number here if no issue exists. +issues: [37580] + +# (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: | + - Adds the following attributes to k8s.namespace entity: + - k8s.namespace.phase: The phase of a namespace indicates where the namespace is in its lifecycle. E.g. 'active', 'terminating' + - k8s.namespace.creation_timestamp: The time when the namespace object was created. + - Adds the following attributes to container entity: + - container.creation_timestamp: The time when the container was started. Only available if container is either in 'running' or 'terminated' state. + +# 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/receiver/k8sclusterreceiver/e2e_test.go b/receiver/k8sclusterreceiver/e2e_test.go index 3dbb239b948e..2de5131cafeb 100644 --- a/receiver/k8sclusterreceiver/e2e_test.go +++ b/receiver/k8sclusterreceiver/e2e_test.go @@ -7,6 +7,7 @@ package k8sclusterreceiver import ( "context" + "fmt" "path/filepath" "strings" "testing" @@ -15,13 +16,17 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.opentelemetry.io/collector/component" "go.opentelemetry.io/collector/component/componenttest" "go.opentelemetry.io/collector/consumer/consumertest" + "go.opentelemetry.io/collector/pdata/pcommon" + "go.opentelemetry.io/collector/pdata/plog" "go.opentelemetry.io/collector/pdata/pmetric" "go.opentelemetry.io/collector/receiver/otlpreceiver" "go.opentelemetry.io/collector/receiver/receivertest" "github.com/open-telemetry/opentelemetry-collector-contrib/pkg/golden" + "github.com/open-telemetry/opentelemetry-collector-contrib/pkg/pdatatest/plogtest" "github.com/open-telemetry/opentelemetry-collector-contrib/pkg/pdatatest/pmetrictest" k8stest "github.com/open-telemetry/opentelemetry-collector-contrib/pkg/xk8stest" ) @@ -249,25 +254,206 @@ func containerImageShorten(value string) string { return shortenNames(value[(strings.LastIndex(value, "/") + 1):]) } -func startUpSink(t *testing.T, mc *consumertest.MetricsSink) func() { +func startUpSink(t *testing.T, consumer any) func() { f := otlpreceiver.NewFactory() cfg := f.CreateDefaultConfig().(*otlpreceiver.Config) cfg.HTTP = nil cfg.GRPC.NetAddr.Endpoint = "0.0.0.0:4317" - rcvr, err := f.CreateMetrics(context.Background(), receivertest.NewNopSettings(), cfg, mc) + var err error + var rcvr component.Component + + switch c := consumer.(type) { + case *consumertest.MetricsSink: + rcvr, err = f.CreateMetrics(context.Background(), receivertest.NewNopSettings(), cfg, c) + case *consumertest.LogsSink: + rcvr, err = f.CreateLogs(context.Background(), receivertest.NewNopSettings(), cfg, c) + default: + t.Fatalf("unsupported consumer type: %T", c) + } + + require.NoError(t, err, "failed creating receiver") require.NoError(t, rcvr.Start(context.Background(), componenttest.NewNopHost())) - require.NoError(t, err, "failed creating metrics receiver") + return func() { - assert.NoError(t, rcvr.Shutdown(context.Background())) + require.NoError(t, rcvr.Shutdown(context.Background())) } } -func waitForData(t *testing.T, entriesNum int, mc *consumertest.MetricsSink) { +func waitForData(t *testing.T, entriesNum int, consumer any) { timeoutMinutes := 3 require.Eventuallyf(t, func() bool { - return len(mc.AllMetrics()) > entriesNum + switch c := consumer.(type) { + case *consumertest.MetricsSink: + return len(c.AllMetrics()) > entriesNum + case *consumertest.LogsSink: + return len(c.AllLogs()) > entriesNum + default: + t.Fatalf("unsupported consumer type: %T", c) + return false + } }, time.Duration(timeoutMinutes)*time.Minute, 1*time.Second, - "failed to receive %d entries, received %d metrics in %d minutes", entriesNum, - len(mc.AllMetrics()), timeoutMinutes) + "failed to receive %d entries in %d minutes", entriesNum, timeoutMinutes) +} + +// TestE2ENamespaceMetadata tests the k8s cluster receiver's exporting of entities in a real k8s cluster +func TestE2ENamespaceMetadata(t *testing.T) { + k8sClient, err := k8stest.NewK8sClient(testKubeConfig) + require.NoError(t, err) + + logsConsumer := new(consumertest.LogsSink) + shutdownSink := startUpSink(t, logsConsumer) + defer shutdownSink() + + testID := uuid.NewString()[:8] + collectorObjs := k8stest.CreateCollectorObjects(t, k8sClient, testID, filepath.Join(".", "testdata", "e2e", "entities-test", "collector"), map[string]string{}, "") + + t.Cleanup(func() { + for _, obj := range collectorObjs { + require.NoErrorf(t, k8stest.DeleteObject(k8sClient, obj), "failed to delete object %s", obj.GetName()) + } + }) + + namespaceObj, err := k8stest.CreateObjects(k8sClient, filepath.Join(".", "testdata", "e2e", "entities-test", "testobjects")) + require.NoErrorf(t, err, "failed to create test k8s objects") + + entityType := "k8s.namespace" + entityNameKey := "k8s.namespace.name" + entityName := "test-entities-ns" + namespaceLogs := waitForEntityLogs(t, entityType, entityNameKey, entityName, logsConsumer) + + expected, err := golden.ReadLogs("./testdata/e2e/entities-test/expected-ns.yaml") + require.NoError(t, err) + + commonReplacements := map[string]map[string]string{ + "otel.entity.attributes": { + "k8s.namespace.creation_timestamp": "2025-01-01T00:00:00Z", + }, + "otel.entity.id": { + "k8s.namespace.uid": "entity-id", + }, + } + + replaceLogValues(t, namespaceLogs[0], commonReplacements) + + require.NoError(t, plogtest.CompareLogs(expected, namespaceLogs[0], + plogtest.IgnoreTimestamp(), + plogtest.IgnoreObservedTimestamp(), + plogtest.IgnoreScopeLogsOrder(), + plogtest.IgnoreLogRecordsOrder(), + )) + + logsConsumer.Reset() + + // Delete test namespace object to trigger terminating phase and check if new event log is generated with the correct phase + require.NoErrorf(t, k8stest.DeleteObjects(k8sClient, namespaceObj), "failed to delete test k8s objects") + + namespaceLogs = waitForEntityLogs(t, entityType, entityNameKey, entityName, logsConsumer) + + replaceLogValues(t, namespaceLogs[0], commonReplacements) + + // update the phase in expected log to terminating + replaceLogValues(t, expected, map[string]map[string]string{ + "otel.entity.attributes": { + "k8s.namespace.phase": "terminating", + }, + }) + + require.NoError(t, plogtest.CompareLogs(expected, namespaceLogs[0], + plogtest.IgnoreTimestamp(), + plogtest.IgnoreObservedTimestamp(), + plogtest.IgnoreScopeLogsOrder(), + plogtest.IgnoreLogRecordsOrder(), + )) +} + +// filterEntityLogs returns logs that contain the entity with the given entityType and entityNameKey and entityName. +func filterEntityLogs(logs []plog.Logs, entityType, entityNameKey, entityName string) []plog.Logs { + var entityLogs []plog.Logs + for _, log := range logs { + for i := 0; i < log.ResourceLogs().Len(); i++ { + resourceLog := log.ResourceLogs().At(i) + for j := 0; j < resourceLog.ScopeLogs().Len(); j++ { + scopeLog := resourceLog.ScopeLogs().At(j) + if containsEntity(scopeLog, entityType, entityNameKey, entityName) { + entityLogs = append(entityLogs, log) + break + } + } + } + } + return entityLogs +} + +// containsEntity returns true if the given scopeLog contains an entity with the given entityType, entityNameKey and entityName. +func containsEntity(scopeLog plog.ScopeLogs, entityType, entityNameKey, entityName string) bool { + for k := 0; k < scopeLog.LogRecords().Len(); k++ { + logRecord := scopeLog.LogRecords().At(k) + entityTypeAttr, exists := logRecord.Attributes().Get("otel.entity.type") + if exists && entityTypeAttr.Type() == pcommon.ValueTypeStr && entityTypeAttr.Str() == entityType { + entityAttributesAttr, exists := logRecord.Attributes().Get("otel.entity.attributes") + if exists && entityAttributesAttr.Type() == pcommon.ValueTypeMap { + entityAttributes := entityAttributesAttr.Map() + entityNameValue, exists := entityAttributes.Get(entityNameKey) + if exists && entityNameValue.Type() == pcommon.ValueTypeStr && entityNameValue.Str() == entityName { + return true + } + } + } + } + return false +} + +func waitForEntityLogs(t *testing.T, entityType, entityNameKey, entityName string, consumer *consumertest.LogsSink) []plog.Logs { + timeoutMinutes := 3 + var entityLogs []plog.Logs + require.Eventuallyf(t, func() bool { + for _, logs := range consumer.AllLogs() { + filteredLogs := filterEntityLogs([]plog.Logs{logs}, entityType, entityNameKey, entityName) + if len(filteredLogs) > 0 { + entityLogs = filteredLogs + return true + } + } + return false + }, time.Duration(timeoutMinutes)*time.Minute, 5*time.Second, + "failed to receive logs for entity %s with name %s in %d minutes", entityType, entityName, timeoutMinutes) + return entityLogs +} + +// replaceLogValue replaces the value of the key in the attribute with attributeName with the placeholder. +func replaceLogValue(logs plog.Logs, attributeName, key, placeholder string) error { + rls := logs.ResourceLogs() + for i := 0; i < rls.Len(); i++ { + sls := rls.At(i).ScopeLogs() + for j := 0; j < sls.Len(); j++ { + lrs := sls.At(j).LogRecords() + for k := 0; k < lrs.Len(); k++ { + lr := lrs.At(k) + attr, exists := lr.Attributes().Get(attributeName) + if !exists { + return fmt.Errorf("expected attribute %s not found in log record", attributeName) + } + if attr.Type() != pcommon.ValueTypeMap { + return fmt.Errorf("attribute %s is not of type map in log record", attributeName) + } + attrMap := attr.Map() + val, exists := attrMap.Get(key) + if !exists { + return fmt.Errorf("expected key %s not found in attribute %s map", key, attributeName) + } + val.SetStr(placeholder) + } + } + } + return nil +} + +func replaceLogValues(t *testing.T, logs plog.Logs, replacements map[string]map[string]string) { + for attributeName, keys := range replacements { + for key, placeholder := range keys { + err := replaceLogValue(logs, attributeName, key, placeholder) + require.NoError(t, err) + } + } } diff --git a/receiver/k8sclusterreceiver/informer_transform_test.go b/receiver/k8sclusterreceiver/informer_transform_test.go index 3d02b9ec5ca2..f257ffd5038e 100644 --- a/receiver/k8sclusterreceiver/informer_transform_test.go +++ b/receiver/k8sclusterreceiver/informer_transform_test.go @@ -10,7 +10,6 @@ import ( appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "github.com/open-telemetry/opentelemetry-collector-contrib/receiver/k8sclusterreceiver/internal/testutils" ) @@ -38,7 +37,6 @@ func TestTransformObject(t *testing.T) { testutils.NewPodStatusWithContainer("container-name", "container-id"), ) pod.Spec.Containers[0].Image = "" - pod.Status.ContainerStatuses[0].State = corev1.ContainerState{Running: &corev1.ContainerStateRunning{StartedAt: v1.Time{}}} return pod }(), same: false, diff --git a/receiver/k8sclusterreceiver/internal/container/containers.go b/receiver/k8sclusterreceiver/internal/container/containers.go index 368f4c73c410..78253981a60b 100644 --- a/receiver/k8sclusterreceiver/internal/container/containers.go +++ b/receiver/k8sclusterreceiver/internal/container/containers.go @@ -4,6 +4,8 @@ package container // import "github.com/open-telemetry/opentelemetry-collector-contrib/receiver/k8sclusterreceiver/internal/container" import ( + "time" + "go.opentelemetry.io/collector/pdata/pcommon" conventions "go.opentelemetry.io/collector/semconv/v1.6.1" "go.uber.org/zap" @@ -17,9 +19,10 @@ import ( ) const ( - // Keys for container metadata. - containerKeyStatus = "container.status" - containerKeyStatusReason = "container.status.reason" + // Keys for container metadata used for entity attributes. + containerKeyStatus = "container.status" + containerKeyStatusReason = "container.status.reason" + containerCreationTimestamp = "container.creation_timestamp" // Values for container metadata containerStatusRunning = "running" @@ -98,11 +101,17 @@ func GetMetadata(cs corev1.ContainerStatus) *metadata.KubernetesMetadata { if cs.State.Running != nil { mdata[containerKeyStatus] = containerStatusRunning + if !cs.State.Running.StartedAt.IsZero() { + mdata[containerCreationTimestamp] = cs.State.Running.StartedAt.Format(time.RFC3339) + } } if cs.State.Terminated != nil { mdata[containerKeyStatus] = containerStatusTerminated mdata[containerKeyStatusReason] = cs.State.Terminated.Reason + if !cs.State.Terminated.StartedAt.IsZero() { + mdata[containerCreationTimestamp] = cs.State.Terminated.StartedAt.Format(time.RFC3339) + } } if cs.State.Waiting != nil { diff --git a/receiver/k8sclusterreceiver/internal/container/containers_test.go b/receiver/k8sclusterreceiver/internal/container/containers_test.go new file mode 100644 index 000000000000..030aca08b2fd --- /dev/null +++ b/receiver/k8sclusterreceiver/internal/container/containers_test.go @@ -0,0 +1,80 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestGetMetadata(t *testing.T) { + refTime := v1.Now() + tests := []struct { + name string + containerState corev1.ContainerState + expectedStatus string + expectedReason string + expectedStartedAt string + }{ + { + name: "Running container", + containerState: corev1.ContainerState{ + Running: &corev1.ContainerStateRunning{ + StartedAt: refTime, + }, + }, + expectedStatus: containerStatusRunning, + expectedStartedAt: refTime.Format(time.RFC3339), + }, + { + name: "Terminated container", + containerState: corev1.ContainerState{ + Terminated: &corev1.ContainerStateTerminated{ + ContainerID: "container-id", + Reason: "Completed", + StartedAt: refTime, + FinishedAt: refTime, + ExitCode: 0, + }, + }, + expectedStatus: containerStatusTerminated, + expectedReason: "Completed", + expectedStartedAt: refTime.Format(time.RFC3339), + }, + { + name: "Waiting container", + containerState: corev1.ContainerState{ + Waiting: &corev1.ContainerStateWaiting{ + Reason: "CrashLoopBackOff", + }, + }, + expectedStatus: containerStatusWaiting, + expectedReason: "CrashLoopBackOff", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cs := corev1.ContainerStatus{ + State: tt.containerState, + } + md := GetMetadata(cs) + + require.NotNil(t, md) + assert.Equal(t, tt.expectedStatus, md.Metadata[containerKeyStatus]) + if tt.expectedReason != "" { + assert.Equal(t, tt.expectedReason, md.Metadata[containerKeyStatusReason]) + } + if tt.containerState.Running != nil || tt.containerState.Terminated != nil { + assert.Contains(t, md.Metadata, containerCreationTimestamp) + assert.Equal(t, tt.expectedStartedAt, md.Metadata[containerCreationTimestamp]) + } + }) + } +} diff --git a/receiver/k8sclusterreceiver/internal/container/package_test.go b/receiver/k8sclusterreceiver/internal/container/package_test.go new file mode 100644 index 000000000000..245776eec13d --- /dev/null +++ b/receiver/k8sclusterreceiver/internal/container/package_test.go @@ -0,0 +1,14 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package container + +import ( + "testing" + + "go.uber.org/goleak" +) + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} diff --git a/receiver/k8sclusterreceiver/internal/namespace/namespaces.go b/receiver/k8sclusterreceiver/internal/namespace/namespaces.go index 2e317e79c65c..846108d6da70 100644 --- a/receiver/k8sclusterreceiver/internal/namespace/namespaces.go +++ b/receiver/k8sclusterreceiver/internal/namespace/namespaces.go @@ -4,12 +4,23 @@ package namespace // import "github.com/open-telemetry/opentelemetry-collector-contrib/receiver/k8sclusterreceiver/internal/namespace" import ( + "strings" + "time" + "go.opentelemetry.io/collector/pdata/pcommon" corev1 "k8s.io/api/core/v1" + "github.com/open-telemetry/opentelemetry-collector-contrib/pkg/experimentalmetricmetadata" + "github.com/open-telemetry/opentelemetry-collector-contrib/receiver/k8sclusterreceiver/internal/metadata" imetadata "github.com/open-telemetry/opentelemetry-collector-contrib/receiver/k8sclusterreceiver/internal/metadata" ) +const ( + // Keys for namespace metadata and entity attributes. + k8sNamespaceCreationTime = "k8s.namespace.creation_timestamp" + k8sNamespacePhase = "k8s.namespace.phase" +) + func RecordMetrics(mb *imetadata.MetricsBuilder, ns *corev1.Namespace, ts pcommon.Timestamp) { mb.RecordK8sNamespacePhaseDataPoint(ts, int64(namespacePhaseValues[ns.Status.Phase])) rb := mb.NewResourceBuilder() @@ -24,3 +35,24 @@ var namespacePhaseValues = map[corev1.NamespacePhase]int32{ // If phase is blank for some reason, send as -1 for unknown. corev1.NamespacePhase(""): -1, } + +func GetMetadata(ns *corev1.Namespace) map[experimentalmetricmetadata.ResourceID]*metadata.KubernetesMetadata { + meta := map[string]string{} + meta[metadata.GetOTelNameFromKind("namespace")] = ns.Name + if ns.Status.Phase == "" { + meta[k8sNamespacePhase] = "unknown" + } else { + meta[k8sNamespacePhase] = strings.ToLower(string(ns.Status.Phase)) + } + meta[k8sNamespaceCreationTime] = ns.CreationTimestamp.Format(time.RFC3339) + nsID := experimentalmetricmetadata.ResourceID(ns.UID) + + return map[experimentalmetricmetadata.ResourceID]*metadata.KubernetesMetadata{ + nsID: { + EntityType: "k8s.namespace", + ResourceIDKey: "k8s.namespace.uid", + ResourceID: nsID, + Metadata: meta, + }, + } +} diff --git a/receiver/k8sclusterreceiver/internal/namespace/namespaces_test.go b/receiver/k8sclusterreceiver/internal/namespace/namespaces_test.go index 7338bfa23430..a755d6e38687 100644 --- a/receiver/k8sclusterreceiver/internal/namespace/namespaces_test.go +++ b/receiver/k8sclusterreceiver/internal/namespace/namespaces_test.go @@ -11,7 +11,12 @@ import ( "github.com/stretchr/testify/require" "go.opentelemetry.io/collector/pdata/pcommon" "go.opentelemetry.io/collector/receiver/receivertest" + conventions "go.opentelemetry.io/collector/semconv/v1.18.0" + corev1 "k8s.io/api/core/v1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "github.com/open-telemetry/opentelemetry-collector-contrib/pkg/experimentalmetricmetadata" "github.com/open-telemetry/opentelemetry-collector-contrib/pkg/golden" "github.com/open-telemetry/opentelemetry-collector-contrib/pkg/pdatatest/pmetrictest" "github.com/open-telemetry/opentelemetry-collector-contrib/receiver/k8sclusterreceiver/internal/metadata" @@ -36,3 +41,25 @@ func TestNamespaceMetrics(t *testing.T) { ), ) } + +func TestNamespaceMetadata(t *testing.T) { + ns := &corev1.Namespace{ + ObjectMeta: v1.ObjectMeta{ + UID: types.UID("test-namespace-uid"), + Name: "test-namespace", + Namespace: "default", + CreationTimestamp: v1.Time{Time: time.Now()}, + }, + Status: corev1.NamespaceStatus{ + Phase: corev1.NamespaceActive, + }, + } + + meta := GetMetadata(ns) + + require.NotNil(t, meta) + require.Contains(t, meta, experimentalmetricmetadata.ResourceID("test-namespace-uid")) + require.Equal(t, "test-namespace", meta[experimentalmetricmetadata.ResourceID("test-namespace-uid")].Metadata[conventions.AttributeK8SNamespaceName]) + require.Equal(t, "active", meta[experimentalmetricmetadata.ResourceID("test-namespace-uid")].Metadata["k8s.namespace.phase"]) + require.Equal(t, ns.CreationTimestamp.Format(time.RFC3339), meta[experimentalmetricmetadata.ResourceID("test-namespace-uid")].Metadata["k8s.namespace.creation_timestamp"]) +} diff --git a/receiver/k8sclusterreceiver/internal/testutils/objects.go b/receiver/k8sclusterreceiver/internal/testutils/objects.go index 50330362c852..b6781e77d0a4 100644 --- a/receiver/k8sclusterreceiver/internal/testutils/objects.go +++ b/receiver/k8sclusterreceiver/internal/testutils/objects.go @@ -4,6 +4,8 @@ package testutils // import "github.com/open-telemetry/opentelemetry-collector-contrib/receiver/k8sclusterreceiver/internal/testutils" import ( + "time" + quotav1 "github.com/openshift/api/quota/v1" appsv1 "k8s.io/api/apps/v1" autoscalingv2 "k8s.io/api/autoscaling/v2" @@ -257,7 +259,9 @@ func NewPodStatusWithContainer(containerName, containerID string) *corev1.PodSta Image: "container-image-name", ContainerID: containerID, State: corev1.ContainerState{ - Running: &corev1.ContainerStateRunning{}, + Running: &corev1.ContainerStateRunning{ + StartedAt: v1.Time{Time: time.Date(1, time.January, 1, 1, 1, 1, 1, time.UTC)}, + }, }, }, }, diff --git a/receiver/k8sclusterreceiver/testdata/e2e/entities-test/collector/clusterrole.yaml b/receiver/k8sclusterreceiver/testdata/e2e/entities-test/collector/clusterrole.yaml new file mode 100644 index 000000000000..fb59cf86ef19 --- /dev/null +++ b/receiver/k8sclusterreceiver/testdata/e2e/entities-test/collector/clusterrole.yaml @@ -0,0 +1,63 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ .Name }} + labels: + app: {{ .Name }} +rules: + - apiGroups: + - "" + resources: + - events + - namespaces + - namespaces/status + - nodes + - nodes/spec + - pods + - pods/status + - replicationcontrollers + - replicationcontrollers/status + - resourcequotas + - services + verbs: + - get + - list + - watch + - apiGroups: + - apps + resources: + - daemonsets + - deployments + - replicasets + - statefulsets + verbs: + - get + - list + - watch + - apiGroups: + - extensions + resources: + - daemonsets + - deployments + - replicasets + verbs: + - get + - list + - watch + - apiGroups: + - batch + resources: + - jobs + - cronjobs + verbs: + - get + - list + - watch + - apiGroups: + - autoscaling + resources: + - horizontalpodautoscalers + verbs: + - get + - list + - watch \ No newline at end of file diff --git a/receiver/k8sclusterreceiver/testdata/e2e/entities-test/collector/clusterrolebinding.yaml b/receiver/k8sclusterreceiver/testdata/e2e/entities-test/collector/clusterrolebinding.yaml new file mode 100644 index 000000000000..b792cc6309de --- /dev/null +++ b/receiver/k8sclusterreceiver/testdata/e2e/entities-test/collector/clusterrolebinding.yaml @@ -0,0 +1,12 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ .Name }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ .Name }} +subjects: + - kind: ServiceAccount + name: {{ .Name }} + namespace: default diff --git a/receiver/k8sclusterreceiver/testdata/e2e/entities-test/collector/configmap.yaml b/receiver/k8sclusterreceiver/testdata/e2e/entities-test/collector/configmap.yaml new file mode 100644 index 000000000000..a1785ce89318 --- /dev/null +++ b/receiver/k8sclusterreceiver/testdata/e2e/entities-test/collector/configmap.yaml @@ -0,0 +1,30 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Name }}-config + namespace: default +data: + relay: | + exporters: + otlp: + endpoint: {{ .HostEndpoint }}:4317 + tls: + insecure: true + extensions: + health_check: + endpoint: 0.0.0.0:13133 + processors: + receivers: + k8s_cluster: + service: + telemetry: + logs: + level: "debug" + extensions: + - health_check + pipelines: + logs: + exporters: + - otlp + receivers: + - k8s_cluster diff --git a/receiver/k8sclusterreceiver/testdata/e2e/entities-test/collector/deployment.yaml b/receiver/k8sclusterreceiver/testdata/e2e/entities-test/collector/deployment.yaml new file mode 100644 index 000000000000..841c472b04f4 --- /dev/null +++ b/receiver/k8sclusterreceiver/testdata/e2e/entities-test/collector/deployment.yaml @@ -0,0 +1,59 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Name }} + namespace: default +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: opentelemetry-collector + app.kubernetes.io/instance: {{ .Name }} + template: + metadata: + labels: + app.kubernetes.io/name: opentelemetry-collector + app.kubernetes.io/instance: {{ .Name }} + spec: + serviceAccountName: {{ .Name }} + containers: + - name: opentelemetry-collector + command: + - /otelcontribcol + - --config=/conf/relay.yaml + image: "otelcontribcol:latest" + imagePullPolicy: Never + ports: + - name: otlp + containerPort: 4317 + protocol: TCP + env: + - name: MY_POD_IP + valueFrom: + fieldRef: + apiVersion: v1 + fieldPath: status.podIP + livenessProbe: + httpGet: + path: / + port: 13133 + initialDelaySeconds: 3 + readinessProbe: + httpGet: + path: / + port: 13133 + initialDelaySeconds: 3 + resources: + limits: + cpu: 128m + memory: 256Mi + volumeMounts: + - mountPath: /conf + name: opentelemetry-collector-configmap + volumes: + - name: opentelemetry-collector-configmap + configMap: + name: {{ .Name }}-config + items: + - key: relay + path: relay.yaml diff --git a/receiver/k8sclusterreceiver/testdata/e2e/entities-test/collector/service.yaml b/receiver/k8sclusterreceiver/testdata/e2e/entities-test/collector/service.yaml new file mode 100644 index 000000000000..1bbfffb99197 --- /dev/null +++ b/receiver/k8sclusterreceiver/testdata/e2e/entities-test/collector/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ .Name }} + namespace: default +spec: + type: ClusterIP + ports: + - name: otlp + port: 4317 + targetPort: 4317 + protocol: TCP + appProtocol: grpc + selector: + app.kubernetes.io/name: opentelemetry-collector + app.kubernetes.io/instance: {{ .Name }} diff --git a/receiver/k8sclusterreceiver/testdata/e2e/entities-test/collector/serviceaccount.yaml b/receiver/k8sclusterreceiver/testdata/e2e/entities-test/collector/serviceaccount.yaml new file mode 100644 index 000000000000..bdb3a8dd1b8f --- /dev/null +++ b/receiver/k8sclusterreceiver/testdata/e2e/entities-test/collector/serviceaccount.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ .Name }} + namespace: default diff --git a/receiver/k8sclusterreceiver/testdata/e2e/entities-test/expected-ns.yaml b/receiver/k8sclusterreceiver/testdata/e2e/entities-test/expected-ns.yaml new file mode 100644 index 000000000000..b49f8615fd1b --- /dev/null +++ b/receiver/k8sclusterreceiver/testdata/e2e/entities-test/expected-ns.yaml @@ -0,0 +1,42 @@ +resourceLogs: + - scopeLogs: + - scope: + attributes: + - key: otel.entity.event_as_log + value: + boolValue: true + logRecords: + - timeUnixNano: "1738160303151735394" + body: {} + attributes: + - key: otel.entity.id + value: + kvlistValue: + values: + - key: k8s.namespace.uid + value: + stringValue: entity-id + - key: otel.entity.event.type + value: + stringValue: entity_state + - key: otel.entity.type + value: + stringValue: k8s.namespace + - key: otel.entity.interval + value: + intValue: "300000" + - key: otel.entity.attributes + value: + kvlistValue: + values: + - key: k8s.namespace.name + value: + stringValue: test-entities-ns + - key: k8s.namespace.phase + value: + stringValue: active + - key: k8s.namespace.creation_timestamp + value: + stringValue: "2025-01-01T00:00:00Z" + traceId: "" + spanId: "" diff --git a/receiver/k8sclusterreceiver/testdata/e2e/entities-test/testobjects/1-test-ns.yaml b/receiver/k8sclusterreceiver/testdata/e2e/entities-test/testobjects/1-test-ns.yaml new file mode 100644 index 000000000000..b3b4472b1f54 --- /dev/null +++ b/receiver/k8sclusterreceiver/testdata/e2e/entities-test/testobjects/1-test-ns.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: test-entities-ns diff --git a/receiver/k8sclusterreceiver/watcher.go b/receiver/k8sclusterreceiver/watcher.go index 4912f056e48d..798a320f63d6 100644 --- a/receiver/k8sclusterreceiver/watcher.go +++ b/receiver/k8sclusterreceiver/watcher.go @@ -36,6 +36,7 @@ import ( "github.com/open-telemetry/opentelemetry-collector-contrib/receiver/k8sclusterreceiver/internal/hpa" "github.com/open-telemetry/opentelemetry-collector-contrib/receiver/k8sclusterreceiver/internal/jobs" "github.com/open-telemetry/opentelemetry-collector-contrib/receiver/k8sclusterreceiver/internal/metadata" + "github.com/open-telemetry/opentelemetry-collector-contrib/receiver/k8sclusterreceiver/internal/namespace" "github.com/open-telemetry/opentelemetry-collector-contrib/receiver/k8sclusterreceiver/internal/node" "github.com/open-telemetry/opentelemetry-collector-contrib/receiver/k8sclusterreceiver/internal/pod" "github.com/open-telemetry/opentelemetry-collector-contrib/receiver/k8sclusterreceiver/internal/replicaset" @@ -314,6 +315,8 @@ func (rw *resourceWatcher) objMetadata(obj any) map[experimentalmetricmetadata.R return cronjob.GetMetadata(o) case *autoscalingv2.HorizontalPodAutoscaler: return hpa.GetMetadata(o) + case *corev1.Namespace: + return namespace.GetMetadata(o) } return nil } diff --git a/receiver/k8sclusterreceiver/watcher_test.go b/receiver/k8sclusterreceiver/watcher_test.go index 309144d281ab..7d2fba959b74 100644 --- a/receiver/k8sclusterreceiver/watcher_test.go +++ b/receiver/k8sclusterreceiver/watcher_test.go @@ -331,7 +331,8 @@ func TestObjMetadata(t *testing.T) { ResourceIDKey: "container.id", ResourceID: "container-id", Metadata: map[string]string{ - "container.status": "running", + "container.status": "running", + "container.creation_timestamp": "0001-01-01T01:01:01Z", }, }, }, @@ -537,6 +538,33 @@ func TestObjMetadata(t *testing.T) { }, }, }, + { + name: "Namespace metadata", + metadataStore: metadata.NewStore(), + resource: &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + UID: types.UID("test-namespace-uid"), + Name: "test-namespace", + Namespace: "default", + CreationTimestamp: metav1.Time{Time: time.Now()}, + }, + Status: corev1.NamespaceStatus{ + Phase: corev1.NamespaceActive, + }, + }, + want: map[experimentalmetricmetadata.ResourceID]*metadata.KubernetesMetadata{ + experimentalmetricmetadata.ResourceID("test-namespace-uid"): { + EntityType: "k8s.namespace", + ResourceIDKey: "k8s.namespace.uid", + ResourceID: "test-namespace-uid", + Metadata: map[string]string{ + "k8s.namespace.name": "test-namespace", + "k8s.namespace.phase": "active", + "k8s.namespace.creation_timestamp": time.Now().Format(time.RFC3339), + }, + }, + }, + }, } for _, tt := range tests {