Skip to content

feat(server-utils): Emit execute_tool spans for Anthropic's beta.sessions.events.toolRunner() - #25554

Merged
RulaKhaled merged 3 commits into
developfrom
feat/anthropic-sessions-tool-runner
Oct 27, 2026
Merged

RulaKhaled merged 3 commits into
developfrom
feat/anthropic-sessions-tool-runner

Conversation

@RulaKhaled

Copy link
Copy Markdown
Collaborator

beta.sessions.events.toolRunner() runs the user's tools for a Managed Agents session, but its tool calls got no span. The runner turns a throw into an error result for the model, so nobody saw a failing tool either. #25320 only covered beta.messages.toolRunner().

The session runner calls the same run(input, ctx) as the messages runner and keeps its tools on a public tools field, so this reuses the wrapping from #25320. It has no addTools(), so it can only run the tools it was created with. On the orchestrion path the hook sits on the SessionToolRunner constructor instead of toolRunner(), because the SDK's EnvironmentWorker creates runners with new SessionToolRunner() directly. instrumentAnthropicAiClient can only wrap toolRunner(), so it doesn't see those runners. Supported from SDK 0.97.0, the first version with the session runner.

Known limitation. A tool that stops on the session runner's 120s timeout is recorded as cancelled, like a user abort, because the SDK aborts the same signal for both.

Fixes #25446

🤖 Generated with Claude Code

…ions.events.toolRunner()

`beta.sessions.events.toolRunner()` runs the user's tools for a Managed Agents session, but its tool calls got no span. The runner turns a throw into an error result for the model, so nobody saw a failing tool either. #25320 only covered `beta.messages.toolRunner()`.

The session runner calls the same `run(input, ctx)` as the messages runner and keeps its tools on a public `tools` field, so this reuses the wrapping from #25320. It has no `addTools()`, so it can only run the tools it was created with. On the orchestrion path the hook sits on the `SessionToolRunner` constructor instead of `toolRunner()`, because the SDK's `EnvironmentWorker` creates runners with `new SessionToolRunner()` directly. `instrumentAnthropicAiClient` can only wrap `toolRunner()`, so it doesn't see those runners. Supported from SDK 0.97.0, the first version with the session runner.

Fixes #25446

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

github-actions Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

size-limit report 📦

Compared: bd1c3d5 (PR test merge using develop at 7030f21)

Against: 7030f21 (develop) · base run #63715

✅ Baseline matches: this diff contains only this PR's changes.

Path Size % Change Change
@sentry/browser 30.02 kB - -
@sentry/browser - with treeshaking flags 28.16 kB - -
@sentry/browser - with treeshaking flags tracing without tracing 28.06 kB - -
@sentry/browser (incl. Tracing) 52.21 kB - -
@sentry/browser (incl. Tracing + Span Streaming) 52.22 kB - -
@sentry/browser (incl. Tracing, Profiling) 55.14 kB - -
@sentry/browser (incl. Tracing, Replay) 91.98 kB - -
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags 80.89 kB - -
@sentry/browser (incl. Tracing, Replay with Canvas) 96.72 kB - -
@sentry/browser (incl. Tracing, Replay, Feedback) 109.7 kB - -
@sentry/browser (incl. Feedback) 47.58 kB - -
@sentry/browser (incl. sendFeedback) 35.09 kB - -
@sentry/browser (incl. FeedbackAsync) 40.19 kB - -
@sentry/browser (incl. Metrics) 31.04 kB - -
@sentry/browser (incl. Logs) 31.32 kB - -
@sentry/browser (incl. Metrics & Logs) 31.99 kB - -
@sentry/react 31.84 kB - -
@sentry/react (incl. Tracing) 54.52 kB - -
@sentry/vue 38.17 kB - -
@sentry/vue (incl. Tracing) 55.12 kB - -
@sentry/svelte 30.04 kB - -
@sentry/remix (Remix 3 client bundle) 57.24 kB - -
CDN Bundle 31.79 kB - -
CDN Bundle (incl. Tracing) 52.73 kB - -
CDN Bundle (incl. Logs, Metrics) 33.99 kB - -
CDN Bundle (incl. Tracing, Logs, Metrics) 54.7 kB - -
CDN Bundle (incl. Replay, Logs, Metrics) 74.96 kB - -
CDN Bundle (incl. Tracing, Replay) 90.45 kB - -
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) 92.36 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback) 96.56 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) 98.53 kB - -
CDN Bundle - uncompressed 93.62 kB - -
CDN Bundle (incl. Tracing) - uncompressed 156.47 kB - -
CDN Bundle (incl. Logs, Metrics) - uncompressed 100.17 kB - -
CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed 162.44 kB - -
CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed 230.39 kB - -
CDN Bundle (incl. Tracing, Replay) - uncompressed 276.81 kB - -
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed 282.77 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed 290.51 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed 296.46 kB - -
@sentry/nextjs (client) 57.42 kB - -
@sentry/sveltekit (client) 52.56 kB - -
@sentry/core/server 43.66 kB - -
@sentry/core/browser 13.81 kB - -
@sentry/node 154.13 kB +0.06% +92 B 🔺
@sentry/node/import (ESM hook with diagnostics-channel injection) 81.61 kB +0.05% +35 B 🔺
@sentry/node - without tracing 95.57 kB +0.06% +52 B 🔺
@sentry/node - without channel injection 134.78 kB +0.06% +78 B 🔺
@sentry/aws-serverless 103.73 kB +0.05% +43 B 🔺
@sentry/cloudflare (withSentry) - minified 197.4 kB - -
@sentry/cloudflare (withSentry) 490.4 kB - -
@sentry/nextjs/cloudflare (withSentry) - minified 217.98 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.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit da32a71. Configure here.

@RulaKhaled
RulaKhaled marked this pull request as ready for review October 20, 2026 12:59
@RulaKhaled
RulaKhaled requested a review from a team as a code owner October 20, 2026 12:59
@RulaKhaled
RulaKhaled requested review from JPeer264 and isaacs and removed request for a team October 20, 2026 12:59
@github-actions

Copy link
Copy Markdown
Contributor

👋 @isaacs, @JPeer264 — Please review this PR when you get a chance!

@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Optional E2E tests

Some optional checks were not successful. They do not block the merge. 9 not successful, 56 successful. See the run.

Check Status
❌ CI: Optional E2E Tests / E2E cloudflare-flue-send-to-sentry Test (optional) Failing after 5m Details
❌ CI: Optional E2E Tests / E2E supabase-nextjs Test (optional) Failing after 1m Details
❌ CI: Optional E2E Tests / E2E node-openai-send-to-sentry Test (optional) Failing after 3m Details
❌ CI: Optional E2E Tests / E2E node-express-send-to-sentry-static Test (optional) Failing after 3m Details
❌ CI: Optional E2E Tests / E2E node-anthropic-send-to-sentry (latest) Test (optional) Failing after 13s Details
❌ CI: Optional E2E Tests / E2E node-openai-decisions-send-to-sentry Test (optional) Failing after 5m Details
❌ CI: Optional E2E Tests / E2E node-openai-decisions-send-to-sentry (latest) Test (optional) Failing after 4m Details
❌ CI: Optional E2E Tests / E2E node-eve (latest) Test (optional) Failing after 2m Details
❌ CI: Optional E2E Tests / E2E node-anthropic-send-to-sentry Test (optional) Failing after 3m Details
Successful checks
Check Status
✅ CI: Optional E2E Tests / E2E debug-id-sourcemaps Test (optional) Successful in 1m Details
✅ CI: Optional E2E Tests / E2E cloudflare-think Test (optional) Successful in 1m Details
✅ CI: Optional E2E Tests / E2E nextjs-14 (latest) Test (optional) Successful in 2m Details
✅ CI: Optional E2E Tests / E2E node-flue (latest) Test (optional) Successful in 1m Details
✅ CI: Optional E2E Tests / E2E nextjs-16 (vinext, bun) Test (optional) Successful in 1m Details
✅ CI: Optional E2E Tests / E2E cloudflare-workers-send-to-sentry Test (optional) Successful in 6m Details
✅ CI: Optional E2E Tests / E2E deno (latest) Test (optional) Successful in 44s Details
✅ CI: Optional E2E Tests / E2E astro-7 Test (optional) Successful in 55s Details
✅ CI: Optional E2E Tests / E2E astro-7-static Test (optional) Successful in 53s Details
✅ CI: Optional E2E Tests / E2E nextjs-15 (latest) Test (optional) Successful in 2m Details
✅ CI: Optional E2E Tests / E2E gen-ai-libraries Test (optional) Successful in 49s Details
✅ CI: Optional E2E Tests / E2E vue-3 (canary) Test (optional) Successful in 1m Details
✅ CI: Optional E2E Tests / E2E nuxt-4 (canary) Test (optional) Successful in 2m Details
✅ CI: Optional E2E Tests / E2E nextjs-16 (cloudflare, latest) Test (optional) Successful in 3m Details
✅ CI: Optional E2E Tests / E2E nextjs-15 (turbo) Test (optional) Successful in 2m Details
✅ CI: Optional E2E Tests / E2E node-openai-send-to-sentry (latest) Test (optional) Successful in 5m Details
✅ CI: Optional E2E Tests / E2E nextjs-16-tunnel (cloudflare) Test (optional) Successful in 51s Details
✅ CI: Optional E2E Tests / E2E node-pi-durable (latest) Test (optional) Successful in 1m Details
✅ CI: Optional E2E Tests / E2E angular (canary) Test (optional) Successful in 55s Details
✅ CI: Optional E2E Tests / E2E nextjs-16 (canary, webpack) Test (optional) Successful in 6m Details
✅ CI: Optional E2E Tests / E2E nuxt-4-static (canary) Test (optional) Successful in 6m Details
✅ CI: Optional E2E Tests / E2E nextjs-16 (canary, turbopack) Test (optional) Successful in 5m Details
✅ CI: Optional E2E Tests / E2E node-express-mcp-v2 Test (optional) Successful in 42s Details
✅ CI: Optional E2E Tests / E2E nuxt-5-sourcemaps (client source maps kept) Test (optional) Successful in 30s Details
✅ CI: Optional E2E Tests / E2E nextjs-16 (vinext) Test (optional) Successful in 2m Details
✅ CI: Optional E2E Tests / E2E cloudflare-pi-durable Test (optional) Successful in 2m Details
✅ CI: Optional E2E Tests / E2E cloudflare-mastra Test (optional) Successful in 1m Details
✅ CI: Optional E2E Tests / E2E node-pi-durable Test (optional) Successful in 33s Details
✅ CI: Optional E2E Tests / E2E solidstart-2 Test (optional) Successful in 44s Details
✅ CI: Optional E2E Tests / E2E nextjs-16-standalone (canary, turbopack) Test (optional) Successful in 1m Details
✅ CI: Optional E2E Tests / E2E react-router-7-framework-instrumentation Test (optional) Successful in 1m Details
✅ CI: Optional E2E Tests / E2E node-flue Test (optional) Successful in 1m Details
✅ CI: Optional E2E Tests / E2E nuxt-5-sourcemaps Test (optional) Successful in 36s Details
✅ CI: Optional E2E Tests / E2E node-mastra Test (optional) Successful in 1m Details
✅ CI: Optional E2E Tests / E2E nuxt-3 (canary) Test (optional) Successful in 2m Details
✅ CI: Optional E2E Tests / E2E node-mastra (latest) Test (optional) Successful in 3m Details
✅ CI: Optional E2E Tests / E2E node-fastify-6 (tracer provider) Test (optional) Successful in 25s Details
✅ CI: Optional E2E Tests / E2E nextjs-orpc Test (optional) Successful in 3m Details
✅ CI: Optional E2E Tests / E2E nuxt-5-sourcemaps (static / nuxt generate) Test (optional) Successful in 52s Details
✅ CI: Optional E2E Tests / E2E nextjs-16 (deno) Test (optional) Successful in 3m Details
✅ CI: Optional E2E Tests / E2E cloudflare-think (ai v6) Test (optional) Successful in 1m Details
✅ CI: Optional E2E Tests / E2E react-send-to-sentry Test (optional) Successful in 6m Details
✅ CI: Optional E2E Tests / E2E node-eve Test (optional) Successful in 59s Details
✅ CI: Optional E2E Tests / E2E cloudflare-pi-durable (latest) Test (optional) Successful in 48s Details
✅ CI: Optional E2E Tests / E2E node-express-mcp-v1 Test (optional) Successful in 1m Details
✅ CI: Optional E2E Tests / E2E node-express-send-to-sentry Test (optional) Successful in 2m Details
✅ CI: Optional E2E Tests / E2E nuxt-5 Test (optional) Successful in 2m Details
✅ CI: Optional E2E Tests / E2E nextjs-14 (canary) Test (optional) Successful in 3m Details
✅ CI: Optional E2E Tests / E2E angular (canary) Test (optional) Successful in 1m Details
✅ CI: Optional E2E Tests / E2E gen-ai-libraries (cloudflare) Test (optional) Successful in 32s Details
✅ CI: Optional E2E Tests / E2E tanstackstart-react-sourcemaps Test (optional) Successful in 43s Details
✅ CI: Optional E2E Tests / E2E tanstackstart-react-cloudflare Test (optional) Successful in 31s Details
✅ CI: Optional E2E Tests / E2E node-fastify-6 Test (optional) Successful in 5m Details
✅ CI: Optional E2E Tests / E2E cloudflare-agent Test (optional) Successful in 1m Details
✅ CI: Optional E2E Tests / E2E node-eve (orchestrion) Test (optional) Successful in 51s Details
✅ CI: Optional E2E Tests / E2E vue-3 (no Options API) Test (optional) Successful in 1m Details

@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.

Two small things that might be worth looking at, and/or posting a follow-up about. LGTM!


function handleToolError(span: Span, error: unknown, context: RunContext): void {
if (context?.signal?.aborted) {
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'cancelled' });

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.

(low/preexisting)

A timeout gets recorded as cancelled, and the tool's actual error is dropped. The PR body lists this as a known limitation. It has a second effect: handleToolError returns early when the signal is aborted, so captureException never runs. A tool that hangs for 120s and then throws (or any tool that throws after the per-tool timeout) produces no error event. For a tool that hangs, a timeout is the most useful failure to see.

A closure over the runner won't fix it. You could compare runner.signal.aborted with ctx.signal.aborted, but EnvironmentWorker can pass the same tools array to every session's runner. instrumentTools wraps each tool only once, so a closure would hold the first runner only.

Suggestion: keep the limitation for now. We could ask upstream to abort with a reason (for example, toolCtrl.abort(new DOMException('...', 'TimeoutError'))). Then handleToolError could map a TimeoutError reason to deadline_exceeded and still capture it.

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.

I agree, keeping it as the known limitation. I'll open an issue on the SDK asking for a TimeoutError reason on the per-tool abort, then mapping it to deadline_exceeded and capturing is a one line on our side

/** Give each tool call of a Managed Agents session runner a `gen_ai.execute_tool` span. */
export function instrumentSessionToolRunner(runner: unknown, options: AnthropicAiOptions): void {
// The session runner has no `addTools()`: it only runs the tools it was created with.
instrumentTools((runner as SessionToolRunner | undefined)?.tools, options);

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.

(low)

instrumentToolRunner starts with if (!runner || typeof runner !== 'object') return;. instrumentSessionToolRunner instead does (runner as SessionToolRunner | undefined)?.tools. Both are safe, because instrumentTools rejects anything that is not an array. But the two sibling functions read differently.

Suggestion: use the same early return in both, or drop it from instrumentToolRunner if instrumentTools is the real guard.

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.

fixed! used the same early return in both. couldn't drop it from instrumentToolRunner, wrapInPlace(runner, 'addTools', …) reads runner[key] so it'd throw on null

functionQuery: { className: 'Messages', methodName: 'toolRunner', kind: 'Sync' as const },
})),
// The constructor, not `sessions.events.toolRunner()`, so runners built directly (e.g. by `EnvironmentWorker`) are covered.
...['lib/tools/SessionToolRunner.js', 'lib/tools/SessionToolRunner.mjs'].map(filePath => ({

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.

(Should've been in my review, but I forgot to hit the button first 😅)

The constructor hook targets EnvironmentWorker. But the worker drives each runner from its own poll loop (lib/environments/worker.mjs, for await (const _ of runner)), and nothing in user code wraps that loop in a span. So each tool call becomes its own root span (its own segment under span streaming). A busy session would produce lots of separate execute_tool segments, but not tied to the session, because the agent.tool_use event has no session id, and the span sets no gen_ai.conversation.id, which may be noisy and hard to use.

Suggestion: we could consider a parent span per runner iteration? For example, wrap the runner's [Symbol.asyncIterator] in an invoke_agent-style span that sets gen_ai.conversation.id from runner.sessionId, so the tool spans nest under it. Or if that's out of scope, add a line to the PR body that says EnvironmentWorker tool calls show up as root spans, and open a follow-up issue.

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.

Makes sense to me! fwiw anthropic's own otel tracing puts one anthropic.environment.work span around each session and nests the tool spans under it, so a per runner span sounds right. opened #25768 for it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@RulaKhaled
RulaKhaled merged commit fe5b952 into develop Oct 27, 2026
376 of 385 checks passed
@RulaKhaled
RulaKhaled deleted the feat/anthropic-sessions-tool-runner branch October 27, 2026 15:49
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.sessions.events.toolRunner()

2 participants