Skip to content

Commit 539a950

Browse files
Merge branch 'main' into fix/dev-server-eval-set-legacy-nameerror
2 parents e1cc40b + bddbb3d commit 539a950

12 files changed

Lines changed: 1839 additions & 2 deletions
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""ADK-owned span attribute names.
16+
17+
These attributes are defined by ADK itself; they are not part of any
18+
OpenTelemetry semantic convention (neither the stable one in
19+
``_stable_semconv`` nor the experimental one in ``_experimental_semconv``).
20+
21+
Everything named ``adk.experimental.*`` is emitted only when experimental
22+
telemetry is enabled and carries no compatibility guarantee: an attribute may be
23+
renamed, restructured, or removed in any release.
24+
"""
25+
26+
from __future__ import annotations
27+
28+
ADK_EXPERIMENTAL_SKILL_NAME = 'adk.experimental.skill.name'
29+
ADK_EXPERIMENTAL_SKILL_SOURCE_TYPE = 'adk.experimental.skill.source.type'
30+
ADK_EXPERIMENTAL_SKILL_CACHE_HIT = 'adk.experimental.skill.cache_hit'
31+
ADK_EXPERIMENTAL_SKILL_DESCRIPTION = 'adk.experimental.skill.description'
32+
ADK_EXPERIMENTAL_SKILL_ADDITIONAL_TOOLS = (
33+
'adk.experimental.skill.additional_tools'
34+
)
35+
ADK_EXPERIMENTAL_SKILL_SOURCE_URI = 'adk.experimental.skill.source.uri'

src/google/adk/telemetry/_instrumentation.py

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
import contextlib
1818
import dataclasses
19+
import enum
1920
import logging
2021
import sys
2122
import time
@@ -26,6 +27,7 @@
2627
from opentelemetry import trace
2728
import opentelemetry.context as context_api
2829

30+
from . import _adk_attributes
2931
from . import _metrics
3032
from . import tracing
3133
from ._schema_version import resolve_schema_version
@@ -38,12 +40,16 @@
3840
from ..events import event as event_lib
3941
from ..models.llm_request import LlmRequest
4042
from ..models.llm_response import LlmResponse
43+
from ..skills.models import Skill
4144
from ..tools.base_tool import BaseTool
4245
from ..workflow._base_node import BaseNode
4346

4447
logger = logging.getLogger("google_adk." + __name__)
4548

4649
_INVOKE_AGENT_TELEMETRY_KEY = context_api.create_key("invoke_agent_telemetry")
50+
_TOOL_EXECUTION_TELEMETRY_KEY = context_api.create_key(
51+
"tool_execution_telemetry"
52+
)
4753

4854

4955
@contextlib.contextmanager
@@ -85,6 +91,37 @@ def record_invocation(
8591
yield
8692

8793

94+
class SkillTelemetrySpanType(enum.Enum):
95+
"""Skill telemetry type."""
96+
97+
SKILL_LOAD = enum.auto()
98+
99+
100+
@dataclasses.dataclass
101+
class SkillTelemetry:
102+
"""Skill related telemetry.
103+
104+
Added to the enclosing tool execution via :func:`record_skill_telemetry`,
105+
which is what turns it into attributes on the skill related spans.
106+
107+
Attributes:
108+
span_type: The type of skill telemetry being recorded.
109+
skill_name: The name of the skill.
110+
skill: The loaded skill, or None if the load did not produce one (unknown
111+
skill name, registry failure). Nothing is recorded in that case; the
112+
failure itself is already reported as the span's ``error.type``.
113+
cache_hit: Whether the skill came from the per-invocation fetch cache
114+
instead of the registry. Only meaningful for registry-sourced skills.
115+
additional_tools: The list of additional tools reported by the skill.
116+
"""
117+
118+
span_type: SkillTelemetrySpanType
119+
skill_name: str | None = None
120+
skill: Skill | None = None
121+
cache_hit: bool = False
122+
additional_tools: list[str] = dataclasses.field(default_factory=list)
123+
124+
88125
@dataclasses.dataclass
89126
class TelemetryContext:
90127
"""Stores all telemetry related state."""
@@ -93,6 +130,7 @@ class TelemetryContext:
93130
function_response_event: event_lib.Event | None = None
94131
error_type: str | None = None
95132
span: tracing.GenerateContentSpan | trace.Span | None = None
133+
skill_telemetry: SkillTelemetry | None = None
96134
_llm_responses: list[LlmResponse] = dataclasses.field(default_factory=list)
97135
_inference_call_count: int = 0
98136
_tool_call_count: int = 0
@@ -167,6 +205,109 @@ def _accumulate_invoke_agent_inference_call() -> None:
167205
span_tel_ctx.increment_inference_calls()
168206

169207

208+
def _active_tool_execution_tel_ctx() -> TelemetryContext | None:
209+
"""Returns the TelemetryContext of the active execute_tool span."""
210+
value = context_api.get_value(_TOOL_EXECUTION_TELEMETRY_KEY)
211+
return value if isinstance(value, TelemetryContext) else None
212+
213+
214+
def record_skill_telemetry(
215+
telemetry_type: SkillTelemetrySpanType,
216+
) -> SkillTelemetry:
217+
"""Attaches skill telemetry to the enclosing tool execution.
218+
219+
The attributes are written by :func:`record_tool_execution`, which owns the
220+
``execute_tool`` span, once the tool call completes. Callers therefore never
221+
depend on a span being open: outside a tool execution this is a no-op rather
222+
than an attribute silently landing on whatever span happens to be current.
223+
224+
A tool execution references a single skill, so a second call within the same
225+
tool execution replaces the first.
226+
227+
Args:
228+
telemetry_type: The type of skill telemetry being recorded.
229+
230+
Returns:
231+
skill_telemetry: Skill telemetry reference to record against the active tool
232+
call.
233+
"""
234+
tel_ctx = _active_tool_execution_tel_ctx()
235+
if tel_ctx is None:
236+
logger.debug(
237+
"No tool execution is being recorded, skill telemetry will not be"
238+
" attached to current span."
239+
)
240+
return SkillTelemetry(span_type=telemetry_type)
241+
if tel_ctx.skill_telemetry is not None:
242+
logger.warning(
243+
"Tool execution already has attached skill telemetry, overwriting."
244+
)
245+
tel_ctx.skill_telemetry = SkillTelemetry(span_type=telemetry_type)
246+
return tel_ctx.skill_telemetry
247+
248+
249+
def record_skill_cache_hit() -> None:
250+
"""Records a skill cache hit against the enclosing tool execution."""
251+
tel_ctx = _active_tool_execution_tel_ctx()
252+
if tel_ctx is None:
253+
logger.debug(
254+
"Skipping skill cache hit: no tool execution is being recorded."
255+
)
256+
return
257+
if tel_ctx.skill_telemetry is None:
258+
logger.warning(
259+
"Tool execution has no attached skill telemetry, skipping cache hit."
260+
)
261+
return
262+
tel_ctx.skill_telemetry.cache_hit = True
263+
264+
265+
def _trace_skill_load(
266+
span: trace.Span,
267+
skill_telemetry: SkillTelemetry | None,
268+
invocation_context: InvocationContext,
269+
) -> None:
270+
"""Stamps the skill load attributes onto the ``execute_tool`` span."""
271+
if skill_telemetry is None:
272+
return
273+
274+
telemetry_config = tracing._telemetry_config_from_invocation_context(
275+
invocation_context
276+
)
277+
if not telemetry_config.should_emit_experimental_telemetry:
278+
return
279+
280+
if skill_telemetry.skill_name is not None:
281+
span.set_attribute(
282+
_adk_attributes.ADK_EXPERIMENTAL_SKILL_NAME, skill_telemetry.skill_name
283+
)
284+
285+
skill = skill_telemetry.skill
286+
if skill is None:
287+
return
288+
289+
span.set_attribute(
290+
_adk_attributes.ADK_EXPERIMENTAL_SKILL_DESCRIPTION, skill.description
291+
)
292+
293+
if skill._uri is not None:
294+
span.set_attribute(
295+
_adk_attributes.ADK_EXPERIMENTAL_SKILL_SOURCE_URI, skill._uri
296+
)
297+
298+
if skill._uri.startswith(("http", "https")):
299+
# Only meaningful if skill is from registry.
300+
span.set_attribute(
301+
_adk_attributes.ADK_EXPERIMENTAL_SKILL_CACHE_HIT,
302+
skill_telemetry.cache_hit,
303+
)
304+
305+
span.set_attribute(
306+
_adk_attributes.ADK_EXPERIMENTAL_SKILL_ADDITIONAL_TOOLS,
307+
skill_telemetry.additional_tools,
308+
)
309+
310+
170311
@contextlib.asynccontextmanager
171312
async def record_agent_invocation(
172313
ctx: InvocationContext, agent: BaseAgent
@@ -216,12 +357,19 @@ async def record_tool_execution(
216357
with tracing.tracer.start_as_current_span(span_name) as s:
217358
span = s
218359
tel_ctx = TelemetryContext(otel_context=context_api.get_current())
360+
# Published so the running tool can report telemetry back to this span
361+
# (see `record_skill_telemetry`) without reaching for the ambient span
362+
# itself.
363+
token = context_api.attach(
364+
context_api.set_value(_TOOL_EXECUTION_TELEMETRY_KEY, tel_ctx)
365+
)
219366
try:
220367
yield tel_ctx
221368
except Exception as e:
222369
caught_error = e
223370
raise
224371
finally:
372+
context_api.detach(token)
225373
detected_error_type = tel_ctx.error_type
226374
response_event = (
227375
tel_ctx.function_response_event if caught_error is None else None
@@ -234,6 +382,12 @@ async def record_tool_execution(
234382
invocation_context=invocation_context,
235383
error_type=tel_ctx.error_type,
236384
)
385+
if (
386+
tel_ctx.skill_telemetry is not None
387+
and tel_ctx.skill_telemetry.span_type
388+
== SkillTelemetrySpanType.SKILL_LOAD
389+
):
390+
_trace_skill_load(span, tel_ctx.skill_telemetry, invocation_context)
237391
finally:
238392
_accumulate_invoke_agent_tool_call()
239393
try:

src/google/adk/tools/skill_toolset.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
from ..skills import models
4141
from ..skills import prompt
4242
from ..skills import SkillRegistry
43+
from ..telemetry import _instrumentation
4344
from ..utils import instructions_utils
4445
from .base_tool import BaseTool
4546
from .base_toolset import BaseToolset
@@ -249,13 +250,18 @@ def _get_declaration(self) -> types.FunctionDeclaration | None:
249250
async def run_async(
250251
self, *, args: dict[str, Any], tool_context: ToolContext
251252
) -> Any:
252-
skill_name = args.get("skill_name")
253+
skill_name: str | None = args.get("skill_name")
253254
if not skill_name:
254255
return {
255256
"error": "Argument 'skill_name' is required.",
256257
"error_code": "INVALID_ARGUMENTS",
257258
}
258259

260+
skill_telemetry = _instrumentation.record_skill_telemetry(
261+
_instrumentation.SkillTelemetrySpanType.SKILL_LOAD
262+
)
263+
skill_telemetry.skill_name = skill_name
264+
259265
try:
260266
skill = await self._toolset._get_or_fetch_skill(
261267
skill_name, tool_context.invocation_id
@@ -272,6 +278,11 @@ async def run_async(
272278
"error_code": "SKILL_NOT_FOUND",
273279
}
274280

281+
skill_telemetry.skill = skill
282+
skill_telemetry.additional_tools = skill.frontmatter.metadata.get(
283+
"adk_additional_tools", []
284+
)
285+
275286
# Record skill activation in agent state for tool resolution.
276287
agent_name = tool_context.agent_name
277288
state_key = f"_adk_activated_skill_{agent_name}"
@@ -1349,6 +1360,8 @@ async def _get_or_fetch_skill(
13491360
turn_cache = self._fetched_skill_cache[invocation_id]
13501361
if skill_name in turn_cache:
13511362
cached = turn_cache[skill_name]
1363+
_instrumentation.record_skill_cache_hit()
1364+
13521365
if isinstance(cached, asyncio.Future):
13531366
return await cached
13541367
return cached

tests/unittests/telemetry/functional/_recording.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from __future__ import annotations
2222

2323
from dataclasses import dataclass
24+
from dataclasses import field
2425
from typing import Literal
2526
from typing import TYPE_CHECKING
2627

@@ -31,8 +32,10 @@
3132
from typing_extensions import assert_never
3233

3334
from ._digests import TelemetryDigest
35+
from ._scenarios import ADK_EXPERIMENTAL_TELEMETRY
3436
from ._scenarios import ADK_TELEMETRY_SCHEMA_VERSION_OPT_IN
3537
from ._scenarios import build_mcp_test_runner
38+
from ._scenarios import build_skill_test_runner
3639
from ._scenarios import build_test_runner
3740
from ._scenarios import CAPTURE_CONTENT
3841
from ._scenarios import FakeMcpSession
@@ -41,6 +44,7 @@
4144
from ._scenarios import run_agent_scenario
4245
from ._scenarios import run_node_scenario
4346
from ._scenarios import Scenario
47+
from ._scenarios import SkillType
4448

4549
if TYPE_CHECKING:
4650
from google.adk.events.event import Event
@@ -67,6 +71,8 @@ class FunctionalTestCase:
6771
# When true, the tool raises instead of returning, and the scenario is
6872
# expected to propagate it (tool-failure telemetry path).
6973
tool_fails: bool = False
74+
experimental_telemetry: bool = False
75+
loaded_skills: list[SkillType] = field(default_factory=list)
7076

7177
@property
7278
def expects_failure(self) -> bool:
@@ -99,6 +105,9 @@ def apply_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
99105
ADK_TELEMETRY_SCHEMA_VERSION_OPT_IN, str(self.schema_version)
100106
)
101107
monkeypatch.setenv("ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS", "false")
108+
monkeypatch.setenv(
109+
ADK_EXPERIMENTAL_TELEMETRY, str(self.experimental_telemetry).lower()
110+
)
102111

103112

104113
# ---------------------------------------------------------------------------
@@ -163,5 +172,8 @@ async def _run_scenario(
163172
build_mcp_test_runner(monkeypatch, FakeMcpSession())
164173
)
165174
return []
175+
elif case.scenario == "skill":
176+
await run_agent_scenario(build_skill_test_runner(skills=case.loaded_skills))
177+
return []
166178
else:
167179
assert_never(case.scenario)

0 commit comments

Comments
 (0)