Skip to content

feat(server-utils): Emit execute_tool spans for Anthropic's beta.messages.toolRunner() - #25320

Merged
RulaKhaled merged 9 commits into
developfrom
feat/anthropic-tool-runner-execute-tool-spans
Oct 16, 2026
Merged

RulaKhaled merged 9 commits into
developfrom
feat/anthropic-tool-runner-execute-tool-spans

Conversation

@RulaKhaled

@RulaKhaled RulaKhaled commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

beta.messages.toolRunner() runs the user's tools, but Sentry only recorded its chat calls. Tool calls got no span, and when a tool threw, the runner turned the error into an is_error result for the model, so nobody saw it.

This wraps each tool's run() in place with fill(). When a tool throws, its span is marked as an error. The exception is captured as handled only when the call came from a runner. A direct call rethrows to its caller, so it isn't captured, and neither are intentional ToolError results and aborted calls. Toolsets are skipped, since their run takes (ctx, toolUse). sessions.events.toolRunner() isn't covered yet.

Fixes #25288

🤖 Generated with Claude Code

RulaKhaled and others added 3 commits October 13, 2026 13:33
…essages.toolRunner()`

Wraps each runnable tool's `run()` in place with `fill()`, covering tools
passed to `toolRunner()`, `addTools()` and `setMessagesParams()`, on both the
orchestrion and `instrumentAnthropicAiClient` paths. A tool that throws gets an
error span and a captured event, since the runner hides the error from the app.

Fixes #25288

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… setting them to undefined

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…echanism type

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

size-limit report 📦

Path Size % Change Change
@sentry/browser 29.88 kB - -
@sentry/browser - with treeshaking flags 28.02 kB - -
@sentry/browser - with treeshaking flags tracing without tracing 27.92 kB - -
@sentry/browser (incl. Tracing) 52.07 kB - -
@sentry/browser (incl. Tracing + Span Streaming) 52.07 kB - -
@sentry/browser (incl. Tracing, Profiling) 55.02 kB - -
@sentry/browser (incl. Tracing, Replay) 91.82 kB - -
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags 80.68 kB - -
@sentry/browser (incl. Tracing, Replay with Canvas) 96.55 kB - -
@sentry/browser (incl. Tracing, Replay, Feedback) 109.49 kB - -
@sentry/browser (incl. Feedback) 47.4 kB - -
@sentry/browser (incl. sendFeedback) 34.94 kB - -
@sentry/browser (incl. FeedbackAsync) 40.05 kB - -
@sentry/browser (incl. Metrics) 30.9 kB - -
@sentry/browser (incl. Logs) 31.18 kB - -
@sentry/browser (incl. Metrics & Logs) 31.81 kB - -
@sentry/react 31.7 kB - -
@sentry/react (incl. Tracing) 54.39 kB - -
@sentry/vue 38.04 kB - -
@sentry/vue (incl. Tracing) 55.01 kB - -
@sentry/svelte 29.9 kB - -
@sentry/remix (Remix 3 client bundle) 57.08 kB - -
CDN Bundle 31.64 kB - -
CDN Bundle (incl. Tracing) 52.58 kB - -
CDN Bundle (incl. Logs, Metrics) 33.84 kB - -
CDN Bundle (incl. Tracing, Logs, Metrics) 54.52 kB - -
CDN Bundle (incl. Replay, Logs, Metrics) 74.77 kB - -
CDN Bundle (incl. Tracing, Replay) 90.25 kB - -
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) 92.18 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback) 96.4 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) 98.34 kB - -
CDN Bundle - uncompressed 93.18 kB - -
CDN Bundle (incl. Tracing) - uncompressed 156.03 kB - -
CDN Bundle (incl. Logs, Metrics) - uncompressed 99.72 kB - -
CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed 161.99 kB - -
CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed 229.76 kB - -
CDN Bundle (incl. Tracing, Replay) - uncompressed 276.2 kB - -
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed 282.14 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed 289.9 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed 295.84 kB - -
@sentry/nextjs (client) 56.8 kB - -
@sentry/sveltekit (client) 52.45 kB - -
@sentry/core/server 41.12 kB - -
@sentry/core/browser 13.81 kB - -
@sentry/node 152.85 kB +0.26% +391 B 🔺
@sentry/node/import (ESM hook with diagnostics-channel injection) 83.84 kB +0.03% +25 B 🔺
@sentry/node - without tracing 94.64 kB +0.03% +28 B 🔺
@sentry/node - without channel injection 131.08 kB +0.3% +390 B 🔺
@sentry/aws-serverless 102.8 kB +0.04% +31 B 🔺
@sentry/cloudflare (withSentry) - minified 210.6 kB - -
@sentry/cloudflare (withSentry) 522.53 kB - -
@sentry/nextjs/cloudflare (withSentry) - minified 228.27 kB - -

View base workflow run

@RulaKhaled

Copy link
Copy Markdown
Collaborator Author

bugbot run

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@RulaKhaled
RulaKhaled marked this pull request as ready for review October 13, 2026 12:16
@RulaKhaled
RulaKhaled requested a review from a team as a code owner October 13, 2026 12:16
@RulaKhaled
RulaKhaled requested review from andreiborza, isaacs, mydea and nicohrubec and removed request for a team October 13, 2026 12:16
Comment thread packages/server-utils/src/ai/anthropic-ai/tool-runner.ts Outdated

@isaacs isaacs left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrapipng tool objects in place rather than wrapping the runner or proxying is the right choice for this SDK: the runner uses private class fields, the client code already avoids Proxies for that reason, and tool.run is the one call point that is stable from 0.63 to 0.133.

Capturing the swallowed error is the useful part of the change, since otherwise those errors disappear.

Open questions are what counts as "handled", and keeping the run contract narrow enough that new kinds of tools entries, like toolsets, don't get wrapped by accident. Otherwise LGTM!

Comment thread packages/server-utils/src/ai/anthropic-ai/tool-runner.ts Outdated
Comment thread packages/server-utils/src/ai/anthropic-ai/tool-runner.ts Outdated
Comment thread packages/server-utils/src/ai/anthropic-ai/tool-runner.ts
Comment thread packages/server-utils/src/ai/anthropic-ai/tool-runner.ts Outdated
RulaKhaled and others added 2 commits October 14, 2026 13:05
…rors as handled

Toolsets are nameless `tools` entries whose `run` takes `(ctx, toolUse)`, so
only entries with a string `name` are wrapped now. Tool errors the runner hands
back to the model are captured with `handled: true`, as Flue and Mastra do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 5e0cfc3. Configure here.

Comment thread packages/server-utils/src/ai/anthropic-ai/tool-runner.ts
…on older SDKs

SDK 0.85 to 0.96 pass only `toolUseBlock` to a tool's `run()`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@isaacs isaacs left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks great!

Anthropic SDK 0.133 adds client.beta.sessions.events.toolRunner(sessionID, { tools }) (lib/tools/SessionToolRunner.ts), which also runs BetaRunnableTools. Those tools get no spans unless the same objects were also given to a beta.messages.toolRunner() (in which case they get spans with the wrong framing, see 1). I think that's out of scope for this PR, but worth a follow-up issue so the PR body saying "the tool runner" isn't read as full coverage.

Comment thread packages/server-utils/src/orchestrion/config/anthropic-ai.ts Outdated
// Handled: the runner turns the throw into an error result for the model, so the app never sees it.
// A `ToolError` is the SDK's way to send that result on purpose.
if ((error as Error | undefined)?.name !== 'ToolError') {
captureException(error, { mechanism: { handled: true, type: ANTHROPIC_AI_ORIGIN } });

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, fill replaces run on the user's own tool object for the life of the process. Tool objects are often module-level constants, reused in other places: a direct call in app code or tests, an MCP server, another agent framework, or client.beta.sessions.events.toolRunner().

Every such call now gets an execute_tool span with origin: auto.ai.anthropic, and any throw gets captured with mechanism: { handled: true, type: 'auto.ai.anthropic' }. In those contexts the caller does see the error, so "handled by the Anthropic runner" is wrong. The __sentry_captured__ guard stops a second capture when the app also reports it, but the first (wrong) mechanism wins.

There is no clean way to tell a runner call from a direct call on 0.63 to 0.80 (no context arg). On 0.85+ the runner always passes a context object with toolUseBlock.

I don't think we really need to change the code here, but we might want to document this in the PR body and/or a code comment, since it's a deliberate tradeoff. Maybe we could only capture when context?.toolUse ?? context?.toolUseBlock is present, which is true for every runner call on 0.85+ and false for most direct calls. That would keep the span everywhere but limit the "handled" capture to calls we know came from the runner. Older SDKs would then lose capture, which may be acceptable, or keep capture there unconditionally.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yah you're right on all, I like your proposal, I took your gate to capture only with toolUse/toolUseBlock, span stays everywhere. 0.63 to 0.82 call run(input) bare, so those keep the error span but no event. fwiw the sessions runner also passes that context and swallows, so handled: true is right there

RulaKhaled and others added 2 commits October 16, 2026 13:07
`fill` keeps the tool's `run` wrapped after the runner is done, so a direct
call from app code also went through it and its error was captured as handled
by the runner, dropping the app's own capture. Only runner calls pass a
`toolUse`/`toolUseBlock` context (SDK 0.85+), so capture is limited to those,
while the span stays on every call. Also narrows the `toolRunner` hook to
SDK 0.63+, where the method first ships.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@RulaKhaled
RulaKhaled merged commit 28a69bc into develop Oct 16, 2026
562 of 572 checks passed
@RulaKhaled
RulaKhaled deleted the feat/anthropic-tool-runner-execute-tool-spans branch October 16, 2026 11:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(server-utils): Emit execute_tool spans for Anthropic's beta.messages.toolRunner()

3 participants