1616
1717import contextlib
1818import dataclasses
19+ import enum
1920import logging
2021import sys
2122import time
2627from opentelemetry import trace
2728import opentelemetry .context as context_api
2829
30+ from . import _adk_attributes
2931from . import _metrics
3032from . import tracing
3133from ._schema_version import resolve_schema_version
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
4447logger = 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
89126class 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
171312async 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 :
0 commit comments