Fix: record the tool exception on the execute_tool span - #710
Open
AmaadMartin wants to merge 2 commits into
Open
Fix: record the tool exception on the execute_tool span#710AmaadMartin wants to merge 2 commits into
AmaadMartin wants to merge 2 commits into
Conversation
added 2 commits
August 6, 2026 02:51
A tool that throws leaves its execute_tool span with status UNSET, no exception event and no gen_ai.tool.* attributes, so every OTel backend renders the failed call as a success. traceToolCall now accepts the thrown value and, when present, records the exception, sets error.type and sets an ERROR status. callToolAsync calls it from a catch block and rethrows the value unchanged. This matches adk-python's trace_tool_call error handling.
resolveErrorType gets unit cases for each precedence rule. traceToolCall gets cases for the error path and a regression guard on the success path. A new file drives handleFunctionCallList through a NodeTracerProvider and asserts the exported span, which is the integration-level proof that the wiring works end to end.
7 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Please ensure you have read the contribution guide before creating a pull request.
Link to Issue or Description of Change
Link to an existing issue (if applicable):
Or, if no issue exists, describe the change:
Problem:
callToolAsynchas nocatch, so a tool that throws leaves itsexecute_toolspan with statusUNSET, noexceptionevent and none of thegen_ai.tool.*attributes. Every OTel backend renders that span as a success, which makes tool failures invisible in Cloud Trace, in an OTLP collector and in the dev UI.adk-pythondoes not have this gap:trace_tool_calltakes anerrorargument and records it.Solution:
traceToolCallnow accepts the thrown value. When it is present the function records the exception, setserror.typeand sets anERRORstatus.callToolAsynccalls it from acatchblock and rethrows the value unchanged, so telemetry stays an observation and never becomes control flow. A newresolveErrorTypeincore/src/utils/error_utils.tsderives the label with the reference's precedence: a self-classifiederrorType, then an HTTP status, then the class name.Design notes
instanceof ApiError. The repo bansinstanceoffor SDK class detection because it fails when two copies of a package share one runtime. A duck-typed check needs a plausibility bound, so the status must fall in the existingMIN_HTTP_STATUS/MAX_HTTP_STATUSrange. Without it an unrelatedstatus: 0field would be reported as an HTTP code.resolveErrorTypeuseserror.constructor.name, mirroring Python'stype(error).__name__, so a subclass that never assignsthis.namestill reports its own class. The publishedcorebuild is not minified (core/build.jssetsminify: bundle, true only for the optionalbuild:bundletarget), so this is stable for the default artifact. A consumer who minifies a bundle gets mangled class names.shouldAddRequestResponseToSpanscannot gate.errorTypeparameter, because nothing in this change would read it. Any future response-derived classification must be evaluated only whenerroris absent.invocation,invoke_agentandcall_llmspans have the same gap. They are out of scope here and queued separately.Collision check (
gh pr list --repo AmaadMartin/adk-js --state open --limit 1000)No open PR records the thrown exception on the
execute_toolspan. Three overlap and none of them lands this change:resolveErrorTypeto the same file for the metrics port. Its version readserror.name || error.constructor.nameand does not bound the status, so it reports'0'for{status: 0}. This PR does not stack on it: Feat: Port TokenUsage and resolveErrorType from adk-python (Part 1/2) #399 is 39 commits behindmain, and stacking would disable CI and force me to edit a sibling PR's helper. Merge either one first, then drop the duplicate function.ERROR_TYPEconstant and an error block totraceToolCall, keyed on a response-derivederrorType. It is the response path; this is the exception path. Whichever merges second keeps one copy of the constant.catchtocallToolAsyncfor metrics. It records duration, not the span.Testing Plan
Unit Tests:
core/test/telemetry/tool_exception_span_test.tsdriveshandleFunctionCallListthrough a realNodeTracerProvider,SimpleSpanProcessorandInMemorySpanExporter, and asserts the exported span. That is the integration-level signal, so I added notests/integration/**file: a separate one would duplicate it at a much higher runtime cost. This was a decision, not an omission.Coverage:
core/src/utils/error_utils.tsis at 100% line and branch. Every line and branch this change adds totracing.tsandfunctions.tsis covered.@vitest/coverage-v8reports one uncovered synthetic branch on thefinallyincallToolAsync, which is a pre-existing block that ends the span on every path. I kept it.Proof the tests can fail. I ran each mutation against the new tests and recorded the failure:
catchincallToolAsyncexpected +0 to be 2throw e->return undefinedexpected { result: undefined } to deeply equal { error: 'sku not found' }span.setStatus, keep the attributeexpected "spy" to be called with arguments: [ { code: 2, message: 'TypeError' } ]errorTypeandstatusbranchesexpected '429' to be 'QUOTA_EXCEEDED'error !== undefinedguardexpected 2 not to be 2expected '0' to be 'MyToolError'constructor.name->nameexpected 'Error' to be 'MyToolError'Manual End-to-End (E2E) Tests:
execute_tool <name>span is marked failed, shows anexceptionevent with the thrown message, and carrieserror.type.Checklist