1use common_catalog::consts::{
22 CONFIDENCE_COLUMN, DST_ID_COLUMN, DST_TYPE_COLUMN, DURATION_COUNT_COLUMN, DURATION_NANO_COLUMN,
23 DURATION_SUM_COLUMN, EDGE_ATTRIBUTES_COLUMN, ENTITY_SCOPE_COLUMN, ERROR_COUNT_COLUMN,
24 FRESH_UNTIL_COLUMN, GENERATION_ID_COLUMN, OBSERVED_AT_COLUMN, PARENT_SPAN_ID_COLUMN,
25 PROVENANCE_COLUMN, REL_TYPE_COLUMN, REQUEST_COUNT_COLUMN, SPAN_ID_COLUMN, SPAN_KIND_CLIENT,
26 SPAN_KIND_COLUMN, SPAN_KIND_SERVER, SPAN_STATUS_CODE_COLUMN, SPAN_STATUS_ERROR, SRC_ID_COLUMN,
27 SRC_TYPE_COLUMN, TRACE_ID_COLUMN, TRACE_TIMESTAMP_COLUMN, VALID_FROM_COLUMN,
28 VALID_UNTIL_COLUMN, WINDOW_END_COLUMN, WINDOW_START_COLUMN,
29};
30use datafusion::arrow::datatypes::DataType;
31use datafusion::dataframe::DataFrame;
32use datafusion::functions::core as core_fns;
33use datafusion::functions_aggregate::expr_fn::{bool_or, count, min, sum};
34use datafusion::functions_window::expr_fn::row_number;
35use datafusion_common::{Result as DfResult, ScalarValue};
36use datafusion_expr::{Expr, ExprFunctionExt, JoinType, LogicalPlan, cast, ident, lit, when};
37
38use crate::statement::semantic_graph::conventions::{
39 CONNECTION_TYPE_DATABASE, CONNECTION_TYPE_VIRTUAL_NODE, Conventions, ENTITY_TYPE_GEN_AI_AGENT,
40 ENTITY_TYPE_SERVICE, PROVENANCE_ATTRIBUTE, PROVENANCE_TRACE, REL_TYPE_CALLS,
41};
42use crate::statement::semantic_graph::{
43 DECLARED_EDGE_IDENTITY_COLUMNS, EntityDeclaration, GraphQueryWindow, bin_interval, bin_ms,
44 conventions, entity_id_expr, identifies, interval, null_json, parse_json_expr, qcol, union_all,
45 unnest_rows,
46};
47
48fn builtin() -> DfResult<&'static Conventions> {
50 conventions().map_err(datafusion_common::DataFusionError::Internal)
51}
52
53const RELATIONSHIP_COLUMNS: [&str; 16] = [
60 OBSERVED_AT_COLUMN,
61 WINDOW_START_COLUMN,
62 WINDOW_END_COLUMN,
63 FRESH_UNTIL_COLUMN,
64 SRC_TYPE_COLUMN,
65 SRC_ID_COLUMN,
66 DST_TYPE_COLUMN,
67 DST_ID_COLUMN,
68 REL_TYPE_COLUMN,
69 PROVENANCE_COLUMN,
70 CONFIDENCE_COLUMN,
71 REQUEST_COUNT_COLUMN,
72 ERROR_COUNT_COLUMN,
73 DURATION_SUM_COLUMN,
74 DURATION_COUNT_COLUMN,
75 EDGE_ATTRIBUTES_COLUMN,
76];
77
78const CHILD_SPAN_EARLY_NANOS: i64 = 5 * 60 * 1_000_000_000;
83const CHILD_SPAN_LATE_NANOS: i64 = 60 * 60 * 1_000_000_000;
84
85pub struct CallsSource {
91 pub service: Option<EntityDeclaration>,
92 pub agent: Option<EntityDeclaration>,
93 pub scan: DataFrame,
94}
95
96pub struct CoDeclaredSource {
100 pub declarations: Vec<EntityDeclaration>,
101 pub is_trace: bool,
102 pub scan: DataFrame,
103}
104
105pub struct DeclaredSource {
108 pub scan: DataFrame,
109}
110
111pub struct RelationshipSources {
113 pub traces: Vec<CallsSource>,
114 pub co_declared: Vec<CoDeclaredSource>,
115 pub declared: Option<DeclaredSource>,
116}
117
118pub fn build_relationships_plan(
123 sources: RelationshipSources,
124 window: &GraphQueryWindow,
125) -> DfResult<Option<LogicalPlan>> {
126 let mut union_df = calls_branch(&sources.traces, window)?;
127 if let Some(agent_calls) = agent_calls_branch(&sources.traces, window)? {
128 union_df = union_all(union_df, agent_calls)?;
129 }
130 if let Some(co_declared) = co_declared_branch(sources.co_declared, window)? {
131 union_df = union_all(union_df, co_declared)?;
132 }
133 if let Some(declared) = sources.declared {
134 union_df = union_all(union_df, declared_edges(declared.scan, window)?)?;
135 }
136 union_df
137 .map(|df| {
138 Ok(df
139 .select_columns(&RELATIONSHIP_COLUMNS)?
140 .into_unoptimized_plan())
141 })
142 .transpose()
143}
144
145const CO_DECLARED_VALID_COLUMN: &str = "__edge_valid";
146
147fn co_declared_branch(
153 sources: Vec<CoDeclaredSource>,
154 window: &GraphQueryWindow,
155) -> DfResult<Option<DataFrame>> {
156 let vocabulary = builtin()?;
157 let mut union_df: Option<DataFrame> = None;
158 for source in sources {
159 let find = |ty: &str| source.declarations.iter().find(|d| d.entity_type == ty);
160 let mut pairs = Vec::new();
161 for rule in &vocabulary.co_declared_edges {
162 if let (Some(src), Some(dst)) = (find(&rule.src), find(&rule.dst)) {
163 pairs.push((src, dst, rule.rel.as_str(), PROVENANCE_ATTRIBUTE));
164 }
165 }
166 if source.is_trace {
167 for rule in &vocabulary.trace_co_declared_edges {
170 if let (Some(src), Some(dst)) = (find(&rule.src), find(&rule.dst)) {
171 pairs.push((src, dst, rule.rel.as_str(), PROVENANCE_TRACE));
172 }
173 }
174 }
175 let Some((first, _, _, _)) = pairs.first() else {
176 continue;
177 };
178
179 let ts = ident(&first.time_index);
180 let bin = bin_ms(ts.clone());
181 let window_predicate = ts
182 .clone()
183 .gt_eq(window.source_start())
184 .and(ts.lt(window.source_end()));
185 let null_i64 = || lit(ScalarValue::Int64(None));
186 let rows = pairs
187 .iter()
188 .map(|(src, dst, rel_type, provenance)| {
189 let valid = src
192 .id_columns
193 .iter()
194 .chain(&dst.id_columns)
195 .fold(lit(true), |predicate, id| predicate.and(identifies(id)));
196 vec![
197 valid,
198 bin.clone(),
199 bin.clone(),
200 bin.clone() + bin_interval(),
201 bin.clone() + bin_interval(),
202 lit(src.entity_type.as_str()),
203 entity_id_expr(&src.id_columns, &|c| ident(c)),
204 lit(dst.entity_type.as_str()),
205 entity_id_expr(&dst.id_columns, &|c| ident(c)),
206 lit(*rel_type),
207 lit(*provenance),
208 lit(1.0_f64),
209 null_i64(),
210 null_i64(),
211 lit(ScalarValue::Float64(None)),
212 null_i64(),
213 null_json(),
214 ]
215 })
216 .collect();
217
218 let branch = unnest_rows(
219 source.scan,
220 window_predicate,
221 CO_DECLARED_VALID_COLUMN,
222 &RELATIONSHIP_COLUMNS,
223 rows,
224 )?;
225 union_df = union_all(union_df, branch)?;
226 }
227 union_df.map(DataFrame::distinct).transpose()
231}
232
233const DECLARED_REVISION_COLUMN: &str = "__declared_revision";
235
236fn declared_edges(scan: DataFrame, window: &GraphQueryWindow) -> DfResult<DataFrame> {
246 let eff_valid_from =
247 core_fns::coalesce().call(vec![ident(VALID_FROM_COLUMN), ident(OBSERVED_AT_COLUMN)]);
248 let eff_valid_until =
249 core_fns::coalesce().call(vec![ident(VALID_UNTIL_COLUMN), window.observed_end()]);
250
251 let as_of = ident(OBSERVED_AT_COLUMN)
255 .lt(window.observed_end())
256 .and(eff_valid_from.clone().lt(window.observed_end()));
257
258 let revision = row_number()
259 .partition_by(
260 DECLARED_EDGE_IDENTITY_COLUMNS
261 .iter()
262 .map(|c| ident(*c))
263 .collect(),
264 )
265 .order_by(vec![
268 ident(OBSERVED_AT_COLUMN).sort(false, false),
269 ident(GENERATION_ID_COLUMN).sort(false, false),
270 ident(ENTITY_SCOPE_COLUMN).sort(false, false),
271 ])
272 .build()?
273 .alias(DECLARED_REVISION_COLUMN);
274
275 let still_valid = ident(VALID_UNTIL_COLUMN)
278 .is_null()
279 .or(ident(VALID_UNTIL_COLUMN).gt(window.observed_start()));
280 let tag_utf8 = |name: &str| cast(ident(name), DataType::Utf8).alias(name);
283
284 scan.filter(as_of)?
285 .window(vec![revision])?
286 .filter(ident(DECLARED_REVISION_COLUMN).eq(lit(1_u64)))?
287 .filter(still_valid)?
288 .select(vec![
289 core_fns::greatest()
290 .call(vec![eff_valid_from.clone(), window.observed_start()])
291 .alias(OBSERVED_AT_COLUMN),
292 eff_valid_from.alias(WINDOW_START_COLUMN),
293 eff_valid_until.clone().alias(WINDOW_END_COLUMN),
294 eff_valid_until.alias(FRESH_UNTIL_COLUMN),
295 tag_utf8(SRC_TYPE_COLUMN),
296 tag_utf8(SRC_ID_COLUMN),
297 tag_utf8(DST_TYPE_COLUMN),
298 tag_utf8(DST_ID_COLUMN),
299 tag_utf8(REL_TYPE_COLUMN),
300 tag_utf8(PROVENANCE_COLUMN),
301 ident(CONFIDENCE_COLUMN),
302 ident(REQUEST_COUNT_COLUMN),
303 ident(ERROR_COUNT_COLUMN),
304 ident(DURATION_SUM_COLUMN),
305 ident(DURATION_COUNT_COLUMN),
306 ident(EDGE_ATTRIBUTES_COLUMN),
307 ])
308}
309
310const VIRTUAL_NODE_CONFIDENCE: f64 = 0.5;
313
314fn calls_branch(traces: &[CallsSource], window: &GraphQueryWindow) -> DfResult<Option<DataFrame>> {
332 let mut clients: Option<DataFrame> = None;
333 let mut servers: Option<DataFrame> = None;
334 for trace in traces {
335 let Some(service) = &trace.service else {
336 continue;
337 };
338 clients = union_all(clients, client_spans(service, &trace.scan, window)?)?;
339 servers = union_all(servers, server_spans(service, trace.scan.clone(), window)?)?;
340 }
341 let (Some(clients), Some(servers)) = (clients, servers) else {
342 return Ok(None);
343 };
344
345 let client = clients.alias("client")?;
346 let server = servers.alias("server")?;
347 let join_conditions = vec![
348 qcol("client", TRACE_ID_COLUMN).eq(qcol("server", TRACE_ID_COLUMN)),
349 qcol("server", PARENT_SPAN_ID_COLUMN).eq(qcol("client", SPAN_ID_COLUMN)),
350 qcol("server", TRACE_TIMESTAMP_COLUMN)
351 .gt_eq(qcol("client", TRACE_TIMESTAMP_COLUMN) - interval(CHILD_SPAN_EARLY_NANOS)),
352 qcol("server", TRACE_TIMESTAMP_COLUMN)
353 .lt_eq(qcol("client", TRACE_TIMESTAMP_COLUMN) + interval(CHILD_SPAN_LATE_NANOS)),
354 ];
355
356 let observations = client
357 .join_on(server, JoinType::Left, join_conditions)?
358 .select(vec![
359 bin_ms(qcol("client", TRACE_TIMESTAMP_COLUMN)).alias(OBSERVED_AT_COLUMN),
360 qcol("client", SRC_ID_COLUMN).alias(SRC_ID_COLUMN),
361 core_fns::coalesce()
362 .call(vec![
363 qcol("server", DST_ID_COLUMN),
364 qcol("client", "virtual_dst"),
365 ])
366 .alias(DST_ID_COLUMN),
367 qcol("server", TRACE_ID_COLUMN)
368 .is_not_null()
369 .alias("paired"),
370 qcol("server", SPAN_STATUS_CODE_COLUMN).alias("server_status"),
371 qcol("server", DURATION_NANO_COLUMN).alias("server_duration_nano"),
372 qcol("client", SPAN_STATUS_CODE_COLUMN).alias("client_status"),
373 qcol("client", DURATION_NANO_COLUMN).alias("client_duration_nano"),
374 qcol("client", "virtual_conn").alias("virtual_conn"),
375 ])?
376 .filter(
379 ident(DST_ID_COLUMN)
380 .is_not_null()
381 .and(ident(SRC_ID_COLUMN).not_eq(ident(DST_ID_COLUMN))),
382 )?;
383
384 let paired = ident("paired");
385 let df = observations
386 .aggregate(
387 vec![
388 ident(OBSERVED_AT_COLUMN),
389 ident(SRC_ID_COLUMN),
390 ident(DST_ID_COLUMN),
391 ],
392 vec![
393 count(lit(1))
394 .filter(paired.clone())
395 .build()?
396 .alias("pair_count"),
397 count(lit(1))
398 .filter(
399 paired
400 .clone()
401 .and(ident("server_status").eq(lit(SPAN_STATUS_ERROR))),
402 )
403 .build()?
404 .alias("pair_errors"),
405 sum(ident("server_duration_nano"))
406 .filter(paired.clone())
407 .build()?
408 .alias("pair_duration_nano"),
409 count(lit(1))
410 .filter(!paired.clone())
411 .build()?
412 .alias("unmatched_count"),
413 count(lit(1))
414 .filter(
415 (!paired.clone()).and(ident("client_status").eq(lit(SPAN_STATUS_ERROR))),
416 )
417 .build()?
418 .alias("unmatched_errors"),
419 sum(ident("client_duration_nano"))
420 .filter(!paired.clone())
421 .build()?
422 .alias("unmatched_duration_nano"),
423 bool_or(paired.clone()).alias("has_pair"),
424 min(ident("virtual_conn"))
427 .filter(!paired)
428 .build()?
429 .alias("virtual_conn"),
430 ],
431 )?
432 .select(vec![
433 ident(OBSERVED_AT_COLUMN),
434 ident(OBSERVED_AT_COLUMN).alias(WINDOW_START_COLUMN),
435 (ident(OBSERVED_AT_COLUMN) + bin_interval()).alias(WINDOW_END_COLUMN),
436 (ident(OBSERVED_AT_COLUMN) + bin_interval()).alias(FRESH_UNTIL_COLUMN),
437 lit(ENTITY_TYPE_SERVICE).alias(SRC_TYPE_COLUMN),
438 ident(SRC_ID_COLUMN),
439 lit(ENTITY_TYPE_SERVICE).alias(DST_TYPE_COLUMN),
440 ident(DST_ID_COLUMN),
441 lit(REL_TYPE_CALLS).alias(REL_TYPE_COLUMN),
442 lit(PROVENANCE_TRACE).alias(PROVENANCE_COLUMN),
443 real_wins(lit(1.0_f64), lit(VIRTUAL_NODE_CONFIDENCE))?.alias(CONFIDENCE_COLUMN),
444 real_wins(ident("pair_count"), ident("unmatched_count"))?.alias(REQUEST_COUNT_COLUMN),
445 real_wins(ident("pair_errors"), ident("unmatched_errors"))?.alias(ERROR_COUNT_COLUMN),
446 (cast(
448 real_wins(
449 ident("pair_duration_nano"),
450 ident("unmatched_duration_nano"),
451 )?,
452 DataType::Float64,
453 ) / lit(1e9_f64))
454 .alias(DURATION_SUM_COLUMN),
455 real_wins(ident("pair_count"), ident("unmatched_count"))?.alias(DURATION_COUNT_COLUMN),
456 real_wins(null_json(), virtual_attrs_expr()?)?.alias(EDGE_ATTRIBUTES_COLUMN),
457 ])?;
458 Ok(Some(df))
459}
460
461fn real_wins(real: Expr, r#virtual: Expr) -> DfResult<Expr> {
464 when(ident("has_pair"), real).otherwise(r#virtual)
465}
466
467fn virtual_attrs_expr() -> DfResult<Expr> {
470 when(
471 ident("virtual_conn").eq(lit(CONNECTION_TYPE_DATABASE)),
472 parse_json_expr(lit(format!(
473 r#"{{"connection_type":"{CONNECTION_TYPE_DATABASE}"}}"#
474 ))),
475 )
476 .otherwise(parse_json_expr(lit(format!(
477 r#"{{"connection_type":"{CONNECTION_TYPE_VIRTUAL_NODE}"}}"#
478 ))))
479}
480
481fn span_predicate(service: &EntityDeclaration, window: &GraphQueryWindow, strict: bool) -> Expr {
486 let ts = ident(TRACE_TIMESTAMP_COLUMN);
487 let mut predicate = if strict {
488 ts.clone()
489 .gt_eq(window.source_start())
490 .and(ts.lt(window.source_end()))
491 } else {
492 ts.clone()
493 .gt_eq(window.source_start() - interval(CHILD_SPAN_EARLY_NANOS))
494 .and(ts.lt(window.source_end() + interval(CHILD_SPAN_LATE_NANOS)))
495 };
496 for id in &service.id_columns {
498 predicate = predicate.and(identifies(id));
499 }
500 predicate
501}
502
503fn client_spans(
510 service: &EntityDeclaration,
511 scan: &DataFrame,
512 window: &GraphQueryWindow,
513) -> DfResult<DataFrame> {
514 let present: Vec<(Expr, &str)> = {
519 let schema = scan.schema();
520 builtin()?
521 .virtual_dst_candidates
522 .iter()
523 .filter(|candidate| schema.has_column_with_unqualified_name(&candidate.column))
524 .map(|candidate| {
525 let value = core_fns::nullif().call(vec![
526 cast(ident(&candidate.column), DataType::Utf8),
527 lit(""),
528 ]);
529 (value, candidate.connection_type.as_str())
530 })
531 .collect()
532 };
533 let null_utf8 = || lit(ScalarValue::Utf8(None));
534 let virtual_dst = if present.is_empty() {
535 null_utf8()
536 } else {
537 core_fns::coalesce().call(present.iter().map(|(value, _)| value.clone()).collect())
538 };
539 let mut virtual_conn = null_utf8();
540 for (value, conn) in present.into_iter().rev() {
541 virtual_conn = when(value.is_not_null(), lit(conn)).otherwise(virtual_conn)?;
542 }
543
544 scan.clone()
545 .filter(
546 ident(SPAN_KIND_COLUMN)
547 .eq(lit(SPAN_KIND_CLIENT))
548 .and(span_predicate(service, window, true)),
549 )?
550 .select(vec![
551 ident(TRACE_TIMESTAMP_COLUMN),
552 ident(TRACE_ID_COLUMN),
553 ident(SPAN_ID_COLUMN),
554 entity_id_expr(&service.id_columns, &|c| ident(c)).alias(SRC_ID_COLUMN),
557 ident(SPAN_STATUS_CODE_COLUMN),
558 ident(DURATION_NANO_COLUMN),
559 virtual_dst.alias("virtual_dst"),
560 virtual_conn.alias("virtual_conn"),
561 ])
562}
563
564fn server_spans(
567 service: &EntityDeclaration,
568 trace: DataFrame,
569 window: &GraphQueryWindow,
570) -> DfResult<DataFrame> {
571 trace
572 .filter(
573 ident(SPAN_KIND_COLUMN)
574 .eq(lit(SPAN_KIND_SERVER))
575 .and(span_predicate(service, window, false)),
576 )?
577 .select(vec![
578 ident(TRACE_TIMESTAMP_COLUMN),
579 ident(TRACE_ID_COLUMN),
580 ident(PARENT_SPAN_ID_COLUMN),
581 entity_id_expr(&service.id_columns, &|c| ident(c)).alias(DST_ID_COLUMN),
582 ident(SPAN_STATUS_CODE_COLUMN),
583 ident(DURATION_NANO_COLUMN),
584 ])
585}
586
587fn agent_calls_branch(
595 traces: &[CallsSource],
596 window: &GraphQueryWindow,
597) -> DfResult<Option<DataFrame>> {
598 let mut parents: Option<DataFrame> = None;
599 let mut children: Option<DataFrame> = None;
600 for trace in traces {
601 let Some(agent) = &trace.agent else {
602 continue;
603 };
604 let parent = trace
605 .scan
606 .clone()
607 .filter(span_predicate(agent, window, true))?
608 .select(vec![
609 ident(TRACE_TIMESTAMP_COLUMN),
610 ident(TRACE_ID_COLUMN),
611 ident(SPAN_ID_COLUMN),
612 entity_id_expr(&agent.id_columns, &|c| ident(c)).alias(SRC_ID_COLUMN),
613 ])?;
614 let child = trace
615 .scan
616 .clone()
617 .filter(span_predicate(agent, window, false))?
618 .select(vec![
619 ident(TRACE_TIMESTAMP_COLUMN),
620 ident(TRACE_ID_COLUMN),
621 ident(PARENT_SPAN_ID_COLUMN),
622 entity_id_expr(&agent.id_columns, &|c| ident(c)).alias(DST_ID_COLUMN),
623 ident(SPAN_STATUS_CODE_COLUMN),
624 ident(DURATION_NANO_COLUMN),
625 ])?;
626 parents = union_all(parents, parent)?;
627 children = union_all(children, child)?;
628 }
629 let (Some(parents), Some(children)) = (parents, children) else {
630 return Ok(None);
631 };
632
633 let parent = parents.alias("parent")?;
634 let child = children.alias("child")?;
635 let join_conditions = vec![
636 qcol("parent", TRACE_ID_COLUMN).eq(qcol("child", TRACE_ID_COLUMN)),
637 qcol("child", PARENT_SPAN_ID_COLUMN).eq(qcol("parent", SPAN_ID_COLUMN)),
638 qcol("child", TRACE_TIMESTAMP_COLUMN)
639 .gt_eq(qcol("parent", TRACE_TIMESTAMP_COLUMN) - interval(CHILD_SPAN_EARLY_NANOS)),
640 qcol("child", TRACE_TIMESTAMP_COLUMN)
641 .lt_eq(qcol("parent", TRACE_TIMESTAMP_COLUMN) + interval(CHILD_SPAN_LATE_NANOS)),
642 ];
643
644 let df = parent
645 .join_on(child, JoinType::Inner, join_conditions)?
646 .select(vec![
647 bin_ms(qcol("parent", TRACE_TIMESTAMP_COLUMN)).alias(OBSERVED_AT_COLUMN),
648 qcol("parent", SRC_ID_COLUMN).alias(SRC_ID_COLUMN),
649 qcol("child", DST_ID_COLUMN).alias(DST_ID_COLUMN),
650 qcol("child", SPAN_STATUS_CODE_COLUMN).alias("status_code"),
651 qcol("child", DURATION_NANO_COLUMN).alias(DURATION_NANO_COLUMN),
652 ])?
653 .filter(ident(SRC_ID_COLUMN).not_eq(ident(DST_ID_COLUMN)))?
656 .aggregate(
657 vec![
658 ident(OBSERVED_AT_COLUMN),
659 ident(SRC_ID_COLUMN),
660 ident(DST_ID_COLUMN),
661 ],
662 vec![
663 count(lit(1)).alias(REQUEST_COUNT_COLUMN),
664 count(lit(1))
665 .filter(ident("status_code").eq(lit(SPAN_STATUS_ERROR)))
666 .build()?
667 .alias(ERROR_COUNT_COLUMN),
668 sum(ident(DURATION_NANO_COLUMN)).alias("duration_nano_sum"),
669 ],
670 )?
671 .select(vec![
672 ident(OBSERVED_AT_COLUMN),
673 ident(OBSERVED_AT_COLUMN).alias(WINDOW_START_COLUMN),
674 (ident(OBSERVED_AT_COLUMN) + bin_interval()).alias(WINDOW_END_COLUMN),
675 (ident(OBSERVED_AT_COLUMN) + bin_interval()).alias(FRESH_UNTIL_COLUMN),
676 lit(ENTITY_TYPE_GEN_AI_AGENT).alias(SRC_TYPE_COLUMN),
677 ident(SRC_ID_COLUMN),
678 lit(ENTITY_TYPE_GEN_AI_AGENT).alias(DST_TYPE_COLUMN),
679 ident(DST_ID_COLUMN),
680 lit(REL_TYPE_CALLS).alias(REL_TYPE_COLUMN),
681 lit(PROVENANCE_TRACE).alias(PROVENANCE_COLUMN),
682 lit(1.0_f64).alias(CONFIDENCE_COLUMN),
683 ident(REQUEST_COUNT_COLUMN),
684 ident(ERROR_COUNT_COLUMN),
685 (cast(ident("duration_nano_sum"), DataType::Float64) / lit(1e9_f64))
686 .alias(DURATION_SUM_COLUMN),
687 ident(REQUEST_COUNT_COLUMN).alias(DURATION_COUNT_COLUMN),
688 null_json().alias(EDGE_ATTRIBUTES_COLUMN),
689 ])?;
690 Ok(Some(df))
691}
692
693#[cfg(test)]
694mod tests {
695 use std::sync::Arc;
696
697 use common_catalog::consts::{SEMANTIC_RELATIONSHIPS_DECLARED_TABLE_NAME, SERVICE_NAME_COLUMN};
698 use datafusion::arrow::array::{
699 Array, ArrayRef, BinaryArray, Float64Array, Int64Array, StringArray,
700 TimestampMillisecondArray, TimestampNanosecondArray, UInt64Array,
701 };
702 use datafusion::arrow::datatypes::{Field, Schema, TimeUnit};
703 use datafusion::arrow::record_batch::RecordBatch;
704 use datafusion::datasource::MemTable;
705 use datafusion::prelude::SessionContext;
706
707 use super::super::test_util::{collect, json_texts, strings, ts_values};
708 use super::*;
709
710 fn test_window() -> GraphQueryWindow {
711 GraphQueryWindow::from_observed(0, 10 * 60 * 1000)
712 }
713
714 type Span<'a> = (
718 i64,
719 &'a str,
720 &'a str,
721 Option<&'a str>,
722 &'a str,
723 &'a str,
724 &'a str,
725 u64,
726 );
727
728 fn register_trace_table(
731 ctx: &SessionContext,
732 name: &str,
733 extra: &[(&str, &[Option<&str>])],
734 spans: &[Span<'_>],
735 ) {
736 let mut fields = vec![
737 Field::new(
738 TRACE_TIMESTAMP_COLUMN,
739 DataType::Timestamp(TimeUnit::Nanosecond, None),
740 false,
741 ),
742 Field::new(TRACE_ID_COLUMN, DataType::Utf8, false),
743 Field::new(SPAN_ID_COLUMN, DataType::Utf8, false),
744 Field::new(PARENT_SPAN_ID_COLUMN, DataType::Utf8, true),
745 Field::new(SPAN_KIND_COLUMN, DataType::Utf8, false),
746 Field::new(SPAN_STATUS_CODE_COLUMN, DataType::Utf8, false),
747 Field::new(SERVICE_NAME_COLUMN, DataType::Utf8, false),
748 Field::new(DURATION_NANO_COLUMN, DataType::UInt64, false),
749 ];
750 for (column, _) in extra {
751 fields.push(Field::new(*column, DataType::Utf8, true));
752 }
753 let schema = Arc::new(Schema::new(fields));
754 const MS: i64 = 1_000_000;
755 let mut columns: Vec<ArrayRef> = vec![
756 Arc::new(TimestampNanosecondArray::from(
757 spans.iter().map(|s| s.0 * MS).collect::<Vec<_>>(),
758 )),
759 Arc::new(StringArray::from(
760 spans.iter().map(|s| s.1).collect::<Vec<_>>(),
761 )),
762 Arc::new(StringArray::from(
763 spans.iter().map(|s| s.2).collect::<Vec<_>>(),
764 )),
765 Arc::new(StringArray::from(
766 spans.iter().map(|s| s.3).collect::<Vec<_>>(),
767 )),
768 Arc::new(StringArray::from(
769 spans.iter().map(|s| s.4).collect::<Vec<_>>(),
770 )),
771 Arc::new(StringArray::from(
772 spans.iter().map(|s| s.5).collect::<Vec<_>>(),
773 )),
774 Arc::new(StringArray::from(
775 spans.iter().map(|s| s.6).collect::<Vec<_>>(),
776 )),
777 Arc::new(UInt64Array::from(
778 spans.iter().map(|s| s.7).collect::<Vec<_>>(),
779 )),
780 ];
781 for (_, values) in extra {
782 columns.push(Arc::new(StringArray::from(values.to_vec())));
783 }
784 let batch = RecordBatch::try_new(schema.clone(), columns).unwrap();
785 ctx.register_table(
786 name,
787 Arc::new(MemTable::try_new(schema, vec![vec![batch]]).unwrap()),
788 )
789 .unwrap();
790 }
791
792 const UNSET: &str = "STATUS_CODE_UNSET";
793 const ERROR: &str = "STATUS_CODE_ERROR";
794 const CLIENT: &str = "SPAN_KIND_CLIENT";
795 const SERVER: &str = "SPAN_KIND_SERVER";
796
797 const BASE_SPANS: [Span<'static>; 7] = [
800 (1_000, "t1", "c1", None, CLIENT, UNSET, "frontend", 0),
801 (
802 1_010,
803 "t1",
804 "s1",
805 Some("c1"),
806 SERVER,
807 UNSET,
808 "cart",
809 500_000_000,
810 ),
811 (2_000, "t2", "c2", None, CLIENT, UNSET, "frontend", 0),
812 (
813 2_010,
814 "t2",
815 "s2",
816 Some("c2"),
817 SERVER,
818 ERROR,
819 "cart",
820 1_500_000_000,
821 ),
822 (3_000, "t3", "c3", None, CLIENT, UNSET, "cart", 0),
823 (3_010, "t3", "s3", Some("c3"), SERVER, UNSET, "cart", 100),
824 (4_000, "t4", "c4", None, CLIENT, UNSET, "frontend", 0),
825 ];
826
827 fn trace_table_ctx() -> SessionContext {
828 let ctx = SessionContext::new();
829 let namespaces = [Some("ns1"); 7];
830 register_trace_table(
831 &ctx,
832 "opentelemetry_traces",
833 &[("service_namespace", &namespaces)],
834 &BASE_SPANS,
835 );
836 ctx
837 }
838
839 fn trace_service_decl(id_columns: &[&str]) -> EntityDeclaration {
840 EntityDeclaration {
841 schema: "public".to_string(),
842 table: "opentelemetry_traces".to_string(),
843 time_index: TRACE_TIMESTAMP_COLUMN.to_string(),
844 entity_type: "service".to_string(),
845 id_columns: id_columns.iter().map(|s| s.to_string()).collect(),
846 descriptive_columns: vec![],
847 scope_columns: vec![],
848 }
849 }
850
851 #[tokio::test]
852 async fn calls_plan_aggregates_red_metrics() {
853 let ctx = trace_table_ctx();
854 let trace = ctx.table("opentelemetry_traces").await.unwrap();
855 let plan = build_relationships_plan_for_test(
856 vec![CallsSource {
857 service: Some(trace_service_decl(&[SERVICE_NAME_COLUMN])),
858 agent: None,
859 scan: trace,
860 }],
861 &test_window(),
862 )
863 .unwrap()
864 .unwrap();
865
866 let names = plan
867 .schema()
868 .fields()
869 .iter()
870 .map(|f| f.name().as_str())
871 .collect::<Vec<_>>();
872 assert_eq!(names, RELATIONSHIP_COLUMNS);
873
874 let batches = collect(&ctx, plan).await;
875 let total: usize = batches.iter().map(|b| b.num_rows()).sum();
876 assert_eq!(total, 1);
879 let batch = &batches[0];
880 assert_eq!(strings(batch, 5), vec!["frontend"]);
881 assert_eq!(strings(batch, 7), vec!["cart"]);
882 assert_eq!(strings(batch, 8), vec!["calls"]);
883 assert_eq!(strings(batch, 9), vec!["trace"]);
884
885 let request_count = batch
886 .column(11)
887 .as_any()
888 .downcast_ref::<Int64Array>()
889 .unwrap();
890 assert_eq!(request_count.value(0), 2);
891 let error_count = batch
892 .column(12)
893 .as_any()
894 .downcast_ref::<Int64Array>()
895 .unwrap();
896 assert_eq!(error_count.value(0), 1);
897 let duration_sum = batch
898 .column(13)
899 .as_any()
900 .downcast_ref::<Float64Array>()
901 .unwrap();
902 assert!((duration_sum.value(0) - 2.0).abs() < 1e-9);
903 let duration_count = batch
904 .column(14)
905 .as_any()
906 .downcast_ref::<Int64Array>()
907 .unwrap();
908 assert_eq!(duration_count.value(0), 2);
909 assert!(json_texts(batch, 15)[0].is_none());
911 }
912
913 fn red_metrics(batch: &RecordBatch, row: usize) -> (i64, i64, f64, i64) {
916 let i64_at = |column: usize| {
917 batch
918 .column(column)
919 .as_any()
920 .downcast_ref::<Int64Array>()
921 .unwrap()
922 .value(row)
923 };
924 let duration_sum = batch
925 .column(13)
926 .as_any()
927 .downcast_ref::<Float64Array>()
928 .unwrap()
929 .value(row);
930 (i64_at(11), i64_at(12), duration_sum, i64_at(14))
931 }
932
933 fn confidence(batch: &RecordBatch, row: usize) -> f64 {
934 batch
935 .column(10)
936 .as_any()
937 .downcast_ref::<Float64Array>()
938 .unwrap()
939 .value(row)
940 }
941
942 fn sources(scans: &[DataFrame]) -> Vec<CallsSource> {
943 scans
944 .iter()
945 .map(|scan| CallsSource {
946 service: Some(trace_service_decl(&[SERVICE_NAME_COLUMN])),
947 agent: None,
948 scan: scan.clone(),
949 })
950 .collect()
951 }
952
953 #[tokio::test]
954 async fn calls_plan_merges_edges_across_trace_tables() {
955 let ctx = SessionContext::new();
956 register_trace_table(
958 &ctx,
959 "trace_a",
960 &[],
961 &[
962 (1_000, "t1", "c1", None, CLIENT, UNSET, "frontend", 0),
963 (
964 1_010,
965 "t1",
966 "s1",
967 Some("c1"),
968 SERVER,
969 UNSET,
970 "cart",
971 500_000_000,
972 ),
973 ],
974 );
975 register_trace_table(
976 &ctx,
977 "trace_b",
978 &[],
979 &[
980 (2_000, "t2", "c2", None, CLIENT, UNSET, "frontend", 0),
981 (
982 2_010,
983 "t2",
984 "s2",
985 Some("c2"),
986 SERVER,
987 ERROR,
988 "cart",
989 1_500_000_000,
990 ),
991 ],
992 );
993 let a = ctx.table("trace_a").await.unwrap();
994 let b = ctx.table("trace_b").await.unwrap();
995 let plan = build_relationships_plan_for_test(sources(&[a, b]), &test_window())
996 .unwrap()
997 .unwrap();
998
999 let batches = collect(&ctx, plan).await;
1000 let total: usize = batches.iter().map(|b| b.num_rows()).sum();
1001 assert_eq!(total, 1);
1002 assert_eq!(red_metrics(&batches[0], 0), (2, 1, 2.0, 2));
1003 }
1004
1005 #[tokio::test]
1006 async fn calls_pair_split_across_trace_tables() {
1007 let ctx = SessionContext::new();
1008 register_trace_table(
1011 &ctx,
1012 "trace_a",
1013 &[],
1014 &[(1_000, "t1", "c1", None, CLIENT, UNSET, "frontend", 0)],
1015 );
1016 register_trace_table(
1017 &ctx,
1018 "trace_b",
1019 &[],
1020 &[(
1021 1_010,
1022 "t1",
1023 "s1",
1024 Some("c1"),
1025 SERVER,
1026 UNSET,
1027 "cart",
1028 500_000_000,
1029 )],
1030 );
1031 let a = ctx.table("trace_a").await.unwrap();
1032 let b = ctx.table("trace_b").await.unwrap();
1033 let plan = build_relationships_plan_for_test(sources(&[a, b]), &test_window())
1034 .unwrap()
1035 .unwrap();
1036
1037 let batches = collect(&ctx, plan).await;
1038 let total: usize = batches.iter().map(|b| b.num_rows()).sum();
1039 assert_eq!(total, 1);
1040 let batch = &batches[0];
1041 assert_eq!(strings(batch, 5), vec!["frontend"]);
1042 assert_eq!(strings(batch, 7), vec!["cart"]);
1043 assert_eq!(confidence(batch, 0), 1.0);
1044 assert_eq!(red_metrics(batch, 0), (1, 0, 0.5, 1));
1045 }
1046
1047 #[tokio::test]
1048 async fn virtual_node_edge_from_unmatched_client() {
1049 let ctx = SessionContext::new();
1050 register_trace_table(
1051 &ctx,
1052 "trace_a",
1053 &[(
1054 "span_attributes.peer.service",
1055 &[Some("redis"), None, Some("frontend")],
1056 )],
1057 &[
1058 (
1061 1_000,
1062 "t1",
1063 "c1",
1064 None,
1065 CLIENT,
1066 ERROR,
1067 "frontend",
1068 250_000_000,
1069 ),
1070 (2_000, "t2", "c2", None, CLIENT, UNSET, "frontend", 0),
1072 (3_000, "t3", "c3", None, CLIENT, UNSET, "frontend", 0),
1074 ],
1075 );
1076 let a = ctx.table("trace_a").await.unwrap();
1077 let plan = build_relationships_plan_for_test(sources(&[a]), &test_window())
1078 .unwrap()
1079 .unwrap();
1080
1081 let batches = collect(&ctx, plan).await;
1082 let total: usize = batches.iter().map(|b| b.num_rows()).sum();
1083 assert_eq!(total, 1);
1084 let batch = &batches[0];
1085 assert_eq!(strings(batch, 5), vec!["frontend"]);
1086 assert_eq!(strings(batch, 7), vec!["redis"]);
1087 assert_eq!(strings(batch, 8), vec!["calls"]);
1088 assert_eq!(strings(batch, 9), vec!["trace"]);
1089 assert_eq!(confidence(batch, 0), VIRTUAL_NODE_CONFIDENCE);
1090 assert_eq!(red_metrics(batch, 0), (1, 1, 0.25, 1));
1091 assert_eq!(
1092 json_texts(batch, 15),
1093 vec![Some(r#"{"connection_type":"virtual_node"}"#.to_string())]
1094 );
1095 }
1096
1097 #[tokio::test]
1098 async fn virtual_endpoint_fallback_and_connection_type() {
1099 let ctx = SessionContext::new();
1100 register_trace_table(
1101 &ctx,
1102 "trace_a",
1103 &[
1104 ("span_attributes.service.peer.name", &[Some("")]),
1107 ("span_attributes.db.namespace", &[Some("mysql")]),
1108 ],
1109 &[(1_000, "t1", "c1", None, CLIENT, UNSET, "frontend", 100)],
1110 );
1111 let a = ctx.table("trace_a").await.unwrap();
1112 let plan = build_relationships_plan_for_test(sources(&[a]), &test_window())
1113 .unwrap()
1114 .unwrap();
1115
1116 let batches = collect(&ctx, plan).await;
1117 let batch = &batches[0];
1118 assert_eq!(strings(batch, 7), vec!["mysql"]);
1119 assert_eq!(
1120 json_texts(batch, 15),
1121 vec![Some(r#"{"connection_type":"database"}"#.to_string())]
1122 );
1123 }
1124
1125 #[tokio::test]
1126 async fn agent_calls_from_parent_child_spans() {
1127 let ctx = SessionContext::new();
1128 const INTERNAL: &str = "SPAN_KIND_INTERNAL";
1129 register_trace_table(
1130 &ctx,
1131 "agent_traces",
1132 &[(
1133 "agent_id",
1134 &[
1135 Some("orchestrator"),
1136 Some("researcher"),
1137 Some("researcher"),
1138 Some("researcher"),
1139 ],
1140 )],
1141 &[
1142 (1_000, "t1", "p1", None, INTERNAL, UNSET, "app", 0),
1145 (
1146 1_010,
1147 "t1",
1148 "a1",
1149 Some("p1"),
1150 INTERNAL,
1151 ERROR,
1152 "app",
1153 2_000_000_000,
1154 ),
1155 (
1156 2_000,
1157 "t1",
1158 "a2",
1159 Some("p1"),
1160 INTERNAL,
1161 UNSET,
1162 "app",
1163 1_000_000_000,
1164 ),
1165 (1_020, "t1", "a3", Some("a1"), INTERNAL, UNSET, "app", 100),
1166 ],
1167 );
1168 let scan = ctx.table("agent_traces").await.unwrap();
1169 let plan = build_relationships_plan(
1170 RelationshipSources {
1171 traces: vec![CallsSource {
1172 service: None,
1173 agent: Some(co_decl("gen_ai.agent", &["agent_id"])),
1174 scan,
1175 }],
1176 co_declared: vec![],
1177 declared: None,
1178 },
1179 &test_window(),
1180 )
1181 .unwrap()
1182 .unwrap();
1183
1184 let batches = collect(&ctx, plan).await;
1185 let total: usize = batches.iter().map(|b| b.num_rows()).sum();
1186 assert_eq!(total, 1);
1189 let batch = &batches[0];
1190 assert_eq!(strings(batch, 4), vec!["gen_ai.agent"]);
1191 assert_eq!(strings(batch, 5), vec!["orchestrator"]);
1192 assert_eq!(strings(batch, 6), vec!["gen_ai.agent"]);
1193 assert_eq!(strings(batch, 7), vec!["researcher"]);
1194 assert_eq!(strings(batch, 8), vec!["calls"]);
1195 assert_eq!(strings(batch, 9), vec!["trace"]);
1196 assert_eq!(confidence(batch, 0), 1.0);
1197 assert_eq!(red_metrics(batch, 0), (2, 1, 3.0, 2));
1198 }
1199
1200 #[tokio::test]
1201 async fn agent_calls_anchor_on_the_parent_span() {
1202 let ctx = SessionContext::new();
1203 const INTERNAL: &str = "SPAN_KIND_INTERNAL";
1204 register_trace_table(
1207 &ctx,
1208 "agent_traces",
1209 &[("agent_id", &[Some("orchestrator"), Some("researcher")])],
1210 &[
1211 (599_000, "t1", "p1", None, INTERNAL, UNSET, "app", 0),
1212 (
1213 601_000,
1214 "t1",
1215 "a1",
1216 Some("p1"),
1217 INTERNAL,
1218 UNSET,
1219 "app",
1220 500_000_000,
1221 ),
1222 ],
1223 );
1224 let scan = ctx.table("agent_traces").await.unwrap();
1225 let plan = build_relationships_plan(
1226 RelationshipSources {
1227 traces: vec![CallsSource {
1228 service: None,
1229 agent: Some(co_decl("gen_ai.agent", &["agent_id"])),
1230 scan,
1231 }],
1232 co_declared: vec![],
1233 declared: None,
1234 },
1235 &test_window(),
1236 )
1237 .unwrap()
1238 .unwrap();
1239
1240 let batches = collect(&ctx, plan).await;
1241 let total: usize = batches.iter().map(|b| b.num_rows()).sum();
1242 assert_eq!(total, 1);
1243 let batch = &batches[0];
1244 assert_eq!(ts_values(batch, 0), vec![540_000]);
1246 assert_eq!(red_metrics(batch, 0), (1, 0, 0.5, 1));
1247 }
1248
1249 fn co_declared_ctx() -> SessionContext {
1253 let schema = Arc::new(Schema::new(vec![
1254 Field::new(
1255 "ts",
1256 DataType::Timestamp(TimeUnit::Millisecond, None),
1257 false,
1258 ),
1259 Field::new("instance", DataType::Utf8, true),
1260 Field::new("host", DataType::Utf8, false),
1261 Field::new("service", DataType::Utf8, false),
1262 Field::new("agent_id", DataType::Utf8, false),
1263 Field::new("model_name", DataType::Utf8, false),
1264 Field::new("tool_name", DataType::Utf8, false),
1265 ]));
1266 let batch = RecordBatch::try_new(
1267 schema.clone(),
1268 vec![
1269 Arc::new(TimestampMillisecondArray::from(vec![1_000, 2_000, 3_000])) as ArrayRef,
1270 Arc::new(StringArray::from(vec![Some("i-1"), Some("i-1"), None])),
1271 Arc::new(StringArray::from(vec!["h-1", "h-1", "h-2"])),
1272 Arc::new(StringArray::from(vec!["svc", "svc", "svc"])),
1273 Arc::new(StringArray::from(vec!["agent-1"; 3])),
1274 Arc::new(StringArray::from(vec!["gpt"; 3])),
1275 Arc::new(StringArray::from(vec!["search"; 3])),
1276 ],
1277 )
1278 .unwrap();
1279 let ctx = SessionContext::new();
1280 for name in ["co_metrics", "co_metrics_2"] {
1281 ctx.register_table(
1282 name,
1283 Arc::new(MemTable::try_new(schema.clone(), vec![vec![batch.clone()]]).unwrap()),
1284 )
1285 .unwrap();
1286 }
1287 ctx
1288 }
1289
1290 fn co_decl(entity_type: &str, id_columns: &[&str]) -> EntityDeclaration {
1291 EntityDeclaration {
1292 schema: "public".to_string(),
1293 table: "co_metrics".to_string(),
1294 time_index: "ts".to_string(),
1295 entity_type: entity_type.to_string(),
1296 id_columns: id_columns.iter().map(|s| s.to_string()).collect(),
1297 descriptive_columns: vec![],
1298 scope_columns: vec![],
1299 }
1300 }
1301
1302 async fn co_declared_edges(
1303 ctx: &SessionContext,
1304 declarations: Vec<EntityDeclaration>,
1305 is_trace: bool,
1306 ) -> Option<Vec<(String, String, String, String, String, String)>> {
1307 let scan = ctx.table("co_metrics").await.unwrap();
1308 let plan = build_relationships_plan(
1309 RelationshipSources {
1310 traces: vec![],
1311 co_declared: vec![CoDeclaredSource {
1312 declarations,
1313 is_trace,
1314 scan,
1315 }],
1316 declared: None,
1317 },
1318 &test_window(),
1319 )
1320 .unwrap()?;
1321 assert_eq!(
1322 plan.schema()
1323 .fields()
1324 .iter()
1325 .map(|f| f.name().as_str())
1326 .collect::<Vec<_>>(),
1327 RELATIONSHIP_COLUMNS
1328 );
1329 let batches = collect(ctx, plan).await;
1330 let mut rows = vec![];
1331 for batch in &batches {
1332 for i in 0..batch.num_rows() {
1333 rows.push((
1334 strings(batch, 4)[i].clone(),
1335 strings(batch, 5)[i].clone(),
1336 strings(batch, 6)[i].clone(),
1337 strings(batch, 7)[i].clone(),
1338 strings(batch, 8)[i].clone(),
1339 strings(batch, 9)[i].clone(),
1340 ));
1341 assert_eq!(confidence(batch, i), 1.0);
1342 for column in 11..=15 {
1344 assert!(batch.column(column).is_null(i));
1345 }
1346 }
1347 }
1348 rows.sort();
1349 Some(rows)
1350 }
1351
1352 #[tokio::test]
1353 async fn co_declared_direction_follows_vocabulary() {
1354 let ctx = co_declared_ctx();
1355 let rows = co_declared_edges(
1357 &ctx,
1358 vec![
1359 co_decl("host", &["host"]),
1360 co_decl("service", &["service"]),
1361 co_decl("service.instance", &["instance"]),
1362 ],
1363 false,
1364 )
1365 .await
1366 .unwrap();
1367 assert_eq!(
1370 rows,
1371 vec![
1372 (
1373 "service.instance".to_string(),
1374 "i-1".to_string(),
1375 "host".to_string(),
1376 "h-1".to_string(),
1377 "runs_on".to_string(),
1378 "attribute".to_string(),
1379 ),
1380 (
1381 "service.instance".to_string(),
1382 "i-1".to_string(),
1383 "service".to_string(),
1384 "svc".to_string(),
1385 "part_of".to_string(),
1386 "attribute".to_string(),
1387 ),
1388 ]
1389 );
1390 }
1391
1392 #[tokio::test]
1393 async fn co_declared_edge_folds_across_sources() {
1394 let ctx = co_declared_ctx();
1395 let declarations = || {
1396 vec![
1397 co_decl("service.instance", &["instance"]),
1398 co_decl("host", &["host"]),
1399 ]
1400 };
1401 let source = |scan| CoDeclaredSource {
1402 declarations: declarations(),
1403 is_trace: false,
1404 scan,
1405 };
1406 let a = ctx.table("co_metrics").await.unwrap();
1407 let b = ctx.table("co_metrics_2").await.unwrap();
1408 let plan = build_relationships_plan(
1409 RelationshipSources {
1410 traces: vec![],
1411 co_declared: vec![source(a), source(b)],
1412 declared: None,
1413 },
1414 &test_window(),
1415 )
1416 .unwrap()
1417 .unwrap();
1418
1419 let batches = collect(&ctx, plan).await;
1420 let total: usize = batches.iter().map(|b| b.num_rows()).sum();
1421 assert_eq!(total, 1);
1424 }
1425
1426 #[tokio::test]
1427 async fn co_declared_ignores_types_outside_the_vocabulary() {
1428 let ctx = co_declared_ctx();
1429 let rows = co_declared_edges(
1431 &ctx,
1432 vec![co_decl("service", &["service"]), co_decl("host", &["host"])],
1433 false,
1434 )
1435 .await;
1436 assert!(rows.is_none());
1437 }
1438
1439 #[tokio::test]
1440 async fn agent_edges_require_a_trace_source() {
1441 let ctx = co_declared_ctx();
1442 let declarations = || {
1443 vec![
1444 co_decl("gen_ai.agent", &["agent_id"]),
1445 co_decl("gen_ai.model", &["model_name"]),
1446 co_decl("gen_ai.tool", &["tool_name"]),
1447 ]
1448 };
1449 assert!(
1452 co_declared_edges(&ctx, declarations(), false)
1453 .await
1454 .is_none()
1455 );
1456
1457 let rows = co_declared_edges(&ctx, declarations(), true).await.unwrap();
1458 assert_eq!(
1459 rows,
1460 vec![
1461 (
1462 "gen_ai.agent".to_string(),
1463 "agent-1".to_string(),
1464 "gen_ai.model".to_string(),
1465 "gpt".to_string(),
1466 "uses".to_string(),
1467 "trace".to_string(),
1468 ),
1469 (
1470 "gen_ai.agent".to_string(),
1471 "agent-1".to_string(),
1472 "gen_ai.tool".to_string(),
1473 "search".to_string(),
1474 "invokes".to_string(),
1475 "trace".to_string(),
1476 ),
1477 ]
1478 );
1479 }
1480
1481 #[tokio::test]
1482 async fn real_pair_suppresses_virtual_edge() {
1483 let ctx = SessionContext::new();
1484 register_trace_table(
1485 &ctx,
1486 "trace_a",
1487 &[(
1488 "span_attributes.peer.service",
1489 &[Some("cart"), None, Some("cart")],
1490 )],
1491 &[
1492 (1_000, "t1", "c1", None, CLIENT, ERROR, "frontend", 999),
1495 (
1496 2_010,
1497 "t2",
1498 "s2",
1499 Some("c2"),
1500 SERVER,
1501 UNSET,
1502 "cart",
1503 500_000_000,
1504 ),
1505 (2_000, "t2", "c2", None, CLIENT, UNSET, "frontend", 0),
1506 ],
1507 );
1508 let a = ctx.table("trace_a").await.unwrap();
1509 let plan = build_relationships_plan_for_test(sources(&[a]), &test_window())
1510 .unwrap()
1511 .unwrap();
1512
1513 let batches = collect(&ctx, plan).await;
1514 let total: usize = batches.iter().map(|b| b.num_rows()).sum();
1515 assert_eq!(total, 1);
1518 let batch = &batches[0];
1519 assert_eq!(strings(batch, 7), vec!["cart"]);
1520 assert_eq!(confidence(batch, 0), 1.0);
1521 assert_eq!(red_metrics(batch, 0), (1, 0, 0.5, 1));
1522 assert!(json_texts(batch, 15)[0].is_none());
1523 }
1524
1525 #[tokio::test]
1526 async fn calls_endpoints_follow_service_declaration() {
1527 let ctx = trace_table_ctx();
1528 let trace = ctx.table("opentelemetry_traces").await.unwrap();
1529 let plan = build_relationships_plan_for_test(
1530 vec![CallsSource {
1531 service: Some(trace_service_decl(&[
1532 SERVICE_NAME_COLUMN,
1533 "service_namespace",
1534 ])),
1535 agent: None,
1536 scan: trace,
1537 }],
1538 &test_window(),
1539 )
1540 .unwrap()
1541 .unwrap();
1542
1543 let batches = collect(&ctx, plan).await;
1544 let total: usize = batches.iter().map(|b| b.num_rows()).sum();
1545 assert_eq!(total, 1);
1546 let batch = &batches[0];
1547 assert_eq!(
1550 strings(batch, 5),
1551 vec!["service_name=frontend,service_namespace=ns1"]
1552 );
1553 assert_eq!(
1554 strings(batch, 7),
1555 vec!["service_name=cart,service_namespace=ns1"]
1556 );
1557 }
1558
1559 fn build_relationships_plan_for_test(
1560 traces: Vec<CallsSource>,
1561 window: &GraphQueryWindow,
1562 ) -> DfResult<Option<LogicalPlan>> {
1563 build_relationships_plan(
1564 RelationshipSources {
1565 traces,
1566 co_declared: vec![],
1567 declared: None,
1568 },
1569 window,
1570 )
1571 }
1572
1573 fn declared_table(ctx: &SessionContext) {
1581 let ts_field = |name: &str| {
1582 Field::new(
1583 name,
1584 DataType::Timestamp(TimeUnit::Millisecond, None),
1585 name != OBSERVED_AT_COLUMN,
1586 )
1587 };
1588 let schema = Arc::new(Schema::new(vec![
1589 ts_field(OBSERVED_AT_COLUMN),
1590 ts_field("window_start"),
1591 ts_field("window_end"),
1592 ts_field("fresh_until"),
1593 ts_field("valid_from"),
1594 ts_field("valid_until"),
1595 Field::new("src_type", DataType::Utf8, false),
1596 Field::new("src_id", DataType::Utf8, false),
1597 Field::new("rel_type", DataType::Utf8, false),
1598 Field::new("dst_type", DataType::Utf8, false),
1599 Field::new("dst_id", DataType::Utf8, false),
1600 Field::new("provenance", DataType::Utf8, false),
1601 Field::new("scope", DataType::Utf8, false),
1602 Field::new("generation_id", DataType::Utf8, false),
1603 Field::new("confidence", DataType::Float64, true),
1604 Field::new("request_count", DataType::Int64, true),
1605 Field::new("error_count", DataType::Int64, true),
1606 Field::new("duration_sum", DataType::Float64, true),
1607 Field::new("duration_count", DataType::Int64, true),
1608 Field::new("attributes", DataType::Binary, true),
1609 ]));
1610 let attrs = jsonb::parse_value(br#"{"connection_type":"virtual_node"}"#)
1611 .unwrap()
1612 .to_vec();
1613 let no_ts = || Arc::new(TimestampMillisecondArray::from(vec![None::<i64>; 9])) as ArrayRef;
1614 let batch = RecordBatch::try_new(
1615 schema.clone(),
1616 vec![
1617 Arc::new(TimestampMillisecondArray::from(vec![
1618 100_000, 200_000, 50_000, 90_000, 100_000, 900_000, 100_000, 100_000, 100_000,
1619 ])) as ArrayRef,
1620 no_ts(), no_ts(), no_ts(), Arc::new(TimestampMillisecondArray::from(vec![
1624 None,
1625 None,
1626 None,
1627 None,
1628 None,
1629 None,
1630 Some(700_000),
1631 None,
1632 None,
1633 ])),
1634 Arc::new(TimestampMillisecondArray::from(vec![
1635 None,
1636 Some(300_000),
1637 Some(100_000),
1638 None,
1639 None,
1640 None,
1641 None,
1642 None,
1643 None,
1644 ])),
1645 Arc::new(StringArray::from(vec![
1646 "service", "service", "service", "agent", "service", "service", "service",
1647 "service", "service",
1648 ])),
1649 Arc::new(StringArray::from(vec![
1650 "frontend", "frontend", "api", "agent-1", "webapp", "webapp", "batch",
1651 "gateway", "gateway",
1652 ])),
1653 Arc::new(StringArray::from(vec![
1654 "depends_on",
1655 "depends_on",
1656 "depends_on",
1657 "uses",
1658 "depends_on",
1659 "depends_on",
1660 "depends_on",
1661 "depends_on",
1662 "depends_on",
1663 ])),
1664 Arc::new(StringArray::from(vec![
1665 "service", "service", "service", "tool", "service", "service", "service",
1666 "service", "service",
1667 ])),
1668 Arc::new(StringArray::from(vec![
1669 "db", "db", "cache", "search", "auth", "auth", "queue", "redis", "redis",
1670 ])),
1671 Arc::new(StringArray::from(vec![
1672 "declared", "declared", "declared", "agent", "declared", "declared",
1673 "declared", "declared", "declared",
1674 ])),
1675 Arc::new(StringArray::from(vec![""; 9])),
1676 Arc::new(StringArray::from(vec![
1677 "", "", "", "", "", "", "", "g1", "g2",
1678 ])),
1679 Arc::new(Float64Array::from(vec![
1680 Some(0.5),
1681 Some(1.0),
1682 Some(1.0),
1683 Some(0.8),
1684 Some(0.5),
1685 Some(1.0),
1686 Some(1.0),
1687 Some(0.5),
1688 Some(1.0),
1689 ])),
1690 Arc::new(Int64Array::from(vec![None::<i64>; 9])),
1691 Arc::new(Int64Array::from(vec![None::<i64>; 9])),
1692 Arc::new(Float64Array::from(vec![None::<f64>; 9])),
1693 Arc::new(Int64Array::from(vec![None::<i64>; 9])),
1694 Arc::new(BinaryArray::from(vec![
1695 None,
1696 None,
1697 None,
1698 Some(attrs.as_slice()),
1699 None,
1700 None,
1701 None,
1702 None,
1703 None,
1704 ])),
1705 ],
1706 )
1707 .unwrap();
1708 ctx.register_table(
1709 SEMANTIC_RELATIONSHIPS_DECLARED_TABLE_NAME,
1710 Arc::new(MemTable::try_new(schema, vec![vec![batch]]).unwrap()),
1711 )
1712 .unwrap();
1713 }
1714
1715 #[tokio::test]
1716 async fn declared_edges_latest_revision_validity_and_synthetic_time() {
1717 let ctx = SessionContext::new();
1718 declared_table(&ctx);
1719 let scan = ctx
1720 .table(SEMANTIC_RELATIONSHIPS_DECLARED_TABLE_NAME)
1721 .await
1722 .unwrap();
1723
1724 let window = GraphQueryWindow::from_observed(120_000, 600_000);
1726 let plan = build_relationships_plan(
1727 RelationshipSources {
1728 traces: vec![],
1729 co_declared: vec![],
1730 declared: Some(DeclaredSource { scan }),
1731 },
1732 &window,
1733 )
1734 .unwrap()
1735 .unwrap();
1736 let names = plan
1737 .schema()
1738 .fields()
1739 .iter()
1740 .map(|f| f.name().as_str())
1741 .collect::<Vec<_>>();
1742 assert_eq!(names, RELATIONSHIP_COLUMNS);
1743
1744 type DeclaredRow = (String, i64, i64, i64, i64, String, Option<String>);
1747
1748 let batches = collect(&ctx, plan).await;
1749 let mut rows: Vec<DeclaredRow> = vec![];
1750 for batch in &batches {
1751 let observed = ts_values(batch, 0);
1752 let window_start = ts_values(batch, 1);
1753 let window_end = ts_values(batch, 2);
1754 let fresh_until = ts_values(batch, 3);
1755 let src = strings(batch, 5);
1756 let provenance = strings(batch, 9);
1757 let attributes = json_texts(batch, 15);
1758 for i in 0..batch.num_rows() {
1759 rows.push((
1760 src[i].clone(),
1761 observed[i],
1762 window_start[i],
1763 window_end[i],
1764 fresh_until[i],
1765 provenance[i].clone(),
1766 attributes[i].clone(),
1767 ));
1768 }
1769 }
1770 rows.sort();
1771
1772 assert_eq!(rows.len(), 4);
1777
1778 assert_eq!(
1782 rows[0],
1783 (
1784 "agent-1".to_string(),
1785 120_000,
1786 90_000,
1787 600_000,
1788 600_000,
1789 "agent".to_string(),
1790 Some(r#"{"connection_type":"virtual_node"}"#.to_string()),
1791 )
1792 );
1793
1794 assert_eq!(
1797 rows[1],
1798 (
1799 "frontend".to_string(),
1800 200_000,
1801 200_000,
1802 300_000,
1803 300_000,
1804 "declared".to_string(),
1805 None,
1806 )
1807 );
1808
1809 assert_eq!(
1810 rows[2],
1811 (
1812 "gateway".to_string(),
1813 120_000,
1814 100_000,
1815 600_000,
1816 600_000,
1817 "declared".to_string(),
1818 None,
1819 )
1820 );
1821
1822 assert_eq!(
1823 rows[3],
1824 (
1825 "webapp".to_string(),
1826 120_000,
1827 100_000,
1828 600_000,
1829 600_000,
1830 "declared".to_string(),
1831 None,
1832 )
1833 );
1834 }
1835
1836 #[tokio::test]
1837 async fn declared_edges_pick_the_revision_as_of_the_window() {
1838 let ctx = SessionContext::new();
1839 declared_table(&ctx);
1840 let scan = ctx
1841 .table(SEMANTIC_RELATIONSHIPS_DECLARED_TABLE_NAME)
1842 .await
1843 .unwrap();
1844
1845 let window = GraphQueryWindow::from_observed(0, 150_000);
1849 let plan = build_relationships_plan(
1850 RelationshipSources {
1851 traces: vec![],
1852 co_declared: vec![],
1853 declared: Some(DeclaredSource { scan }),
1854 },
1855 &window,
1856 )
1857 .unwrap()
1858 .unwrap();
1859
1860 let batches = collect(&ctx, plan).await;
1861 let mut rows: Vec<(String, f64)> = vec![];
1862 for batch in &batches {
1863 let src = strings(batch, 5);
1864 let confidence = batch
1865 .column(10)
1866 .as_any()
1867 .downcast_ref::<Float64Array>()
1868 .unwrap();
1869 for (i, src) in src.into_iter().enumerate() {
1870 rows.push((src, confidence.value(i)));
1871 }
1872 }
1873 rows.sort_by(|a, b| a.0.cmp(&b.0));
1874
1875 assert_eq!(
1877 rows,
1878 vec![
1879 ("agent-1".to_string(), 0.8),
1880 ("api".to_string(), 1.0),
1881 ("frontend".to_string(), 0.5),
1882 ("gateway".to_string(), 1.0),
1883 ("webapp".to_string(), 0.5),
1884 ]
1885 );
1886 }
1887
1888 #[tokio::test]
1889 async fn declared_edges_union_calls_branch() {
1890 let ctx = trace_table_ctx();
1891 declared_table(&ctx);
1892 let trace = ctx.table("opentelemetry_traces").await.unwrap();
1893 let declared = ctx
1894 .table(SEMANTIC_RELATIONSHIPS_DECLARED_TABLE_NAME)
1895 .await
1896 .unwrap();
1897
1898 let plan = build_relationships_plan(
1899 RelationshipSources {
1900 traces: vec![CallsSource {
1901 service: Some(trace_service_decl(&[SERVICE_NAME_COLUMN])),
1902 agent: None,
1903 scan: trace,
1904 }],
1905 co_declared: vec![],
1906 declared: Some(DeclaredSource { scan: declared }),
1907 },
1908 &test_window(),
1909 )
1910 .unwrap()
1911 .unwrap();
1912
1913 let batches = collect(&ctx, plan).await;
1914 let mut provenance: Vec<String> = batches.iter().flat_map(|b| strings(b, 9)).collect();
1915 provenance.sort();
1916 assert_eq!(
1920 provenance,
1921 vec![
1922 "agent", "declared", "declared", "declared", "declared", "trace"
1923 ]
1924 );
1925 }
1926}