feat: derive k8s.node from OTLP resource attributes (#9002)

The k8s.pod runs_on k8s.node rule can only fire from kube_pod_info: it is
the only table declaring both endpoints, and co-declared edges require
both on the same row. A deployment sending only OTLP has no
kube-state-metrics tables, so its node layer is invisible and that rule
has no source at all, even though the resource attributes carry
k8s.node.name.

Declare k8s.node in otlp_trace_entities and in the synthesized resource
descriptor, and project k8s.node.name in the descriptor writer so the
column the declaration needs exists. Identity is the name rather than
k8s.node.uid: kube-state-metrics carries no node UID, so the name is the
only identity both sources can agree on.

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
This commit is contained in:
dennis zhuang
2026-09-02 05:12:30 +00:00
committed by GitHub
parent 6f1dd0cb76
commit 02278d301b
4 changed files with 33 additions and 10 deletions
+19 -8
View File
@@ -905,6 +905,7 @@ mod tests {
"resource_attributes.service.instance.id",
"resource_attributes.k8s.pod.uid",
"resource_attributes.k8s.pod.name",
"resource_attributes.k8s.node.name",
],
&[(TABLE_DATA_MODEL, TABLE_DATA_MODEL_TRACE_V1)],
);
@@ -913,20 +914,27 @@ mod tests {
.iter()
.map(|d| d.entity_type.as_str())
.collect();
assert_eq!(types, vec!["k8s.pod", "service", "service.instance"]);
assert_eq!(
types,
vec!["k8s.node", "k8s.pod", "service", "service.instance"]
);
assert_eq!(
declarations[0].id_columns,
vec!["resource_attributes.k8s.node.name"]
);
assert_eq!(
declarations[1].id_columns,
vec!["resource_attributes.k8s.pod.uid"]
);
assert_eq!(
declarations[0].descriptive_columns,
declarations[1].descriptive_columns,
vec!["resource_attributes.k8s.pod.name"]
);
assert_eq!(
declarations[2].id_columns,
declarations[3].id_columns,
vec!["service_name", "resource_attributes.service.instance.id"]
);
assert_eq!(declarations[1].id_qualifier, None);
assert_eq!(declarations[2].id_qualifier, None);
let namespaced = table_info(
&[
@@ -1113,6 +1121,7 @@ mod tests {
"k8s.pod.name",
"k8s.container.name",
"k8s.namespace.name",
"k8s.node.name",
],
OTEL_STAMPS,
);
@@ -1127,6 +1136,7 @@ mod tests {
"container",
"host",
"k8s.container",
"k8s.node",
"k8s.pod",
"service",
"service.instance"
@@ -1145,13 +1155,14 @@ mod tests {
);
assert_eq!(declarations[1].id_columns, vec!["host.id"]);
assert_eq!(declarations[1].descriptive_columns, vec!["host.name"]);
assert_eq!(declarations[4].id_columns, vec!["job"]);
assert_eq!(declarations[3].id_columns, vec!["k8s.node.name"]);
assert_eq!(declarations[5].id_columns, vec!["job"]);
assert_eq!(
declarations[4].descriptive_columns,
declarations[5].descriptive_columns,
vec!["service.name", "service.namespace"]
);
assert_eq!(declarations[5].id_columns, vec!["job", "instance"]);
assert!(declarations[5].descriptive_columns.is_empty());
assert_eq!(declarations[6].id_columns, vec!["job", "instance"]);
assert!(declarations[6].descriptive_columns.is_empty());
// Nothing here can produce a k8s.container, so the generic one must
// stand or the container disappears instead of changing type.
@@ -75,6 +75,11 @@ otlp_trace_entities:
descriptive:
- resource_attributes.k8s.pod.name
- resource_attributes.k8s.namespace.name
# Named, not k8s.node.uid: kube-state-metrics carries no node UID, so the
# name is the only identity both sources can agree on. Same-named nodes in
# different clusters therefore merge.
- entity: k8s.node
id: [resource_attributes.k8s.node.name]
# Implicit declarations for Prometheus entity-descriptor metrics, keyed by
# table name. Pod and service identities are the UIDs (names stay
@@ -184,3 +189,6 @@ otel_info_metrics:
descriptive:
- k8s.pod.name
- k8s.namespace.name
# Named for the same reason as on the trace side above.
- entity: k8s.node
id: [k8s.node.name]
@@ -37,7 +37,7 @@ use crate::otlp::metrics::{
};
use crate::otlp::trace::{
KEY_CONTAINER_ID, KEY_CONTAINER_NAME, KEY_HOST_ID, KEY_HOST_NAME, KEY_K8S_CONTAINER_NAME,
KEY_K8S_NAMESPACE_NAME, KEY_K8S_POD_NAME, KEY_K8S_POD_UID, KEY_SERVICE_NAME,
KEY_K8S_NAMESPACE_NAME, KEY_K8S_NODE_NAME, KEY_K8S_POD_NAME, KEY_K8S_POD_UID, KEY_SERVICE_NAME,
KEY_SERVICE_NAMESPACE,
};
use crate::row_writer::{self, MultiTableData};
@@ -64,12 +64,13 @@ fn is_projected_attr(key: &str) -> bool {
| KEY_K8S_POD_NAME
| KEY_K8S_CONTAINER_NAME
| KEY_K8S_NAMESPACE_NAME
| KEY_K8S_NODE_NAME
)
}
/// Upper bound of [`is_projected_attr`] plus the derived `job`/`instance`,
/// used to size the per-row buffers.
const MAX_PROJECTED_TAGS: usize = 12;
const MAX_PROJECTED_TAGS: usize = 13;
/// Projected attributes (sorted `(name, value)` pairs) -> graph window ->
/// the newest data-point time seen in that window, which is what the row for
@@ -264,6 +265,7 @@ mod tests {
kv("service.namespace", "shop"),
kv("service.instance.id", "inst-1"),
kv("host.id", "h-1"),
kv("k8s.node.name", "node-a"),
kv("os.type", "linux"),
];
data.observe(&attrs, &gauge_at(&[100, 50]), &OtlpMetricCtx::default());
@@ -273,6 +275,7 @@ mod tests {
assert!(tags.contains(&("job".to_string(), "shop/api".to_string())));
assert!(tags.contains(&("instance".to_string(), "inst-1".to_string())));
assert!(tags.contains(&("service.name".to_string(), "api".to_string())));
assert!(tags.contains(&("k8s.node.name".to_string(), "node-a".to_string())));
assert!(
tags.iter()
.all(|(k, _)| k != "os.type" && k != "service.instance.id")
+1
View File
@@ -56,6 +56,7 @@ pub const KEY_K8S_POD_UID: &str = "k8s.pod.uid";
pub const KEY_K8S_POD_NAME: &str = "k8s.pod.name";
pub const KEY_K8S_CONTAINER_NAME: &str = "k8s.container.name";
pub const KEY_K8S_NAMESPACE_NAME: &str = "k8s.namespace.name";
pub const KEY_K8S_NODE_NAME: &str = "k8s.node.name";
pub const KEY_SPAN_KIND: &str = "span.kind";
// jaeger const keys, not sure if they are general