Skip to content

Commit e98633a

Browse files
RKestcopybara-github
authored andcommitted
feat(telemetry): emit entrypoint invoke_workflow span under schema v2
When ADK_TELEMETRY_SCHEMA_VERSION_OPT_IN resolves to 2, replace the legacy top-level 'invocation' span with an entrypoint 'invoke_workflow {entrypoint}' span (entrypoint = root agent or root node name), set is_entrypoint, and emit the gen_ai.invoke_workflow.duration metric alongside it. Entrypoint status is tracked via a contextvar so nested/agent-as-tool workflows report is_entrypoint=false and the span is not double-emitted when the ADK entrypoint is itself a workflow. Schema v1 is unchanged. Co-authored-by: Max Ind <maxind@google.com> PiperOrigin-RevId: 941359381
1 parent df6baf4 commit e98633a

8 files changed

Lines changed: 1065 additions & 1114 deletions

File tree

src/google/adk/telemetry/_instrumentation.py

Lines changed: 30 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929
from . import _metrics
3030
from . import tracing
3131
from ..events import event as event_lib
32+
from ._schema_version import resolve_schema_version
33+
from ._schema_version import SCHEMA_VERSION_SEMCONV_ALIGNED
3234

3335
if TYPE_CHECKING:
3436
from ..agents.base_agent import BaseAgent
@@ -41,52 +43,42 @@
4143
logger = logging.getLogger("google_adk." + __name__)
4244

4345

44-
def _get_elapsed_s(
45-
span: trace.Span | tracing.GenerateContentSpan | None,
46-
fallback_start: float,
47-
) -> float:
48-
"""Guarantees consistent time source for duration calculation.
49-
50-
Note: This must be called with an ended span.
51-
52-
Args:
53-
span (trace.Span | tracing.GenerateContentSpan | None): The ended span to
54-
extract duration from.
55-
fallback_start (float): Fallback start time in seconds (monotonic).
56-
57-
Returns:
58-
float: Elapsed duration in seconds.
59-
"""
60-
if span is None:
61-
return time.monotonic() - fallback_start
62-
63-
span = span.span if hasattr(span, "span") else span
64-
start_ns = getattr(span, "start_time", None)
65-
end_ns = getattr(span, "end_time", None)
66-
67-
if isinstance(start_ns, int) and isinstance(end_ns, int):
68-
return (end_ns - start_ns) / 1e9 # Convert ns to s
69-
70-
# Fallback if span times are missing
71-
return time.monotonic() - fallback_start
72-
73-
7446
@contextlib.contextmanager
7547
def record_invocation(
7648
entrypoint_node: BaseNode | None,
7749
conversation_id: str,
7850
) -> Iterator[None]:
79-
"""Top-level ``invocation`` span for a runner invocation.
51+
"""Top-level invocation span for a runner invocation.
52+
53+
Schema v1 emits the legacy ``invocation`` span. Schema v2 replaces it with an
54+
entrypoint ``invoke_workflow {entrypoint}`` span (entrypoint = root agent or
55+
root node name), which omits the ``gen_ai.workflow.nested`` attribute, and a
56+
``gen_ai.invoke_workflow.duration`` metric -- unless the entrypoint is itself
57+
a workflow, in which case its own node span is the entrypoint
58+
``invoke_workflow`` span and we avoid double-emitting it here.
8059
8160
Args:
8261
entrypoint_node: The runner's root agent/node.
83-
conversation_id: Session/conversation id.
62+
conversation_id: Session/conversation id (stamped on the v2 span).
8463
8564
Yields:
86-
Nothing; the span is active for the duration of the block.
65+
Nothing; the span (if any) is active for the duration of the block.
8766
"""
88-
del entrypoint_node, conversation_id # Unused until schema v2 lands.
89-
with tracing.tracer.start_as_current_span("invocation"):
67+
if resolve_schema_version() < SCHEMA_VERSION_SEMCONV_ALIGNED:
68+
with tracing.tracer.start_as_current_span("invocation"):
69+
yield
70+
return
71+
72+
from . import node_tracing
73+
from ..workflow._workflow import Workflow
74+
75+
if isinstance(entrypoint_node, Workflow):
76+
# The workflow's own node span is the entrypoint `invoke_workflow` span.
77+
yield
78+
return
79+
80+
entrypoint_name = entrypoint_node.name if entrypoint_node else ""
81+
with node_tracing._use_invoke_workflow_span(entrypoint_name, conversation_id):
9082
yield
9183

9284

@@ -152,7 +144,7 @@ async def record_agent_invocation(
152144
finally:
153145
_record_agent_metrics(
154146
agent.name,
155-
_get_elapsed_s(span, start_time),
147+
_metrics.get_elapsed_s(span, start_time),
156148
getattr(ctx, "user_content", None),
157149
getattr(getattr(ctx, "session", None), "events", []),
158150
caught_error,
@@ -198,7 +190,7 @@ async def record_tool_execution(
198190
tool_name=tool.name,
199191
tool_type=tool.__class__.__name__,
200192
agent_name=agent.name,
201-
elapsed_s=_get_elapsed_s(span, start_time),
193+
elapsed_s=_metrics.get_elapsed_s(span, start_time),
202194
error=caught_error,
203195
)
204196
except Exception: # pylint: disable=broad-exception-caught
@@ -227,7 +219,7 @@ async def record_inference_telemetry(
227219
finally:
228220
inference_error = sys.exc_info()[1]
229221
agent = invocation_context.agent
230-
elapsed_s = _get_elapsed_s(tel_ctx.span, start_time)
222+
elapsed_s = _metrics.get_elapsed_s(tel_ctx.span, start_time)
231223
try:
232224
if agent is not None and tracing._should_emit_native_telemetry(agent):
233225
_metrics.record_client_operation_duration(

src/google/adk/telemetry/_metrics.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from __future__ import annotations
1616

1717
import logging
18+
import time
1819
from typing import TYPE_CHECKING
1920

2021
from google.adk import version
@@ -30,6 +31,10 @@
3031
from google.adk.events.event import Event
3132
from google.adk.models.llm_request import LlmRequest
3233
from google.adk.models.llm_response import LlmResponse
34+
from opentelemetry.trace import Span
35+
from opentelemetry.util.types import AttributeValue
36+
37+
from .tracing import GenerateContentSpan
3338

3439
logger = logging.getLogger("google_adk." + __name__)
3540

@@ -61,6 +66,11 @@
6166
409.6,
6267
],
6368
)
69+
_workflow_invocation_duration = meter.create_histogram(
70+
"gen_ai.invoke_workflow.duration",
71+
unit="s",
72+
description="Duration of workflow invocations.",
73+
)
6474
_tool_execution_duration = meter.create_histogram(
6575
"gen_ai.tool.execution.duration",
6676
unit="s",
@@ -160,6 +170,27 @@ def record_agent_invocation_duration(
160170
_agent_invocation_duration.record(elapsed_s, attributes=attrs)
161171

162172

173+
def record_workflow_invocation_duration(
174+
*,
175+
workflow_name: str,
176+
elapsed_s: float,
177+
nested: bool,
178+
error: BaseException | None = None,
179+
) -> None:
180+
"""Records the duration of a workflow invocation."""
181+
attrs: dict[str, AttributeValue] = {
182+
gen_ai_attributes.GEN_AI_OPERATION_NAME: "invoke_workflow",
183+
}
184+
# Root workflow omits the attribute entirely; only nested ones emit it.
185+
if nested:
186+
attrs["gen_ai.workflow.nested"] = True
187+
if error is not None:
188+
attrs[error_attributes.ERROR_TYPE] = type(error).__name__
189+
if workflow_name:
190+
attrs["gen_ai.workflow.name"] = workflow_name
191+
_workflow_invocation_duration.record(elapsed_s, attributes=attrs)
192+
193+
163194
def record_agent_request_size(
164195
agent_name: str, user_content: types.Content | None
165196
):
@@ -303,3 +334,33 @@ def _get_content_size(
303334

304335
def _get_provider_name() -> str:
305336
return tracing._guess_gemini_system_name()
337+
338+
339+
def get_elapsed_s(
340+
span: Span | GenerateContentSpan | None,
341+
fallback_start: float,
342+
) -> float:
343+
"""Guarantees consistent time source for duration calculation.
344+
345+
Note: This must be called with an ended span.
346+
347+
Args:
348+
span (trace.Span | tracing.GenerateContentSpan | None): The ended span to
349+
extract duration from.
350+
fallback_start (float): Fallback start time in seconds (monotonic).
351+
352+
Returns:
353+
float: Elapsed duration in seconds.
354+
"""
355+
if span is None:
356+
return time.monotonic() - fallback_start
357+
358+
span = span.span if hasattr(span, "span") else span
359+
start_ns = getattr(span, "start_time", None)
360+
end_ns = getattr(span, "end_time", None)
361+
362+
if isinstance(start_ns, int) and isinstance(end_ns, int):
363+
return (end_ns - start_ns) / 1e9 # Convert ns to s
364+
365+
# Fallback if span times are missing
366+
return time.monotonic() - fallback_start

0 commit comments

Comments
 (0)