Skip to content

Commit 044cd7b

Browse files
google-genai-botcopybara-github
authored andcommitted
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: 940684383
1 parent 8aff514 commit 044cd7b

7 files changed

Lines changed: 149 additions & 5 deletions

File tree

src/google/adk/agents/invocation_context.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from typing import Any
1919
from typing import cast
2020
from typing import Optional
21+
from typing import TYPE_CHECKING
2122

2223
from google.adk.platform import uuid as platform_uuid
2324
from google.genai import types
@@ -47,6 +48,9 @@
4748
from .run_config import RunConfig
4849
from .transcription_entry import TranscriptionEntry
4950

51+
if TYPE_CHECKING:
52+
from google.adk.telemetry._instrumentation import TelemetryContext
53+
5054

5155
class LlmCallsLimitExceededError(Exception):
5256
"""Error thrown when the number of LLM calls exceed the limit."""
@@ -270,6 +274,12 @@ class InvocationContext(BaseModel):
270274
of this invocation.
271275
"""
272276

277+
_invoke_agent_telemetry_context: Optional[TelemetryContext] = PrivateAttr(
278+
default=None
279+
)
280+
"""TelemetryContext of the active ``invoke_agent`` span, if any.
281+
"""
282+
273283
@property
274284
def is_resumable(self) -> bool:
275285
"""Returns whether the current invocation is resumable."""

src/google/adk/telemetry/_instrumentation.py

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,11 @@
2727

2828
from . import _metrics
2929
from . import tracing
30-
from ..events import event as event_lib
3130

3231
if TYPE_CHECKING:
3332
from ..agents.base_agent import BaseAgent
3433
from ..agents.invocation_context import InvocationContext
34+
from ..events import event as event_lib
3535
from ..models.llm_request import LlmRequest
3636
from ..models.llm_response import LlmResponse
3737
from ..tools.base_tool import BaseTool
@@ -78,11 +78,31 @@ class TelemetryContext:
7878
error_type: str | None = None
7979
span: tracing.GenerateContentSpan | trace.Span | None = None
8080
_llm_responses: list[LlmResponse] = dataclasses.field(default_factory=list)
81+
_inference_call_count: int = 0
82+
_tool_call_count: int = 0
8183

8284
@property
8385
def llm_responses(self) -> list[LlmResponse]:
8486
return self._llm_responses
8587

88+
@property
89+
def inference_call_count(self) -> int:
90+
"""Number of model calls counted against this invoke_agent span."""
91+
return self._inference_call_count
92+
93+
def increment_inference_calls(self) -> None:
94+
"""Counts one model call against this span (including calls that raised)."""
95+
self._inference_call_count += 1
96+
97+
def increment_tool_calls(self) -> None:
98+
"""Counts one tool call against this invoke_agent span."""
99+
self._tool_call_count += 1
100+
101+
@property
102+
def tool_call_count(self) -> int:
103+
"""Number of tool calls counted against this invoke_agent span."""
104+
return self._tool_call_count
105+
86106
def record_llm_response(
87107
self, invocation_context: InvocationContext, response: LlmResponse
88108
) -> None:
@@ -110,6 +130,30 @@ def _record_agent_metrics(
110130
logger.exception("Failed to record agent metrics for agent %s", agent_name)
111131

112132

133+
def _flush_invoke_agent_metrics(
134+
tel_ctx: TelemetryContext, agent_name: str
135+
) -> None:
136+
"""Flushes this span's accumulated inference/tool-call metrics."""
137+
_metrics.record_invoke_agent_inference_calls(
138+
agent_name, tel_ctx.inference_call_count
139+
)
140+
_metrics.record_invoke_agent_tool_calls(agent_name, tel_ctx.tool_call_count)
141+
142+
143+
def _accumulate_invoke_agent_tool_call(ctx: InvocationContext) -> None:
144+
"""Counts one tool call against the active invoke_agent span."""
145+
span_tel_ctx = ctx._invoke_agent_telemetry_context # pylint: disable=protected-access
146+
if span_tel_ctx is not None:
147+
span_tel_ctx.increment_tool_calls()
148+
149+
150+
def _accumulate_invoke_agent_inference_call(ctx: InvocationContext) -> None:
151+
"""Counts one model call against the active invoke_agent span."""
152+
span_tel_ctx = ctx._invoke_agent_telemetry_context # pylint: disable=protected-access
153+
if span_tel_ctx is not None:
154+
span_tel_ctx.increment_inference_calls()
155+
156+
113157
@contextlib.asynccontextmanager
114158
async def record_agent_invocation(
115159
ctx: InvocationContext, agent: BaseAgent
@@ -119,11 +163,13 @@ async def record_agent_invocation(
119163
caught_error: Exception | None = None
120164
span: trace.Span | None = None
121165
span_name = f"invoke_agent {agent.name}"
166+
tel_ctx = TelemetryContext()
122167
try:
123168
with tracing.tracer.start_as_current_span(span_name) as s:
124169
span = s
125170
tracing.trace_agent_invocation(span, agent, ctx)
126-
tel_ctx = TelemetryContext(otel_context=context_api.get_current())
171+
tel_ctx.otel_context = context_api.get_current()
172+
ctx._invoke_agent_telemetry_context = tel_ctx # pylint: disable=protected-access
127173
yield tel_ctx
128174
except Exception as e:
129175
caught_error = e
@@ -136,14 +182,15 @@ async def record_agent_invocation(
136182
getattr(getattr(ctx, "session", None), "events", []),
137183
caught_error,
138184
)
185+
_flush_invoke_agent_metrics(tel_ctx, agent.name)
139186

140187

141188
@contextlib.asynccontextmanager
142189
async def record_tool_execution(
143190
tool: BaseTool,
144191
agent: BaseAgent,
145192
function_args: dict[str, object],
146-
invocation_context: InvocationContext | None = None,
193+
invocation_context: InvocationContext,
147194
) -> AsyncIterator[TelemetryContext]:
148195
"""Unified context manager for consolidated tool execution telemetry."""
149196
start_time = time.monotonic()
@@ -171,6 +218,7 @@ async def record_tool_execution(
171218
invocation_context=invocation_context,
172219
error_type=tel_ctx.error_type,
173220
)
221+
_accumulate_invoke_agent_tool_call(invocation_context)
174222
finally:
175223
try:
176224
_metrics.record_tool_execution_duration(
@@ -205,6 +253,7 @@ async def record_inference_telemetry(
205253
yield tel_ctx
206254
finally:
207255
inference_error = sys.exc_info()[1]
256+
_accumulate_invoke_agent_inference_call(invocation_context)
208257
agent = invocation_context.agent
209258
elapsed_s = _get_elapsed_s(tel_ctx.span, start_time)
210259
try:

src/google/adk/telemetry/_metrics.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,44 @@
146146
gen_ai_metrics.create_gen_ai_client_operation_duration(meter)
147147
)
148148
_client_token_usage = gen_ai_metrics.create_gen_ai_client_token_usage(meter)
149+
_invoke_agent_inference_calls = meter.create_histogram(
150+
"gen_ai.invoke_agent.inference_calls",
151+
unit="1",
152+
description="Number of inference (model) calls per agent invocation.",
153+
explicit_bucket_boundaries_advisory=[
154+
1,
155+
2,
156+
3,
157+
4,
158+
5,
159+
6,
160+
8,
161+
12,
162+
16,
163+
24,
164+
32,
165+
64,
166+
],
167+
)
168+
_invoke_agent_tool_calls = meter.create_histogram(
169+
"gen_ai.invoke_agent.tool_calls",
170+
unit="1",
171+
description="Number of tool calls per agent invocation.",
172+
explicit_bucket_boundaries_advisory=[
173+
1,
174+
2,
175+
3,
176+
4,
177+
5,
178+
6,
179+
8,
180+
12,
181+
16,
182+
24,
183+
32,
184+
64,
185+
],
186+
)
149187

150188

151189
def record_agent_invocation_duration(
@@ -160,6 +198,18 @@ def record_agent_invocation_duration(
160198
_agent_invocation_duration.record(elapsed_s, attributes=attrs)
161199

162200

201+
def record_invoke_agent_inference_calls(agent_name: str, count: int):
202+
"""Records the number of inference (model) calls in an agent invocation."""
203+
attrs = {gen_ai_attributes.GEN_AI_AGENT_NAME: agent_name}
204+
_invoke_agent_inference_calls.record(count, attributes=attrs)
205+
206+
207+
def record_invoke_agent_tool_calls(agent_name: str, count: int):
208+
"""Records the number of tool calls in an agent invocation."""
209+
attrs = {gen_ai_attributes.GEN_AI_AGENT_NAME: agent_name}
210+
_invoke_agent_tool_calls.record(count, attributes=attrs)
211+
212+
163213
def record_agent_request_size(
164214
agent_name: str, user_content: types.Content | None
165215
):

tests/unittests/telemetry/functional_node_test_cases.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2900,6 +2900,12 @@
29002900
value=NON_DETERMINISTIC,
29012901
),
29022902
}),
2903+
"gen_ai.invoke_agent.inference_calls": frozenset({
2904+
MetricPoint(attributes={"gen_ai.agent.name": AGENT_NAME}, value=2),
2905+
}),
2906+
"gen_ai.invoke_agent.tool_calls": frozenset({
2907+
MetricPoint(attributes={"gen_ai.agent.name": AGENT_NAME}, value=1),
2908+
}),
29032909
}
29042910

29052911

@@ -2947,6 +2953,12 @@
29472953
value=NON_DETERMINISTIC,
29482954
),
29492955
}),
2956+
"gen_ai.invoke_agent.inference_calls": frozenset({
2957+
MetricPoint(attributes={"gen_ai.agent.name": AGENT_NAME}, value=2),
2958+
}),
2959+
"gen_ai.invoke_agent.tool_calls": frozenset({
2960+
MetricPoint(attributes={"gen_ai.agent.name": AGENT_NAME}, value=1),
2961+
}),
29502962
}
29512963

29522964

tests/unittests/telemetry/functional_test_cases.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2294,6 +2294,12 @@
22942294
value=NON_DETERMINISTIC,
22952295
),
22962296
}),
2297+
"gen_ai.invoke_agent.inference_calls": frozenset({
2298+
MetricPoint(attributes={"gen_ai.agent.name": AGENT_NAME}, value=2),
2299+
}),
2300+
"gen_ai.invoke_agent.tool_calls": frozenset({
2301+
MetricPoint(attributes={"gen_ai.agent.name": AGENT_NAME}, value=1),
2302+
}),
22972303
}
22982304

22992305

@@ -2341,6 +2347,12 @@
23412347
value=NON_DETERMINISTIC,
23422348
),
23432349
}),
2350+
"gen_ai.invoke_agent.inference_calls": frozenset({
2351+
MetricPoint(attributes={"gen_ai.agent.name": AGENT_NAME}, value=2),
2352+
}),
2353+
"gen_ai.invoke_agent.tool_calls": frozenset({
2354+
MetricPoint(attributes={"gen_ai.agent.name": AGENT_NAME}, value=1),
2355+
}),
23442356
}
23452357

23462358

tests/unittests/telemetry/functional_test_helpers.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,16 @@ class HistogramSpec(NamedTuple):
339339
attr="_client_token_usage",
340340
metric_name="gen_ai.client.token.usage",
341341
),
342+
HistogramSpec(
343+
module=_metrics,
344+
attr="_invoke_agent_inference_calls",
345+
metric_name="gen_ai.invoke_agent.inference_calls",
346+
),
347+
HistogramSpec(
348+
module=_metrics,
349+
attr="_invoke_agent_tool_calls",
350+
metric_name="gen_ai.invoke_agent.tool_calls",
351+
),
342352
)
343353

344354

tests/unittests/telemetry/test_instrumentation.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
# pylint: disable=protected-access
1616

1717
import time
18+
import types
1819
from unittest import mock
1920

2021
from google.adk.telemetry import _instrumentation
@@ -94,8 +95,8 @@ async def test_record_agent_invocation_tolerates_minimal_context():
9495
"""
9596
agent = mock.MagicMock()
9697
agent.name = "test_agent"
97-
# Bare object without `user_content` and without `session`.
98-
bare_ctx = object()
98+
# Context-like without `user_content` and without `session`.
99+
bare_ctx = types.SimpleNamespace()
99100

100101
with (
101102
mock.patch.object(

0 commit comments

Comments
 (0)