feat(telemetry): add gen_ai.invoke_agent.{inference,tool}_calls metrics

Adds the two per-invocation call-count metrics from semconv #336
(open-telemetry/semantic-conventions-genai#336):

* gen_ai.invoke_agent.inference_calls: model calls in one invoke_agent span.
* gen_ai.invoke_agent.tool_calls: tool calls in one invoke_agent span.

Both are counted on the per-span TelemetryContext at the call sites
(record_inference_telemetry, record_tool_execution) and flushed in
record_agent_invocation, keyed by gen_ai.agent.name. One point per invoke_agent
span, so a multi-agent invocation does not double count. Failed calls still
count; only client-side tool calls count. Always emitted (a no-op without a
MeterProvider).

PiperOrigin-RevId: 940760757
This commit is contained in:
Google Team Member
2026-06-30 18:05:56 -07:00
committed by Copybara-Service
parent 196f7708f8
commit c6dec00a6e
7 changed files with 5 additions and 149 deletions
@@ -18,7 +18,6 @@ import asyncio
from typing import Any
from typing import cast
from typing import Optional
from typing import TYPE_CHECKING
from google.adk.platform import uuid as platform_uuid
from google.genai import types
@@ -48,9 +47,6 @@ from .live_request_queue import LiveRequestQueue
from .run_config import RunConfig
from .transcription_entry import TranscriptionEntry
if TYPE_CHECKING:
from google.adk.telemetry._instrumentation import TelemetryContext
class LlmCallsLimitExceededError(Exception):
"""Error thrown when the number of LLM calls exceed the limit."""
@@ -274,12 +270,6 @@ class InvocationContext(BaseModel):
of this invocation.
"""
_invoke_agent_telemetry_context: Optional[TelemetryContext] = PrivateAttr(
default=None
)
"""TelemetryContext of the active ``invoke_agent`` span, if any.
"""
@property
def is_resumable(self) -> bool:
"""Returns whether the current invocation is resumable."""
+3 -52
View File
@@ -27,11 +27,11 @@ import opentelemetry.context as context_api
from . import _metrics
from . import tracing
from ..events import event as event_lib
if TYPE_CHECKING:
from ..agents.base_agent import BaseAgent
from ..agents.invocation_context import InvocationContext
from ..events import event as event_lib
from ..models.llm_request import LlmRequest
from ..models.llm_response import LlmResponse
from ..tools.base_tool import BaseTool
@@ -78,31 +78,11 @@ class TelemetryContext:
error_type: str | None = None
span: tracing.GenerateContentSpan | trace.Span | None = None
_llm_responses: list[LlmResponse] = dataclasses.field(default_factory=list)
_inference_call_count: int = 0
_tool_call_count: int = 0
@property
def llm_responses(self) -> list[LlmResponse]:
return self._llm_responses
@property
def inference_call_count(self) -> int:
"""Number of model calls counted against this invoke_agent span."""
return self._inference_call_count
def increment_inference_calls(self) -> None:
"""Counts one model call against this span (including calls that raised)."""
self._inference_call_count += 1
def increment_tool_calls(self) -> None:
"""Counts one tool call against this invoke_agent span."""
self._tool_call_count += 1
@property
def tool_call_count(self) -> int:
"""Number of tool calls counted against this invoke_agent span."""
return self._tool_call_count
def record_llm_response(
self, invocation_context: InvocationContext, response: LlmResponse
) -> None:
@@ -130,30 +110,6 @@ def _record_agent_metrics(
logger.exception("Failed to record agent metrics for agent %s", agent_name)
def _flush_invoke_agent_metrics(
tel_ctx: TelemetryContext, agent_name: str
) -> None:
"""Flushes this span's accumulated inference/tool-call metrics."""
_metrics.record_invoke_agent_inference_calls(
agent_name, tel_ctx.inference_call_count
)
_metrics.record_invoke_agent_tool_calls(agent_name, tel_ctx.tool_call_count)
def _accumulate_invoke_agent_tool_call(ctx: InvocationContext) -> None:
"""Counts one tool call against the active invoke_agent span."""
span_tel_ctx = ctx._invoke_agent_telemetry_context # pylint: disable=protected-access
if span_tel_ctx is not None:
span_tel_ctx.increment_tool_calls()
def _accumulate_invoke_agent_inference_call(ctx: InvocationContext) -> None:
"""Counts one model call against the active invoke_agent span."""
span_tel_ctx = ctx._invoke_agent_telemetry_context # pylint: disable=protected-access
if span_tel_ctx is not None:
span_tel_ctx.increment_inference_calls()
@contextlib.asynccontextmanager
async def record_agent_invocation(
ctx: InvocationContext, agent: BaseAgent
@@ -163,13 +119,11 @@ async def record_agent_invocation(
caught_error: Exception | None = None
span: trace.Span | None = None
span_name = f"invoke_agent {agent.name}"
tel_ctx = TelemetryContext()
try:
with tracing.tracer.start_as_current_span(span_name) as s:
span = s
tracing.trace_agent_invocation(span, agent, ctx)
tel_ctx.otel_context = context_api.get_current()
ctx._invoke_agent_telemetry_context = tel_ctx # pylint: disable=protected-access
tel_ctx = TelemetryContext(otel_context=context_api.get_current())
yield tel_ctx
except Exception as e:
caught_error = e
@@ -182,7 +136,6 @@ async def record_agent_invocation(
getattr(getattr(ctx, "session", None), "events", []),
caught_error,
)
_flush_invoke_agent_metrics(tel_ctx, agent.name)
@contextlib.asynccontextmanager
@@ -190,7 +143,7 @@ async def record_tool_execution(
tool: BaseTool,
agent: BaseAgent,
function_args: dict[str, object],
invocation_context: InvocationContext,
invocation_context: InvocationContext | None = None,
) -> AsyncIterator[TelemetryContext]:
"""Unified context manager for consolidated tool execution telemetry."""
start_time = time.monotonic()
@@ -218,7 +171,6 @@ async def record_tool_execution(
invocation_context=invocation_context,
error_type=tel_ctx.error_type,
)
_accumulate_invoke_agent_tool_call(invocation_context)
finally:
try:
_metrics.record_tool_execution_duration(
@@ -253,7 +205,6 @@ async def record_inference_telemetry(
yield tel_ctx
finally:
inference_error = sys.exc_info()[1]
_accumulate_invoke_agent_inference_call(invocation_context)
agent = invocation_context.agent
elapsed_s = _get_elapsed_s(tel_ctx.span, start_time)
try:
-50
View File
@@ -146,44 +146,6 @@ _client_operation_duration = (
gen_ai_metrics.create_gen_ai_client_operation_duration(meter)
)
_client_token_usage = gen_ai_metrics.create_gen_ai_client_token_usage(meter)
_invoke_agent_inference_calls = meter.create_histogram(
"gen_ai.invoke_agent.inference_calls",
unit="1",
description="Number of inference (model) calls per agent invocation.",
explicit_bucket_boundaries_advisory=[
1,
2,
3,
4,
5,
6,
8,
12,
16,
24,
32,
64,
],
)
_invoke_agent_tool_calls = meter.create_histogram(
"gen_ai.invoke_agent.tool_calls",
unit="1",
description="Number of tool calls per agent invocation.",
explicit_bucket_boundaries_advisory=[
1,
2,
3,
4,
5,
6,
8,
12,
16,
24,
32,
64,
],
)
def record_agent_invocation_duration(
@@ -198,18 +160,6 @@ def record_agent_invocation_duration(
_agent_invocation_duration.record(elapsed_s, attributes=attrs)
def record_invoke_agent_inference_calls(agent_name: str, count: int):
"""Records the number of inference (model) calls in an agent invocation."""
attrs = {gen_ai_attributes.GEN_AI_AGENT_NAME: agent_name}
_invoke_agent_inference_calls.record(count, attributes=attrs)
def record_invoke_agent_tool_calls(agent_name: str, count: int):
"""Records the number of tool calls in an agent invocation."""
attrs = {gen_ai_attributes.GEN_AI_AGENT_NAME: agent_name}
_invoke_agent_tool_calls.record(count, attributes=attrs)
def record_agent_request_size(
agent_name: str, user_content: types.Content | None
):
@@ -2900,12 +2900,6 @@ EXPECTED_NODE_METRICS_V1: dict[str, frozenset[MetricPoint]] = {
value=NON_DETERMINISTIC,
),
}),
"gen_ai.invoke_agent.inference_calls": frozenset({
MetricPoint(attributes={"gen_ai.agent.name": AGENT_NAME}, value=2),
}),
"gen_ai.invoke_agent.tool_calls": frozenset({
MetricPoint(attributes={"gen_ai.agent.name": AGENT_NAME}, value=1),
}),
}
@@ -2953,12 +2947,6 @@ EXPECTED_NODE_METRICS_V2: dict[str, frozenset[MetricPoint]] = {
value=NON_DETERMINISTIC,
),
}),
"gen_ai.invoke_agent.inference_calls": frozenset({
MetricPoint(attributes={"gen_ai.agent.name": AGENT_NAME}, value=2),
}),
"gen_ai.invoke_agent.tool_calls": frozenset({
MetricPoint(attributes={"gen_ai.agent.name": AGENT_NAME}, value=1),
}),
}
@@ -2294,12 +2294,6 @@ EXPECTED_METRICS_V1: dict[str, frozenset[MetricPoint]] = {
value=NON_DETERMINISTIC,
),
}),
"gen_ai.invoke_agent.inference_calls": frozenset({
MetricPoint(attributes={"gen_ai.agent.name": AGENT_NAME}, value=2),
}),
"gen_ai.invoke_agent.tool_calls": frozenset({
MetricPoint(attributes={"gen_ai.agent.name": AGENT_NAME}, value=1),
}),
}
@@ -2347,12 +2341,6 @@ EXPECTED_METRICS_V2: dict[str, frozenset[MetricPoint]] = {
value=NON_DETERMINISTIC,
),
}),
"gen_ai.invoke_agent.inference_calls": frozenset({
MetricPoint(attributes={"gen_ai.agent.name": AGENT_NAME}, value=2),
}),
"gen_ai.invoke_agent.tool_calls": frozenset({
MetricPoint(attributes={"gen_ai.agent.name": AGENT_NAME}, value=1),
}),
}
@@ -339,16 +339,6 @@ _PATCHED_HISTOGRAMS: tuple[HistogramSpec, ...] = (
attr="_client_token_usage",
metric_name="gen_ai.client.token.usage",
),
HistogramSpec(
module=_metrics,
attr="_invoke_agent_inference_calls",
metric_name="gen_ai.invoke_agent.inference_calls",
),
HistogramSpec(
module=_metrics,
attr="_invoke_agent_tool_calls",
metric_name="gen_ai.invoke_agent.tool_calls",
),
)
@@ -15,7 +15,6 @@
# pylint: disable=protected-access
import time
import types
from unittest import mock
from google.adk.telemetry import _instrumentation
@@ -95,8 +94,8 @@ async def test_record_agent_invocation_tolerates_minimal_context():
"""
agent = mock.MagicMock()
agent.name = "test_agent"
# Context-like without `user_content` and without `session`.
bare_ctx = types.SimpleNamespace()
# Bare object without `user_content` and without `session`.
bare_ctx = object()
with (
mock.patch.object(