1515from __future__ import annotations
1616
1717from collections .abc import AsyncIterator
18+ from collections .abc import Iterator
1819from contextlib import asynccontextmanager
20+ from contextlib import contextmanager
1921from dataclasses import dataclass
2022from dataclasses import field
23+ import time
2124from typing import TYPE_CHECKING
2225
2326from opentelemetry import context as context_api
2427from opentelemetry .semconv ._incubating .attributes .gen_ai_attributes import GEN_AI_CONVERSATION_ID
2528from opentelemetry .semconv ._incubating .attributes .gen_ai_attributes import GEN_AI_OPERATION_NAME
26- from opentelemetry .util . types import Attributes
29+ from opentelemetry .trace import Span
2730
2831from ..agents .context import Context
2932from ..workflow ._base_node import BaseNode
3033from .tracing import tracer
3134
3235if TYPE_CHECKING :
36+ from ..agents .base_agent import BaseAgent
3337 from ..events .event import Event
3438 from ..workflow ._workflow import Workflow
3539
40+ # Span/metric attribute flagging whether an `invoke_workflow` span is the
41+ # entrypoint (first workflow) of its invocation.
42+ GEN_AI_WORKFLOW_IS_ENTRYPOINT = "gen_ai.workflow.is_entrypoint"
43+
44+ # OTel-context key recording that an entrypoint workflow is already active. It
45+ # rides along the otel_context propagated to child nodes, so only the first
46+ # workflow invoked within an invocation is marked as the entrypoint -- nested
47+ # workflows (incl. agents-as-tool that spin up their own runner) see the key
48+ # already set and report is_entrypoint=false.
49+ _ENTRYPOINT_WORKFLOW_KEY = context_api .create_key (
50+ "adk-entrypoint-workflow-active"
51+ )
52+
3653
3754@dataclass (frozen = True )
3855class TelemetryContext :
@@ -49,12 +66,6 @@ def add_event(self, event: Event) -> None:
4966 self ._associated_event_ids .append (event .id )
5067
5168
52- @dataclass
53- class _SpanMetadata :
54- name : str
55- attributes : Attributes
56-
57-
5869@asynccontextmanager
5970async def start_as_current_node_span (
6071 context : Context , node : BaseNode
@@ -83,64 +94,117 @@ async def start_as_current_node_span(
8394 Context with the started span.
8495 """
8596
86- span_metadata = _span_metadata (context , node )
87- if span_metadata is None :
88- token = context_api .attach (context .telemetry_context .otel_context )
89- try :
90- yield TelemetryContext (
91- otel_context = context .telemetry_context .otel_context
92- )
93- finally :
94- context_api .detach (token )
95- return
96-
97- with tracer .start_as_current_span (
98- span_metadata .name ,
99- attributes = span_metadata .attributes ,
100- context = context .telemetry_context .otel_context ,
101- ) as span :
102- telemetry_context = TelemetryContext (otel_context = context_api .get_current ())
103- yield telemetry_context
104-
105- if span .is_recording () and len (telemetry_context ._associated_event_ids ) > 0 :
106- span .set_attribute (
107- "gcp.vertex.agent.associated_event_ids" ,
108- telemetry_context ._associated_event_ids ,
109- )
110-
111-
112- def _span_metadata (context : Context , node : BaseNode ) -> _SpanMetadata | None :
11397 from ..agents .base_agent import BaseAgent
11498 from ..workflow ._workflow import Workflow
11599
116100 if isinstance (node , BaseAgent ):
117- return None
101+ with _invoke_agent_span (context , node ) as tel_ctx :
102+ yield tel_ctx
118103 elif isinstance (node , Workflow ):
119- return _workflow_span_metadata (context , node )
104+ with _invoke_workflow_span (context , node ) as tel_ctx :
105+ yield tel_ctx
120106 else :
121- return _default_node_span_metadata (context , node )
107+ with _invoke_node_span (context , node ) as tel_ctx :
108+ yield tel_ctx
109+
110+
111+ @contextmanager
112+ def _invoke_agent_span (
113+ context : Context , agent : BaseAgent
114+ ) -> Iterator [TelemetryContext ]:
115+ """Passes through an agent node; agents emit their own `invoke_agent` span."""
116+ del agent
117+ token = context_api .attach (context .telemetry_context .otel_context )
118+ try :
119+ yield TelemetryContext (otel_context = context .telemetry_context .otel_context )
120+ finally :
121+ context_api .detach (token )
122122
123123
124- def _workflow_span_metadata (
124+ @contextmanager
125+ def _invoke_workflow_span (
125126 context : Context , workflow : Workflow
126- ) -> _SpanMetadata :
127- return _SpanMetadata (
128- name = f"invoke_workflow { workflow .name } " ,
129- attributes = {
130- GEN_AI_OPERATION_NAME : "invoke_workflow" ,
131- "gen_ai.workflow.name" : workflow .name ,
132- GEN_AI_CONVERSATION_ID : context .session .id ,
133- },
134- )
127+ ) -> Iterator [TelemetryContext ]:
128+ """Opens an `invoke_workflow` span plus its duration metric for ``node``."""
129+ with _use_invoke_workflow_span (
130+ workflow .name ,
131+ context .session .id ,
132+ otel_context = context .telemetry_context .otel_context ,
133+ ) as span :
134+ tel_ctx = TelemetryContext (otel_context = context_api .get_current ())
135+ yield tel_ctx
136+ _maybe_set_associated_events (span , tel_ctx )
135137
136138
137- def _default_node_span_metadata (
139+ @contextmanager
140+ def _invoke_node_span (
138141 context : Context , node : BaseNode
139- ) -> _SpanMetadata :
140- return _SpanMetadata (
141- name = f"invoke_node { node .name } " ,
142+ ) -> Iterator [TelemetryContext ]:
143+ """Opens an `invoke_node` span for a plain node."""
144+ with tracer .start_as_current_span (
145+ f"invoke_node { node .name } " ,
142146 attributes = {
143147 GEN_AI_OPERATION_NAME : "invoke_node" ,
144148 GEN_AI_CONVERSATION_ID : context .session .id ,
145149 },
150+ context = context .telemetry_context .otel_context ,
151+ ) as span :
152+ tel_ctx = TelemetryContext (otel_context = context_api .get_current ())
153+ yield tel_ctx
154+ _maybe_set_associated_events (span , tel_ctx )
155+
156+
157+ def _maybe_set_associated_events (
158+ span : Span , telemetry_context : TelemetryContext
159+ ) -> None :
160+ """Stamps the node's associated event IDs onto its span, if any."""
161+ if span .is_recording () and len (telemetry_context ._associated_event_ids ) > 0 :
162+ span .set_attribute (
163+ "gcp.vertex.agent.associated_event_ids" ,
164+ telemetry_context ._associated_event_ids ,
165+ )
166+
167+
168+ @contextmanager
169+ def _use_invoke_workflow_span (
170+ workflow_name : str ,
171+ conversation_id : str ,
172+ * ,
173+ otel_context : context_api .Context | None = None ,
174+ ) -> Iterator [Span ]:
175+ """Opens an `invoke_workflow {workflow_name}` span."""
176+ from . import _metrics
177+
178+ if otel_context is None :
179+ otel_context = context_api .get_current ()
180+ # First workflow in the invocation is the entrypoint. The flag rides along the
181+ # otel_context propagated to child nodes, so nested workflows see it set.
182+ is_entrypoint = not context_api .get_value (
183+ _ENTRYPOINT_WORKFLOW_KEY , otel_context
146184 )
185+ if is_entrypoint :
186+ otel_context = context_api .set_value (
187+ _ENTRYPOINT_WORKFLOW_KEY , True , otel_context
188+ )
189+ attributes = {
190+ GEN_AI_OPERATION_NAME : "invoke_workflow" ,
191+ "gen_ai.workflow.name" : workflow_name ,
192+ GEN_AI_CONVERSATION_ID : conversation_id ,
193+ GEN_AI_WORKFLOW_IS_ENTRYPOINT : is_entrypoint ,
194+ }
195+ start_s = time .monotonic ()
196+ error : Exception | None = None
197+ try :
198+ with tracer .start_as_current_span (
199+ f"invoke_workflow { workflow_name } " ,
200+ attributes = attributes ,
201+ context = otel_context ,
202+ ) as span :
203+ yield span
204+ except Exception as e : # pylint: disable=broad-exception-caught
205+ error = e
206+ raise
207+ finally :
208+ _metrics .record_workflow_invocation_duration (
209+ workflow_name , time .monotonic () - start_s , is_entrypoint , error
210+ )
0 commit comments