Skip to content

Commit 6c43998

Browse files
feat(consumers): ensure that gen_ai spans get the gen_ai.agent.name from the ancestor span (#102955)
1 parent f8b6ef1 commit 6c43998

File tree

2 files changed

+251
-4
lines changed

2 files changed

+251
-4
lines changed

src/sentry/spans/consumers/process_segments/enrichment.py

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from collections import defaultdict
2-
from collections.abc import Sequence
2+
from collections.abc import Iterator, Sequence
33
from typing import Any
44

55
from sentry_kafka_schemas.schema_types.ingest_spans_v1 import SpanEvent
@@ -75,11 +75,14 @@ def __init__(self, spans: list[SpanEvent]) -> None:
7575
self._ttid_ts = _timestamp_by_op(spans, "ui.load.initial_display")
7676
self._ttfd_ts = _timestamp_by_op(spans, "ui.load.full_display")
7777

78-
self._span_map: dict[str, list[tuple[int, int]]] = {}
78+
self._span_intervals: dict[str, list[tuple[int, int]]] = {}
79+
self._spans_by_id: dict[str, SpanEvent] = {}
7980
for span in spans:
81+
if "span_id" in span:
82+
self._spans_by_id[span["span_id"]] = span
8083
if parent_span_id := span.get("parent_span_id"):
8184
interval = _span_interval(span)
82-
self._span_map.setdefault(parent_span_id, []).append(interval)
85+
self._span_intervals.setdefault(parent_span_id, []).append(interval)
8386

8487
def _attributes(self, span: SpanEvent) -> dict[str, Any]:
8588
attributes: dict[str, Any] = {**(span.get("attributes") or {})}
@@ -119,13 +122,45 @@ def get_value(key: str) -> Any:
119122
if attributes.get(key) is None:
120123
attributes[key] = value
121124

125+
if get_span_op(span).startswith("gen_ai.") and "gen_ai.agent.name" not in attributes:
126+
if (parent_span_id := span.get("parent_span_id")) is not None:
127+
parent_span = self._spans_by_id.get(parent_span_id)
128+
if (
129+
parent_span is not None
130+
and get_span_op(parent_span) == "gen_ai.invoke_agent"
131+
and (agent_name := attribute_value(parent_span, "gen_ai.agent.name"))
132+
is not None
133+
):
134+
attributes["gen_ai.agent.name"] = {
135+
"type": "string",
136+
"value": agent_name,
137+
}
138+
122139
attributes["sentry.exclusive_time_ms"] = {
123140
"type": "double",
124141
"value": self._exclusive_time(span),
125142
}
126143

127144
return attributes
128145

146+
def _iter_ancestors(self, span: SpanEvent) -> Iterator[SpanEvent]:
147+
"""
148+
Iterates over the ancestors of a span in order towards the root using the "parent_span_id" attribute.
149+
"""
150+
current: SpanEvent | None = span
151+
parent_span_id: str | None = None
152+
153+
while current is not None:
154+
parent_span_id = current.get("parent_span_id")
155+
if parent_span_id is not None:
156+
current = self._spans_by_id.get(parent_span_id)
157+
else:
158+
current = None
159+
if current is not None:
160+
yield current
161+
else:
162+
break
163+
129164
def _exclusive_time(self, span: SpanEvent) -> float:
130165
"""
131166
Sets the exclusive time on all spans in the list.
@@ -134,7 +169,7 @@ def _exclusive_time(self, span: SpanEvent) -> float:
134169
of all time intervals where no child span was active.
135170
"""
136171

137-
intervals = self._span_map.get(span["span_id"], [])
172+
intervals = self._span_intervals.get(span["span_id"], [])
138173
# Sort by start ASC, end DESC to skip over nested intervals efficiently
139174
intervals.sort(key=lambda x: (x[0], -x[1]))
140175

tests/sentry/spans/consumers/process_segments/test_enrichment.py

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,3 +471,215 @@ def _mock_performance_issue_span(is_segment, attributes, **fields) -> SpanEvent:
471471
**fields,
472472
},
473473
)
474+
475+
476+
def test_enrich_gen_ai_agent_name_from_immediate_parent() -> None:
477+
"""Test that gen_ai.agent.name is inherited from the immediate parent with gen_ai.invoke_agent operation."""
478+
parent_span = build_mock_span(
479+
project_id=1,
480+
is_segment=True,
481+
span_id="aaaaaaaaaaaaaaaa",
482+
start_timestamp=1609455600.0,
483+
end_timestamp=1609455605.0,
484+
span_op="gen_ai.invoke_agent",
485+
attributes={
486+
"gen_ai.agent.name": {"type": "string", "value": "MyAgent"},
487+
},
488+
)
489+
490+
child_span = build_mock_span(
491+
project_id=1,
492+
span_id="bbbbbbbbbbbbbbbb",
493+
parent_span_id="aaaaaaaaaaaaaaaa",
494+
start_timestamp=1609455601.0,
495+
end_timestamp=1609455602.0,
496+
span_op="gen_ai.execute_tool",
497+
)
498+
499+
spans = [parent_span, child_span]
500+
_, enriched_spans = TreeEnricher.enrich_spans(spans)
501+
compatible_spans = [make_compatible(span) for span in enriched_spans]
502+
503+
parent, child = compatible_spans
504+
assert attribute_value(parent, "gen_ai.agent.name") == "MyAgent"
505+
assert attribute_value(child, "gen_ai.agent.name") == "MyAgent"
506+
507+
508+
def test_enrich_gen_ai_agent_name_not_overwritten() -> None:
509+
"""Test that gen_ai.agent.name is not overwritten if already set on the child."""
510+
parent_span = build_mock_span(
511+
project_id=1,
512+
is_segment=True,
513+
span_id="aaaaaaaaaaaaaaaa",
514+
start_timestamp=1609455600.0,
515+
end_timestamp=1609455605.0,
516+
span_op="gen_ai.invoke_agent",
517+
attributes={
518+
"gen_ai.agent.name": {"type": "string", "value": "ParentAgent"},
519+
},
520+
)
521+
522+
child_span = build_mock_span(
523+
project_id=1,
524+
span_id="bbbbbbbbbbbbbbbb",
525+
parent_span_id="aaaaaaaaaaaaaaaa",
526+
start_timestamp=1609455601.0,
527+
end_timestamp=1609455602.0,
528+
span_op="gen_ai.handoff",
529+
attributes={
530+
"gen_ai.agent.name": {"type": "string", "value": "ChildAgent"},
531+
},
532+
)
533+
534+
spans = [parent_span, child_span]
535+
_, enriched_spans = TreeEnricher.enrich_spans(spans)
536+
compatible_spans = [make_compatible(span) for span in enriched_spans]
537+
538+
parent, child = compatible_spans
539+
assert attribute_value(parent, "gen_ai.agent.name") == "ParentAgent"
540+
assert attribute_value(child, "gen_ai.agent.name") == "ChildAgent"
541+
542+
543+
def test_enrich_gen_ai_agent_name_not_set_without_ancestor() -> None:
544+
"""Test that gen_ai.agent.name is not set if no ancestor has it."""
545+
parent_span = build_mock_span(
546+
project_id=1,
547+
is_segment=True,
548+
span_id="aaaaaaaaaaaaaaaa",
549+
start_timestamp=1609455600.0,
550+
end_timestamp=1609455605.0,
551+
span_op="some.operation",
552+
)
553+
554+
child_span = build_mock_span(
555+
project_id=1,
556+
span_id="bbbbbbbbbbbbbbbb",
557+
parent_span_id="aaaaaaaaaaaaaaaa",
558+
start_timestamp=1609455601.0,
559+
end_timestamp=1609455602.0,
560+
span_op="gen_ai.execute_tool",
561+
)
562+
563+
spans = [parent_span, child_span]
564+
_, enriched_spans = TreeEnricher.enrich_spans(spans)
565+
compatible_spans = [make_compatible(span) for span in enriched_spans]
566+
567+
parent, child = compatible_spans
568+
assert attribute_value(parent, "gen_ai.agent.name") is None
569+
assert attribute_value(child, "gen_ai.agent.name") is None
570+
571+
572+
def test_enrich_gen_ai_agent_name_not_from_sibling() -> None:
573+
"""Test that gen_ai.agent.name is not taken from a sibling span."""
574+
parent_span = build_mock_span(
575+
project_id=1,
576+
is_segment=True,
577+
span_id="aaaaaaaaaaaaaaaa",
578+
start_timestamp=1609455600.0,
579+
end_timestamp=1609455605.0,
580+
span_op="some.operation",
581+
)
582+
583+
sibling_with_agent = build_mock_span(
584+
project_id=1,
585+
span_id="bbbbbbbbbbbbbbbb",
586+
parent_span_id="aaaaaaaaaaaaaaaa",
587+
start_timestamp=1609455601.0,
588+
end_timestamp=1609455602.0,
589+
span_op="gen_ai.invoke_agent",
590+
attributes={
591+
"gen_ai.agent.name": {"type": "string", "value": "SiblingAgent"},
592+
},
593+
)
594+
595+
target_child = build_mock_span(
596+
project_id=1,
597+
span_id="cccccccccccccccc",
598+
parent_span_id="aaaaaaaaaaaaaaaa",
599+
start_timestamp=1609455602.5,
600+
end_timestamp=1609455603.5,
601+
span_op="gen_ai.execute_tool",
602+
)
603+
604+
spans = [parent_span, sibling_with_agent, target_child]
605+
_, enriched_spans = TreeEnricher.enrich_spans(spans)
606+
compatible_spans = [make_compatible(span) for span in enriched_spans]
607+
608+
parent, sibling, target = compatible_spans
609+
assert attribute_value(parent, "gen_ai.agent.name") is None
610+
assert attribute_value(sibling, "gen_ai.agent.name") == "SiblingAgent"
611+
assert attribute_value(target, "gen_ai.agent.name") is None
612+
613+
614+
def test_enrich_gen_ai_agent_name_only_from_invoke_agent_parent() -> None:
615+
"""Test that gen_ai.agent.name is only inherited from parent with gen_ai.invoke_agent operation."""
616+
parent_span = build_mock_span(
617+
project_id=1,
618+
is_segment=True,
619+
span_id="aaaaaaaaaaaaaaaa",
620+
start_timestamp=1609455600.0,
621+
end_timestamp=1609455605.0,
622+
span_op="gen_ai.create_agent",
623+
attributes={
624+
"gen_ai.agent.name": {"type": "string", "value": "CreateAgentName"},
625+
},
626+
)
627+
628+
child_span = build_mock_span(
629+
project_id=1,
630+
span_id="bbbbbbbbbbbbbbbb",
631+
parent_span_id="aaaaaaaaaaaaaaaa",
632+
start_timestamp=1609455601.0,
633+
end_timestamp=1609455602.0,
634+
span_op="gen_ai.run",
635+
)
636+
637+
spans = [parent_span, child_span]
638+
_, enriched_spans = TreeEnricher.enrich_spans(spans)
639+
compatible_spans = [make_compatible(span) for span in enriched_spans]
640+
641+
parent, child = compatible_spans
642+
assert attribute_value(parent, "gen_ai.agent.name") == "CreateAgentName"
643+
assert attribute_value(child, "gen_ai.agent.name") is None
644+
645+
646+
def test_enrich_gen_ai_agent_name_not_from_grandparent() -> None:
647+
"""Test that gen_ai.agent.name is NOT inherited from grandparent, only from immediate parent."""
648+
grandparent_span = build_mock_span(
649+
project_id=1,
650+
is_segment=True,
651+
span_id="aaaaaaaaaaaaaaaa",
652+
start_timestamp=1609455600.0,
653+
end_timestamp=1609455605.0,
654+
span_op="gen_ai.invoke_agent",
655+
attributes={
656+
"gen_ai.agent.name": {"type": "string", "value": "GrandparentAgent"},
657+
},
658+
)
659+
660+
parent_span = build_mock_span(
661+
project_id=1,
662+
span_id="bbbbbbbbbbbbbbbb",
663+
parent_span_id="aaaaaaaaaaaaaaaa",
664+
start_timestamp=1609455601.0,
665+
end_timestamp=1609455604.0,
666+
span_op="some.operation",
667+
)
668+
669+
child_span = build_mock_span(
670+
project_id=1,
671+
span_id="cccccccccccccccc",
672+
parent_span_id="bbbbbbbbbbbbbbbb",
673+
start_timestamp=1609455602.0,
674+
end_timestamp=1609455603.0,
675+
span_op="gen_ai.run",
676+
)
677+
678+
spans = [grandparent_span, parent_span, child_span]
679+
_, enriched_spans = TreeEnricher.enrich_spans(spans)
680+
compatible_spans = [make_compatible(span) for span in enriched_spans]
681+
682+
grandparent, parent, child = compatible_spans
683+
assert attribute_value(grandparent, "gen_ai.agent.name") == "GrandparentAgent"
684+
assert attribute_value(parent, "gen_ai.agent.name") is None
685+
assert attribute_value(child, "gen_ai.agent.name") is None

0 commit comments

Comments
 (0)