Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 56 additions & 2 deletions src/google/adk/flows/llm_flows/functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,48 @@
_TOOL_THREAD_POOL_LOCK = threading.Lock()


def _detect_error_type_for_telemetry(
tool: BaseTool,
tool_context: ToolContext,
function_response: Any,
) -> Optional[str]:
"""Detects an error type from a tool response for telemetry purposes.

This does not modify the response. `_detect_error_in_response` is an optional
per-tool hook accessed via `getattr` to avoid adding a public API on
`BaseTool`. Any exception raised by the detector is logged and swallowed so
that telemetry logic never breaks tool execution.

Args:
tool: The tool whose response is being inspected.
tool_context: The tool context for the current invocation. Detection is
skipped when the tool is requesting auth or confirmation.
function_response: The raw response returned by the tool.

Returns:
The error type string reported by the tool's `_detect_error_in_response`
hook, or `None` if no error was detected, no hook is defined, or the hook
raised an exception.
"""
try:
if (
tool_context.actions.requested_auth_configs
or tool_context.actions.requested_tool_confirmations
):
return None
detector = getattr(tool, '_detect_error_in_response', None)
if detector is None:
return None
return detector(function_response)
except Exception: # pylint: disable=broad-exception-caught
# Never let telemetry logic break tool execution.
logger.exception(
'Error while detecting error type for telemetry from tool %r.',
getattr(tool, 'name', tool),
)
return None


def _is_live_request_queue_annotation(param: inspect.Parameter) -> bool:
"""Check whether a parameter is annotated as LiveRequestQueue.

Expand Down Expand Up @@ -482,6 +524,7 @@ async def _run_on_tool_error_callbacks(
function_args = (
copy.deepcopy(function_call.args) if function_call.args else {}
)
detected_error_type: Optional[str] = None

tool_context = _create_tool_context(
invocation_context, function_call, tool_confirmation
Expand All @@ -505,7 +548,7 @@ async def _run_on_tool_error_callbacks(
raise tool_error

async def _run_with_trace():
nonlocal function_args
nonlocal function_args, detected_error_type

# Step 1: Check if plugin before_tool_callback overrides the function
# response.
Expand Down Expand Up @@ -586,6 +629,10 @@ async def _run_with_trace():
# the tool returned nothing.
return None

detected_error_type = _detect_error_type_for_telemetry(
tool, tool_context, function_response
)

# Note: State deltas are not applied here - they are collected in
# tool_context.actions.state_delta and applied later when the session
# service processes the events
Expand All @@ -600,6 +647,7 @@ async def _run_with_trace():
tool, agent, function_args
) as tel_ctx:
tel_ctx.function_response_event = await _run_with_trace()
tel_ctx.error_type = detected_error_type
return tel_ctx.function_response_event


Expand Down Expand Up @@ -718,6 +766,7 @@ async def _run_on_tool_error_callbacks(
function_args = (
copy.deepcopy(function_call.args) if function_call.args else {}
)
detected_error_type: Optional[str] = None

tool_context = _create_tool_context(invocation_context, function_call)

Expand All @@ -738,7 +787,7 @@ async def _run_on_tool_error_callbacks(
raise tool_error

async def _run_with_trace():
nonlocal function_args
nonlocal function_args, detected_error_type

# Do not use "args" as the variable name, because it is a reserved keyword
# in python debugger.
Expand Down Expand Up @@ -827,6 +876,10 @@ async def _run_with_trace():
# build when the tool returned nothing.
return None

detected_error_type = _detect_error_type_for_telemetry(
tool, tool_context, function_response
)

# Note: State deltas are not applied here - they are collected in
# tool_context.actions.state_delta and applied later when the session
# service processes the events
Expand All @@ -841,6 +894,7 @@ async def _run_with_trace():
tool, agent, function_args
) as tel_ctx:
tel_ctx.function_response_event = await _run_with_trace()
tel_ctx.error_type = detected_error_type
return tel_ctx.function_response_event


Expand Down
2 changes: 2 additions & 0 deletions src/google/adk/telemetry/_instrumentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ class TelemetryContext:

otel_context: context_api.Context
function_response_event: event_lib.Event | None = None
error_type: str | None = None


def _record_agent_metrics(
Expand Down Expand Up @@ -148,6 +149,7 @@ async def record_tool_execution(
args=function_args,
function_response_event=response_event,
error=caught_error,
error_type=tel_ctx.error_type,
)
finally:
try:
Expand Down
7 changes: 7 additions & 0 deletions src/google/adk/telemetry/tracing.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ def trace_tool_call(
function_response_event: Event | None,
error: Exception | None = None,
span: Span | None = None,
error_type: str | None = None,
):
"""Traces tool call.

Expand All @@ -180,6 +181,10 @@ def trace_tool_call(
function_response_event: The event with the function response details.
error: The exception raised during tool execution, if any.
span: The span to record attributes on. If None, uses current span.
error_type: An error type string detected from the tool's response dict
(e.g., "HTTP_ERROR", "MCP_TOOL_ERROR"). Used when the tool returned an
error as a dict rather than raising an exception. Ignored if `error` is
also set (exception takes precedence).
"""
span = span or trace.get_current_span()

Expand All @@ -196,6 +201,8 @@ def trace_tool_call(
span.set_attribute(ERROR_TYPE, str(error.error_type))
else:
span.set_attribute(ERROR_TYPE, type(error).__name__)
elif error_type is not None:
span.set_attribute(ERROR_TYPE, error_type)

# Special case for client side association with a remote tool call
if (
Expand Down
6 changes: 6 additions & 0 deletions src/google/adk/tools/bash_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,3 +247,9 @@ async def run_async(
"stdout": stdout_res,
"stderr": stderr_res,
}

def _detect_error_in_response(self, response: Any) -> Optional[str]:
"""Telemetry hook: returns an error type if the response indicates an error."""
if isinstance(response, dict) and response.get("error"):
return "TOOL_ERROR"
return None
6 changes: 6 additions & 0 deletions src/google/adk/tools/discovery_engine_search_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,12 @@ def discovery_engine_search(
except GoogleAPICallError as e:
return {'status': 'error', 'error_message': str(e)}

def _detect_error_in_response(self, response: Any) -> Optional[str]:
"""Telemetry hook: returns an error type if the response indicates an error."""
if isinstance(response, dict) and response.get('status') == 'error':
return 'TOOL_ERROR'
return None

def _do_search(
self,
query: str,
Expand Down
24 changes: 24 additions & 0 deletions src/google/adk/tools/environment/_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,12 @@ async def run_async(
result['error'] = f'Command timed out after {DEFAULT_TIMEOUT}s.'
return result

def _detect_error_in_response(self, response: Any) -> Optional[str]:
"""Telemetry hook: returns an error type if the response indicates an error."""
if isinstance(response, dict) and response.get('status') == 'error':
return 'TOOL_ERROR'
return None


@experimental
class ReadFileTool(BaseTool):
Expand Down Expand Up @@ -261,6 +267,12 @@ async def run_async(
except Exception as e:
return {'status': 'error', 'error': str(e)}

def _detect_error_in_response(self, response: Any) -> Optional[str]:
"""Telemetry hook: returns an error type if the response indicates an error."""
if isinstance(response, dict) and response.get('status') == 'error':
return 'TOOL_ERROR'
return None


@experimental
class WriteFileTool(BaseTool):
Expand Down Expand Up @@ -312,6 +324,12 @@ async def run_async(
return {'status': 'error', 'error': str(e)}
return {'status': 'ok', 'message': f'Wrote {path}'}

def _detect_error_in_response(self, response: Any) -> Optional[str]:
"""Telemetry hook: returns an error type if the response indicates an error."""
if isinstance(response, dict) and response.get('status') == 'error':
return 'TOOL_ERROR'
return None


@experimental
class EditFileTool(BaseTool):
Expand Down Expand Up @@ -409,3 +427,9 @@ async def run_async(
new_content = re.sub(pattern, lambda m: new_string, content, count=1)
await self._environment.write_file(path, new_content)
return {'status': 'ok', 'message': f'Edited {path}'}

def _detect_error_in_response(self, response: Any) -> Optional[str]:
"""Telemetry hook: returns an error type if the response indicates an error."""
if isinstance(response, dict) and response.get('status') == 'error':
return 'TOOL_ERROR'
return None
6 changes: 6 additions & 0 deletions src/google/adk/tools/function_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,12 @@ async def run_async(

return await self._invoke_callable(self.func, args_to_call)

def _detect_error_in_response(self, response: Any) -> Optional[str]:
"""Telemetry hook: returns an error type if the response indicates an error."""
if isinstance(response, dict) and response.get('error'):
return 'TOOL_ERROR'
return None

async def _invoke_callable(
self, target: Callable[..., Any], args_to_call: dict[str, Any]
) -> Any:
Expand Down
6 changes: 6 additions & 0 deletions src/google/adk/tools/google_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,12 @@ async def run_async(
"error_details": str(ex),
}

def _detect_error_in_response(self, response: Any) -> Optional[str]:
"""Telemetry hook: returns an error type if the response indicates an error."""
if isinstance(response, dict) and response.get("status") == "ERROR":
return "TOOL_ERROR"
return None

async def _run_async_with_credential(
self,
credentials: Credentials,
Expand Down
6 changes: 6 additions & 0 deletions src/google/adk/tools/mcp_tool/mcp_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,12 @@ async def _run_async_impl(
)
return result

def _detect_error_in_response(self, response: Any) -> Optional[str]:
"""Telemetry hook: returns an error type if the response indicates an error."""
if isinstance(response, dict) and response.get("isError"):
return "MCP_TOOL_ERROR"
return None

def _resolve_progress_callback(
self, tool_context: ToolContext
) -> Optional[ProgressFnT]:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,12 @@ async def call(
self._logger.debug("API Response (non-JSON): %s", response.text)
return {"text": response.text} # Return text if not JSON

def _detect_error_in_response(self, response: Any) -> Optional[str]:
"""Telemetry hook: returns an error type if the response indicates an error."""
if isinstance(response, dict) and response.get("error"):
return "HTTP_ERROR"
return None

def __str__(self):
return (
f'RestApiTool(name="{self.name}", description="{self.description}",'
Expand Down
22 changes: 22 additions & 0 deletions src/google/adk/tools/skill_toolset.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import logging
import mimetypes
from typing import Any
from typing import Optional
from typing import TYPE_CHECKING
import warnings

Expand Down Expand Up @@ -249,6 +250,13 @@ async def run_async(
"frontmatter": skill.frontmatter.model_dump(),
}

def _detect_error_in_response(self, response: Any) -> Optional[str]:
"""Telemetry hook: returns an error type if the response indicates an error."""
if isinstance(response, dict) and response.get("error"):
error_code = response.get("error_code")
return error_code if error_code else "TOOL_ERROR"
return None


@experimental(FeatureName.SKILL_TOOLSET)
class LoadSkillResourceTool(BaseTool):
Expand Down Expand Up @@ -361,6 +369,13 @@ async def run_async(
"content": content,
}

def _detect_error_in_response(self, response: Any) -> Optional[str]:
"""Telemetry hook: returns an error type if the response indicates an error."""
if isinstance(response, dict) and response.get("error"):
error_code = response.get("error_code")
return error_code if error_code else "TOOL_ERROR"
return None

@override
async def process_llm_request(
self, *, tool_context: ToolContext, llm_request: Any
Expand Down Expand Up @@ -873,6 +888,13 @@ async def run_async(
positional_args, # pylint: disable=protected-access
)

def _detect_error_in_response(self, response: Any) -> Optional[str]:
"""Telemetry hook: returns an error type if the response indicates an error."""
if isinstance(response, dict) and response.get("error"):
error_code = response.get("error_code")
return error_code if error_code else "TOOL_ERROR"
return None


@experimental(FeatureName.SKILL_TOOLSET)
class SkillToolset(BaseToolset):
Expand Down
Loading