1use std::collections::{BTreeMap, HashMap};
27use std::sync::Weak;
28
29use async_trait::async_trait;
30use auth::{
31 PermissionChecker, PermissionCheckerRef, PermissionReq, PermissionTableTarget,
32 PermissionTableTargets, SEMANTIC_GRAPH_QUERY,
33};
34use catalog::CatalogManager;
35use catalog::system_schema::semantic_graph::EntityGraphProvider;
36use common_catalog::consts::{
37 DEFAULT_PRIVATE_SCHEMA_NAME, DEFAULT_SCHEMA_NAME, INFORMATION_SCHEMA_NAME, OBSERVED_AT_COLUMN,
38 PG_CATALOG_NAME, SEMANTIC_RELATIONSHIPS_DECLARED_TABLE_NAME,
39};
40use common_error::ext::{BoxedError, ErrorExt};
41use common_error::status_code::StatusCode;
42use common_query::OutputData;
43use common_recordbatch::SendableRecordBatchStream;
44use common_telemetry::{debug, warn};
45use common_time::timestamp::TimeUnit;
46use datafusion::dataframe::DataFrame;
47use datafusion_expr::LogicalPlan;
48use futures::TryStreamExt;
49use operator::statement::semantic_graph::{
50 CallsSource, CoDeclaredSource, Conventions, DeclaredSource, ENTITY_TYPE_GEN_AI_AGENT,
51 ENTITY_TYPE_SERVICE, EntityDeclaration, GraphQueryWindow, ImplicitEntity, RegistrySource,
52 RelationshipSources, build_registry_plan, build_relationships_plan, conventions,
53 declared_relationships_schema_matches,
54};
55use query::QueryEngineRef;
56use session::context::{QueryContext, QueryContextBuilder, QueryContextRef};
57use snafu::ResultExt;
58use store_api::storage::ScanRequest;
59use table::TableRef;
60use table::metadata::TableInfo;
61use table::predicate::{TimeRangeExtraction, extract_time_range_strict};
62use table::requests::{
63 EntityRole, SEMANTIC_METRIC_TYPE, SEMANTIC_SIGNAL_TYPE, SEMANTIC_SOURCE, SIGNAL_TYPE_METRIC,
64 SOURCE_OPENTELEMETRY, SOURCE_PROMETHEUS, is_trace_v1_table, parse_entity_columns,
65 parse_entity_option_key,
66};
67
68use crate::error;
69
70pub struct EntityGraphProviderImpl {
72 query_engine: QueryEngineRef,
73 catalog_manager: Weak<dyn CatalogManager>,
74 permission_checker: Option<PermissionCheckerRef>,
75}
76
77struct EntitySource {
78 declarations: Vec<EntityDeclaration>,
79 is_trace: bool,
80 table: TableRef,
81}
82
83struct TraceSource {
84 service: Option<EntityDeclaration>,
85 agent: Option<EntityDeclaration>,
86 table: TableRef,
87}
88
89impl EntityGraphProviderImpl {
90 pub fn new(
91 query_engine: QueryEngineRef,
92 catalog_manager: Weak<dyn CatalogManager>,
93 permission_checker: Option<PermissionCheckerRef>,
94 ) -> Self {
95 Self {
96 query_engine,
97 catalog_manager,
98 permission_checker,
99 }
100 }
101
102 fn authorize_sources(
106 &self,
107 query_ctx: Option<&QueryContext>,
108 targets: PermissionTableTargets,
109 ) -> Result<bool, BoxedError> {
110 let Some(ctx) = query_ctx else {
111 return Ok(true);
112 };
113 match self
114 .permission_checker
115 .as_ref()
116 .check_permission_with_table_targets(
117 ctx.current_user(),
118 PermissionReq::Action(SEMANTIC_GRAPH_QUERY),
119 targets,
120 ) {
121 Ok(_) => Ok(true),
122 Err(err) if err.status_code() == StatusCode::PermissionDenied => Ok(false),
123 Err(err) => Err(BoxedError::new(err)),
124 }
125 }
126
127 fn parse_declarations(table_info: &TableInfo) -> Vec<EntityDeclaration> {
130 let Some(time_index) = table_info
131 .meta
132 .schema
133 .timestamp_column()
134 .map(|c| c.name.clone())
135 else {
136 return vec![];
137 };
138
139 type RoleColumns = (Vec<String>, Vec<String>, Vec<String>);
141 let mut by_type: HashMap<String, RoleColumns> = HashMap::new();
142 for (key, value) in &table_info.meta.options.extra_options {
143 let Some((entity_type, role)) = parse_entity_option_key(key) else {
144 continue;
145 };
146 let cols = parse_entity_columns(value);
147 let entry = by_type.entry(entity_type.to_string()).or_default();
148 match role {
149 EntityRole::Id => entry.0 = cols,
150 EntityRole::Descriptive => entry.1 = cols,
151 EntityRole::Scope => entry.2 = cols,
152 }
153 }
154
155 by_type
156 .into_iter()
157 .filter(|(_, (id, _, _))| !id.is_empty())
158 .filter_map(
159 |(entity_type, (id_columns, descriptive_columns, scope_columns))| {
160 let schema = &table_info.meta.schema;
163 if let Some(missing) = id_columns
164 .iter()
165 .chain(&descriptive_columns)
166 .chain(&scope_columns)
167 .find(|c| schema.column_schema_by_name(c).is_none())
168 {
169 warn!(
170 "Skipping entity declaration `{}` of table `{}`: column `{}` not found",
171 entity_type, table_info.name, missing
172 );
173 return None;
174 }
175 Some(EntityDeclaration {
176 schema: table_info.schema_name.clone(),
177 table: table_info.name.clone(),
178 time_index: time_index.clone(),
179 entity_type,
180 id_columns,
181 id_qualifier: None,
182 descriptive_columns,
183 scope_columns,
184 })
185 },
186 )
187 .collect()
188 }
189
190 fn declarations_for(
199 table_info: &TableInfo,
200 conventions: &Conventions,
201 ) -> Vec<EntityDeclaration> {
202 let mut declarations = Self::parse_declarations(table_info);
203 if is_trace_v1_table(table_info) {
204 Self::extend_with_implicit_entities(
205 table_info,
206 &conventions.otlp_trace_entities,
207 &mut declarations,
208 );
209 }
210 Self::extend_with_info_metric_conventions(
211 table_info,
212 &conventions.prometheus_info_metrics,
213 SOURCE_PROMETHEUS,
214 None,
215 &mut declarations,
216 );
217 Self::extend_with_info_metric_conventions(
218 table_info,
219 &conventions.otel_info_metrics,
220 SOURCE_OPENTELEMETRY,
221 Some(servers::semantic::METRIC_TYPE_INFO),
222 &mut declarations,
223 );
224 declarations
225 }
226
227 fn explicitly_declares(table_info: &TableInfo, entity_type: &str) -> bool {
229 table_info.meta.options.extra_options.keys().any(|key| {
230 parse_entity_option_key(key)
231 .is_some_and(|(ty, role)| ty == entity_type && role == EntityRole::Id)
232 })
233 }
234
235 fn extend_with_info_metric_conventions(
243 table_info: &TableInfo,
244 whitelist: &BTreeMap<String, Vec<ImplicitEntity>>,
245 expected_source: &str,
246 expected_metric_type: Option<&str>,
247 declarations: &mut Vec<EntityDeclaration>,
248 ) {
249 let Some(implicit_entities) = whitelist.get(&table_info.name) else {
250 return;
251 };
252 let options = &table_info.meta.options.extra_options;
253 if options.get(SEMANTIC_SIGNAL_TYPE).map(String::as_str) != Some(SIGNAL_TYPE_METRIC)
254 || options.get(SEMANTIC_SOURCE).map(String::as_str) != Some(expected_source)
255 || expected_metric_type.is_some_and(|expected| {
256 options.get(SEMANTIC_METRIC_TYPE).map(String::as_str) != Some(expected)
257 })
258 || table_info.is_physical_table()
259 {
260 debug!(
261 "Table `{}` matches the info-metric whitelist but is not an eligible \
262 `{expected_source}` info-metric source; skipping its implicit declarations",
263 table_info.name
264 );
265 return;
266 }
267 Self::extend_with_implicit_entities(table_info, implicit_entities, declarations);
268 }
269
270 fn extend_with_implicit_entities(
274 table_info: &TableInfo,
275 entities: &[ImplicitEntity],
276 declarations: &mut Vec<EntityDeclaration>,
277 ) {
278 let schema = &table_info.meta.schema;
279 let Some(time_index) = schema.timestamp_column().map(|c| c.name.clone()) else {
280 debug!(
281 "Table `{}` has no time index; skipping its implicit declarations",
282 table_info.name
283 );
284 return;
285 };
286 for implicit in entities {
287 if Self::explicitly_declares(table_info, &implicit.entity) {
288 debug!(
289 "Table `{}` explicitly declares `{}`; the implicit declaration is suppressed",
290 table_info.name, implicit.entity
291 );
292 continue;
293 }
294 if let Some(missing) = implicit
295 .id
296 .iter()
297 .find(|c| schema.column_schema_by_name(c).is_none())
298 {
299 debug!(
300 "Table `{}` lacks the id column `{}`; skipping the implicit `{}` declaration",
301 table_info.name, missing, implicit.entity
302 );
303 continue;
304 }
305 let descriptive_columns = if implicit.descriptive_rest {
306 table_info
307 .meta
308 .row_key_column_names()
309 .filter(|c| !implicit.id.contains(c))
310 .cloned()
311 .collect()
312 } else {
313 implicit
314 .descriptive
315 .iter()
316 .filter(|c| schema.column_schema_by_name(c).is_some())
317 .cloned()
318 .collect()
319 };
320 let id_qualifier = implicit
323 .qualified_by
324 .clone()
325 .filter(|c| schema.column_schema_by_name(c).is_some());
326 declarations.push(EntityDeclaration {
327 schema: table_info.schema_name.clone(),
328 table: table_info.name.clone(),
329 time_index: time_index.clone(),
330 entity_type: implicit.entity.clone(),
331 id_columns: implicit.id.clone(),
332 id_qualifier,
333 descriptive_columns,
334 scope_columns: vec![],
335 });
336 }
337 }
338
339 async fn enumerate(
344 &self,
345 catalog: &str,
346 query_ctx: Option<&QueryContext>,
347 ) -> Result<(Vec<EntitySource>, Vec<TraceSource>), BoxedError> {
348 let Some(catalog_manager) = self.catalog_manager.upgrade() else {
349 return Ok((vec![], vec![]));
350 };
351 let conventions = conventions()
352 .map_err(datafusion::error::DataFusionError::Internal)
353 .context(error::DataFusionSnafu)
354 .map_err(BoxedError::new)?;
355
356 let per_table_auth = match self.permission_checker.as_ref() {
359 Some(checker) if query_ctx.is_some() => {
360 if checker.uses_table_targets() {
361 true
362 } else if self
363 .authorize_sources(query_ctx, PermissionTableTargets::resolved(vec![]))?
364 {
365 false
366 } else {
367 debug!(
368 "Caller lacks the entity-graph read permission; deriving an empty graph \
369 (catalog: {catalog})"
370 );
371 return Ok((vec![], vec![]));
372 }
373 }
374 _ => false,
375 };
376
377 let mut declarations = vec![];
378 let mut traces = vec![];
379 let schemas = catalog_manager
380 .schema_names(catalog, query_ctx)
381 .await
382 .map_err(BoxedError::new)?;
383 for schema in schemas {
384 if schema == INFORMATION_SCHEMA_NAME
387 || schema == PG_CATALOG_NAME
388 || schema == DEFAULT_PRIVATE_SCHEMA_NAME
389 {
390 continue;
391 }
392 let mut tables = catalog_manager.tables(catalog, &schema, query_ctx);
393 while let Some(table) = tables.try_next().await.map_err(BoxedError::new)? {
394 let table_info = table.table_info();
395 let table_declarations = Self::declarations_for(&table_info, conventions);
396 let is_trace = is_trace_v1_table(&table_info);
397 if per_table_auth
399 && (is_trace || !table_declarations.is_empty())
400 && !self.authorize_sources(
401 query_ctx,
402 PermissionTableTargets::resolved(vec![PermissionTableTarget::new(
403 catalog,
404 &schema,
405 &table_info.name,
406 )]),
407 )?
408 {
409 debug!(
410 "Excluding `{schema}.{}` from the entity-graph derivation: caller lacks \
411 read permission",
412 table_info.name
413 );
414 continue;
415 }
416 if is_trace {
417 let find = |entity_type: &str| {
418 table_declarations
419 .iter()
420 .find(|d| d.entity_type == entity_type)
421 .cloned()
422 };
423 let service = find(ENTITY_TYPE_SERVICE);
424 let agent = find(ENTITY_TYPE_GEN_AI_AGENT);
425 if service.is_none() {
426 warn!(
429 "Trace table `{}` has no usable service declaration; skipping calls derivation",
430 table_info.name
431 );
432 }
433 if service.is_some() || agent.is_some() {
434 traces.push(TraceSource {
435 service,
436 agent,
437 table: table.clone(),
438 });
439 }
440 }
441 if !table_declarations.is_empty() {
442 declarations.push(EntitySource {
443 declarations: table_declarations,
444 is_trace,
445 table,
446 });
447 }
448 }
449 }
450 Ok((declarations, traces))
451 }
452
453 fn query_window(request: &ScanRequest) -> Result<Option<GraphQueryWindow>, BoxedError> {
465 let invalid =
466 |err_msg: String| Err(BoxedError::new(error::InvalidSqlSnafu { err_msg }.build()));
467 match extract_time_range_strict(OBSERVED_AT_COLUMN, TimeUnit::Millisecond, &request.filters)
468 {
469 TimeRangeExtraction::Absent => Ok(Some(GraphQueryWindow::default_last_hour())),
470 TimeRangeExtraction::Extracted(range) => {
471 let Some(start) = range.start() else {
472 return invalid(format!(
473 "the {OBSERVED_AT_COLUMN} filter has no lower bound; the graph cannot \
474 derive over unbounded history — add e.g. {OBSERVED_AT_COLUMN} >= \
475 '2026-01-01 00:00:00'"
476 ));
477 };
478 let end_ms = range
479 .end()
480 .map(|ts| ts.value())
481 .unwrap_or_else(common_time::util::current_time_millis);
482 if start.value() >= end_ms {
483 return Ok(None);
484 }
485 Ok(Some(GraphQueryWindow::from_observed(start.value(), end_ms)))
486 }
487 TimeRangeExtraction::Unsupported => invalid(format!(
488 "cannot derive the graph window from the {OBSERVED_AT_COLUMN} filter; use plain \
489 range predicates (>=, <, BETWEEN) with literal bounds"
490 )),
491 }
492 }
493
494 fn read_table(&self, table: TableRef) -> Result<DataFrame, BoxedError> {
495 self.query_engine.read_table(table).map_err(BoxedError::new)
496 }
497
498 async fn declared_source(
503 &self,
504 catalog: &str,
505 query_ctx: Option<&QueryContext>,
506 ) -> Result<Option<DeclaredSource>, BoxedError> {
507 let Some(catalog_manager) = self.catalog_manager.upgrade() else {
508 return Ok(None);
509 };
510 let Some(table) = catalog_manager
511 .table(
512 catalog,
513 DEFAULT_PRIVATE_SCHEMA_NAME,
514 SEMANTIC_RELATIONSHIPS_DECLARED_TABLE_NAME,
515 query_ctx,
516 )
517 .await
518 .map_err(BoxedError::new)?
519 else {
520 return Ok(None);
521 };
522 if !self.authorize_sources(
523 query_ctx,
524 PermissionTableTargets::resolved(vec![PermissionTableTarget::new(
525 catalog,
526 DEFAULT_PRIVATE_SCHEMA_NAME,
527 SEMANTIC_RELATIONSHIPS_DECLARED_TABLE_NAME,
528 )]),
529 )? {
530 debug!(
531 "Excluding declared edges: caller lacks read permission on \
532 `{DEFAULT_PRIVATE_SCHEMA_NAME}.{SEMANTIC_RELATIONSHIPS_DECLARED_TABLE_NAME}`"
533 );
534 return Ok(None);
535 }
536 let table_info = table.table_info();
537 if !declared_relationships_schema_matches(&table_info) {
538 return Err(BoxedError::new(
539 error::InvalidSqlSnafu {
540 err_msg: format!(
541 "{DEFAULT_PRIVATE_SCHEMA_NAME}.{SEMANTIC_RELATIONSHIPS_DECLARED_TABLE_NAME} \
542 does not match its canonical schema; declared edges cannot be derived"
543 ),
544 }
545 .build(),
546 ));
547 }
548 Ok(Some(DeclaredSource {
549 scan: self.read_table(table)?,
550 }))
551 }
552
553 async fn execute_plan(
557 &self,
558 catalog: &str,
559 plan: LogicalPlan,
560 query_ctx: Option<QueryContextRef>,
561 ) -> Result<Option<SendableRecordBatchStream>, BoxedError> {
562 let query_ctx = query_ctx.unwrap_or_else(|| {
563 QueryContextBuilder::default()
564 .current_catalog(catalog.to_string())
565 .current_schema(DEFAULT_SCHEMA_NAME.to_string())
566 .build()
567 .into()
568 });
569 let output = self
570 .query_engine
571 .execute(plan, query_ctx)
572 .await
573 .map_err(BoxedError::new)?;
574 let stream = match output.data {
575 OutputData::Stream(stream) => stream,
576 OutputData::RecordBatches(batches) => batches.as_stream(),
577 OutputData::AffectedRows(_) => return Ok(None),
578 };
579 Ok(Some(stream))
580 }
581}
582
583#[async_trait]
584impl EntityGraphProvider for EntityGraphProviderImpl {
585 async fn scan_entities(
586 &self,
587 catalog: &str,
588 request: ScanRequest,
589 query_ctx: Option<QueryContextRef>,
590 ) -> Result<Option<SendableRecordBatchStream>, BoxedError> {
591 let (sources, _) = self.enumerate(catalog, query_ctx.as_deref()).await?;
592 let mut plans = Vec::with_capacity(sources.len());
593 for source in sources {
594 plans.push(RegistrySource {
595 declarations: source.declarations,
596 scan: self.read_table(source.table)?,
597 });
598 }
599 let Some(window) = Self::query_window(&request)? else {
600 return Ok(None);
601 };
602 let Some(plan) = build_registry_plan(plans, &window)
603 .context(error::DataFusionSnafu)
604 .map_err(BoxedError::new)?
605 else {
606 return Ok(None);
607 };
608 self.execute_plan(catalog, plan, query_ctx).await
609 }
610
611 async fn scan_relationships(
612 &self,
613 catalog: &str,
614 request: ScanRequest,
615 query_ctx: Option<QueryContextRef>,
616 ) -> Result<Option<SendableRecordBatchStream>, BoxedError> {
617 let (sources, traces) = self.enumerate(catalog, query_ctx.as_deref()).await?;
618 let mut calls = Vec::with_capacity(traces.len());
619 for trace in traces {
620 calls.push(CallsSource {
621 service: trace.service,
622 agent: trace.agent,
623 scan: self.read_table(trace.table)?,
624 });
625 }
626 let mut co_declared = Vec::with_capacity(sources.len());
627 for source in sources {
628 co_declared.push(CoDeclaredSource {
629 declarations: source.declarations,
630 is_trace: source.is_trace,
631 scan: self.read_table(source.table)?,
632 });
633 }
634 let declared = self.declared_source(catalog, query_ctx.as_deref()).await?;
635 let Some(window) = Self::query_window(&request)? else {
636 return Ok(None);
637 };
638 let Some(plan) = build_relationships_plan(
639 RelationshipSources {
640 traces: calls,
641 co_declared,
642 declared,
643 },
644 &window,
645 )
646 .context(error::DataFusionSnafu)
647 .map_err(BoxedError::new)?
648 else {
649 return Ok(None);
650 };
651 self.execute_plan(catalog, plan, query_ctx).await
652 }
653}
654
655#[cfg(test)]
656mod tests {
657 use std::collections::HashMap;
658 use std::sync::Arc;
659
660 use common_catalog::consts::{DEFAULT_CATALOG_NAME, MITO_ENGINE};
661 use datatypes::prelude::ConcreteDataType;
662 use datatypes::schema::{ColumnSchema, SchemaBuilder};
663 use store_api::metric_engine_consts::PHYSICAL_TABLE_METADATA_KEY;
664 use table::metadata::{TableInfoBuilder, TableMeta, TableType};
665 use table::requests::{TABLE_DATA_MODEL, TABLE_DATA_MODEL_TRACE_V1, TableOptions};
666
667 use super::*;
668
669 fn table_info(columns: &[&str], extra: &[(&str, &str)]) -> TableInfo {
670 let mut column_schemas = vec![
671 ColumnSchema::new(
672 "ts",
673 ConcreteDataType::timestamp_millisecond_datatype(),
674 false,
675 )
676 .with_time_index(true),
677 ];
678 column_schemas.extend(
679 columns
680 .iter()
681 .map(|c| ColumnSchema::new(*c, ConcreteDataType::string_datatype(), true)),
682 );
683 assemble_table_info(column_schemas, extra)
684 }
685
686 fn assemble_table_info(column_schemas: Vec<ColumnSchema>, extra: &[(&str, &str)]) -> TableInfo {
687 named_table_info("t1", column_schemas, vec![], extra)
688 }
689
690 fn named_table_info(
691 name: &str,
692 column_schemas: Vec<ColumnSchema>,
693 primary_key_indices: Vec<usize>,
694 extra: &[(&str, &str)],
695 ) -> TableInfo {
696 let schema = Arc::new(
697 SchemaBuilder::try_from_columns(column_schemas)
698 .unwrap()
699 .build()
700 .unwrap(),
701 );
702 let options = TableOptions {
703 extra_options: extra
704 .iter()
705 .map(|(k, v)| (k.to_string(), v.to_string()))
706 .collect::<HashMap<_, _>>(),
707 ..Default::default()
708 };
709 let meta = TableMeta {
710 schema,
711 primary_key_indices,
712 value_indices: vec![],
713 engine: MITO_ENGINE.to_string(),
714 next_column_id: 1,
715 options,
716 created_on: Default::default(),
717 updated_on: Default::default(),
718 partition_key_indices: vec![],
719 column_ids: vec![],
720 };
721 TableInfoBuilder::default()
722 .table_id(1)
723 .name(name)
724 .catalog_name(DEFAULT_CATALOG_NAME)
725 .schema_name(DEFAULT_SCHEMA_NAME)
726 .table_version(0)
727 .table_type(TableType::Base)
728 .meta(meta)
729 .build()
730 .unwrap()
731 }
732
733 fn prom_table_info(name: &str, tags: &[&str], extra: &[(&str, &str)]) -> TableInfo {
735 let mut column_schemas = vec![
736 ColumnSchema::new(
737 "greptime_timestamp",
738 ConcreteDataType::timestamp_millisecond_datatype(),
739 false,
740 )
741 .with_time_index(true),
742 ];
743 column_schemas.extend(
744 tags.iter()
745 .map(|c| ColumnSchema::new(*c, ConcreteDataType::string_datatype(), true)),
746 );
747 let primary_key_indices = (1..=tags.len()).collect();
748 named_table_info(name, column_schemas, primary_key_indices, extra)
749 }
750
751 #[test]
752 fn future_lower_only_window_matches_nothing() {
753 let future_ms = common_time::util::current_time_millis() + 86_400_000;
754 let request = ScanRequest {
755 filters: vec![
756 datafusion_expr::col(OBSERVED_AT_COLUMN).gt_eq(datafusion_expr::lit(
757 datafusion::common::ScalarValue::TimestampMillisecond(Some(future_ms), None),
758 )),
759 ],
760 ..Default::default()
761 };
762 assert!(
763 EntityGraphProviderImpl::query_window(&request)
764 .unwrap()
765 .is_none()
766 );
767
768 assert!(
769 EntityGraphProviderImpl::query_window(&ScanRequest::default())
770 .unwrap()
771 .is_some()
772 );
773 }
774
775 #[test]
776 fn declaration_referencing_missing_column_is_skipped() {
777 let info = table_info(
778 &["service_name"],
779 &[
780 ("greptime.semantic.entity.service.id", "service_name"),
781 ("greptime.semantic.entity.host.id", "gone"),
782 ],
783 );
784 let declarations = EntityGraphProviderImpl::declarations_for(&info, conventions().unwrap());
785 assert_eq!(declarations.len(), 1);
786 assert_eq!(declarations[0].entity_type, "service");
787
788 let info = table_info(
789 &["service_name"],
790 &[
791 ("greptime.semantic.entity.service.id", "service_name"),
792 ("greptime.semantic.entity.service.descriptive", "gone"),
793 ],
794 );
795 assert!(
796 EntityGraphProviderImpl::declarations_for(&info, conventions().unwrap()).is_empty()
797 );
798 }
799
800 const PROM_STAMPS: &[(&str, &str)] = &[
801 (SEMANTIC_SIGNAL_TYPE, SIGNAL_TYPE_METRIC),
802 (SEMANTIC_SOURCE, SOURCE_PROMETHEUS),
803 ];
804
805 fn sorted_declarations(info: &TableInfo) -> Vec<EntityDeclaration> {
806 let mut declarations =
807 EntityGraphProviderImpl::declarations_for(info, conventions().unwrap());
808 declarations.sort_by(|a, b| a.entity_type.cmp(&b.entity_type));
809 declarations
810 }
811
812 #[test]
813 fn prometheus_info_metric_gets_implicit_declarations() {
814 let info = prom_table_info(
815 "kube_pod_info",
816 &["namespace", "pod", "uid", "node", "job", "instance"],
817 PROM_STAMPS,
818 );
819 let declarations = sorted_declarations(&info);
820 assert_eq!(declarations.len(), 2);
821 assert_eq!(declarations[0].entity_type, "k8s.node");
822 assert_eq!(declarations[0].id_columns, vec!["node"]);
823 assert_eq!(declarations[1].entity_type, "k8s.pod");
824 assert_eq!(declarations[1].id_columns, vec!["uid"]);
825 assert_eq!(
828 declarations[1].descriptive_columns,
829 vec!["namespace", "pod", "node"]
830 );
831 assert_eq!(declarations[1].time_index, "greptime_timestamp");
832 }
833
834 #[test]
835 fn trace_table_gets_implicit_resource_entities() {
836 let full = table_info(
837 &[
838 "service_name",
839 "resource_attributes.service.instance.id",
840 "resource_attributes.k8s.pod.uid",
841 "resource_attributes.k8s.pod.name",
842 ],
843 &[(TABLE_DATA_MODEL, TABLE_DATA_MODEL_TRACE_V1)],
844 );
845 let declarations = sorted_declarations(&full);
846 let types: Vec<&str> = declarations
847 .iter()
848 .map(|d| d.entity_type.as_str())
849 .collect();
850 assert_eq!(types, vec!["k8s.pod", "service", "service.instance"]);
851 assert_eq!(
852 declarations[0].id_columns,
853 vec!["resource_attributes.k8s.pod.uid"]
854 );
855 assert_eq!(
856 declarations[0].descriptive_columns,
857 vec!["resource_attributes.k8s.pod.name"]
858 );
859 assert_eq!(
860 declarations[2].id_columns,
861 vec!["service_name", "resource_attributes.service.instance.id"]
862 );
863 assert_eq!(declarations[1].id_qualifier, None);
864
865 let namespaced = table_info(
866 &[
867 "service_name",
868 "resource_attributes.service.namespace",
869 "resource_attributes.service.instance.id",
870 ],
871 &[(TABLE_DATA_MODEL, TABLE_DATA_MODEL_TRACE_V1)],
872 );
873 for declaration in sorted_declarations(&namespaced) {
874 assert_eq!(
875 declaration.id_qualifier.as_deref(),
876 Some("resource_attributes.service.namespace"),
877 "{} must qualify its identity like the metric side's job",
878 declaration.entity_type
879 );
880 }
881
882 let no_uid = table_info(
884 &["service_name", "resource_attributes.k8s.pod.name"],
885 &[(TABLE_DATA_MODEL, TABLE_DATA_MODEL_TRACE_V1)],
886 );
887 let types: Vec<String> = sorted_declarations(&no_uid)
888 .into_iter()
889 .map(|d| d.entity_type)
890 .collect();
891 assert_eq!(types, vec!["service"]);
892
893 let invalid_explicit = table_info(
896 &["service_name"],
897 &[
898 (TABLE_DATA_MODEL, TABLE_DATA_MODEL_TRACE_V1),
899 ("greptime.semantic.entity.service.id", "gone"),
900 ],
901 );
902 assert!(sorted_declarations(&invalid_explicit).is_empty());
903 }
904
905 #[test]
906 fn trace_table_host_and_container_require_stable_ids() {
907 let with_ids = table_info(
908 &[
909 "service_name",
910 "resource_attributes.host.id",
911 "resource_attributes.host.name",
912 "resource_attributes.container.id",
913 ],
914 &[(TABLE_DATA_MODEL, TABLE_DATA_MODEL_TRACE_V1)],
915 );
916 let declarations = sorted_declarations(&with_ids);
917 let types: Vec<&str> = declarations
918 .iter()
919 .map(|d| d.entity_type.as_str())
920 .collect();
921 assert_eq!(types, vec!["container", "host", "service"]);
922 assert_eq!(
923 declarations[1].id_columns,
924 vec!["resource_attributes.host.id"]
925 );
926 assert_eq!(
927 declarations[1].descriptive_columns,
928 vec!["resource_attributes.host.name"]
929 );
930
931 let names_only = table_info(
932 &[
933 "service_name",
934 "resource_attributes.host.name",
935 "resource_attributes.container.name",
936 ],
937 &[(TABLE_DATA_MODEL, TABLE_DATA_MODEL_TRACE_V1)],
938 );
939 let types: Vec<String> = sorted_declarations(&names_only)
940 .into_iter()
941 .map(|d| d.entity_type)
942 .collect();
943 assert_eq!(types, vec!["service"]);
944 }
945
946 #[test]
947 fn prometheus_implicit_declarations_are_gated() {
948 let labels: &[&str] = &["namespace", "pod", "node"];
949 assert!(
951 sorted_declarations(&prom_table_info(
952 "kube_pod_info",
953 labels,
954 &[
955 (SEMANTIC_SIGNAL_TYPE, SIGNAL_TYPE_METRIC),
956 (SEMANTIC_SOURCE, "opentelemetry"),
957 ],
958 ))
959 .is_empty()
960 );
961 assert!(
963 sorted_declarations(&prom_table_info("http_requests_total", labels, PROM_STAMPS))
964 .is_empty()
965 );
966 let mut stamps = PROM_STAMPS.to_vec();
967 stamps.push((PHYSICAL_TABLE_METADATA_KEY, "true"));
968 assert!(sorted_declarations(&prom_table_info("kube_pod_info", labels, &stamps)).is_empty());
969
970 let info = prom_table_info("kube_pod_info", &["namespace", "pod", "node"], PROM_STAMPS);
972 let declarations = sorted_declarations(&info);
973 assert_eq!(declarations.len(), 1);
974 assert_eq!(declarations[0].entity_type, "k8s.node");
975 }
976
977 #[test]
978 fn explicit_declaration_suppresses_the_implicit_one() {
979 let mut stamps = PROM_STAMPS.to_vec();
980 stamps.push(("greptime.semantic.entity.k8s.pod.id", "pod"));
981 let info = prom_table_info("kube_pod_info", &["namespace", "pod", "node"], &stamps);
982 let declarations = sorted_declarations(&info);
983 assert_eq!(declarations.len(), 2);
984 assert_eq!(declarations[0].entity_type, "k8s.node");
985 assert_eq!(declarations[1].entity_type, "k8s.pod");
986 assert_eq!(declarations[1].id_columns, vec!["pod"]);
988 }
989
990 const OTEL_STAMPS: &[(&str, &str)] = &[
991 (SEMANTIC_SIGNAL_TYPE, SIGNAL_TYPE_METRIC),
992 (SEMANTIC_SOURCE, SOURCE_OPENTELEMETRY),
993 (SEMANTIC_METRIC_TYPE, servers::semantic::METRIC_TYPE_INFO),
994 ];
995
996 #[test]
997 fn greptime_otel_resource_info_gets_implicit_declarations() {
998 let info = prom_table_info(
999 "greptime_otel_resource_info",
1000 &[
1001 "job",
1002 "instance",
1003 "service.name",
1004 "service.namespace",
1005 "host.id",
1006 "host.name",
1007 "container.id",
1008 "container.name",
1009 "k8s.pod.uid",
1010 "k8s.pod.name",
1011 "k8s.namespace.name",
1012 ],
1013 OTEL_STAMPS,
1014 );
1015 let declarations = sorted_declarations(&info);
1016 let types: Vec<&str> = declarations
1017 .iter()
1018 .map(|d| d.entity_type.as_str())
1019 .collect();
1020 assert_eq!(
1021 types,
1022 vec![
1023 "container",
1024 "host",
1025 "k8s.pod",
1026 "service",
1027 "service.instance"
1028 ]
1029 );
1030 assert_eq!(declarations[1].id_columns, vec!["host.id"]);
1031 assert_eq!(declarations[1].descriptive_columns, vec!["host.name"]);
1032 assert_eq!(declarations[3].id_columns, vec!["job"]);
1033 assert_eq!(
1034 declarations[3].descriptive_columns,
1035 vec!["service.name", "service.namespace"]
1036 );
1037 assert_eq!(declarations[4].id_columns, vec!["job", "instance"]);
1038 assert!(declarations[4].descriptive_columns.is_empty());
1039
1040 let partial = prom_table_info(
1041 "greptime_otel_resource_info",
1042 &["job", "service.name", "host.id"],
1043 OTEL_STAMPS,
1044 );
1045 let types: Vec<String> = sorted_declarations(&partial)
1046 .into_iter()
1047 .map(|d| d.entity_type)
1048 .collect();
1049 assert_eq!(types, vec!["host", "service"]);
1050 }
1051
1052 #[test]
1053 fn otel_implicit_declarations_are_gated() {
1054 let labels: &[&str] = &["job", "instance", "host.id"];
1055 assert!(
1056 sorted_declarations(&prom_table_info(
1057 "greptime_otel_resource_info",
1058 labels,
1059 PROM_STAMPS
1060 ))
1061 .is_empty()
1062 );
1063 let mut stamps = OTEL_STAMPS.to_vec();
1064 stamps.push((PHYSICAL_TABLE_METADATA_KEY, "true"));
1065 assert!(
1066 sorted_declarations(&prom_table_info(
1067 "greptime_otel_resource_info",
1068 labels,
1069 &stamps
1070 ))
1071 .is_empty()
1072 );
1073 assert!(
1074 conventions()
1075 .unwrap()
1076 .otel_info_metrics
1077 .contains_key(servers::otlp::metrics::OTEL_RESOURCE_INFO_TABLE_NAME)
1078 );
1079
1080 let wrong_type = [
1081 (SEMANTIC_SIGNAL_TYPE, SIGNAL_TYPE_METRIC),
1082 (SEMANTIC_SOURCE, SOURCE_OPENTELEMETRY),
1083 (SEMANTIC_METRIC_TYPE, servers::semantic::METRIC_TYPE_GAUGE),
1084 ];
1085 assert!(
1086 sorted_declarations(&prom_table_info(
1087 "greptime_otel_resource_info",
1088 labels,
1089 &wrong_type
1090 ))
1091 .is_empty()
1092 );
1093 }
1094
1095 #[test]
1096 fn target_info_descriptive_rest_covers_remaining_tags() {
1097 let info = prom_table_info(
1098 "target_info",
1099 &["job", "instance", "k8s_cluster_name", "service_version"],
1100 PROM_STAMPS,
1101 );
1102 let declarations = sorted_declarations(&info);
1103 assert_eq!(declarations.len(), 2);
1104 assert_eq!(declarations[0].entity_type, "service");
1105 assert_eq!(declarations[0].id_columns, vec!["job"]);
1106 assert!(declarations[0].descriptive_columns.is_empty());
1107 assert_eq!(declarations[1].entity_type, "service.instance");
1110 assert_eq!(declarations[1].id_columns, vec!["job", "instance"]);
1111 assert_eq!(
1112 declarations[1].descriptive_columns,
1113 vec!["k8s_cluster_name", "service_version"]
1114 );
1115 }
1116}