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