Skip to content

feat(server-utils): Capture errors thrown in Mastra - #24374

Open
mydea wants to merge 2 commits into
developfrom
feat/mastra-error-capture
Open

mydea wants to merge 2 commits into
developfrom
feat/mastra-error-capture

Conversation

@mydea

@mydea mydea commented Sep 14, 2026

Copy link
Copy Markdown
Member

Stacked on #24368.

When a Mastra tool or model operation throws, the exporter only reflected it on the span (error status + a stack-less error.type, from Mastra's serialized errorInfo) — the error never became a Sentry issue.

Mastra runs each operation's work inside executeWithContext({ span, fn }) (the same seam #24368 uses for nesting). When fn rejects, that channel's error event carries the real Error, stack and all. The integration now subscribes to it and captures the error with captureException (mechanism auto.ai.mastra, handled: false), inside withActiveSpan of the exporter's span for that operation so the issue lands on the right trace. captureException dedupes on the error instance, so an error re-thrown through outer operations is captured only once.

Capturing needs no async-context binding, so it rides the attach-only path next to the exporter registration (not gated behind waitForTracingChannelBinding).

Root cause: the exporter receives errorInfo ({ name, message }), not the thrown Error, so it can only set span status — there's no stack to build a real issue from. The actual Error is only observable at the throw site, which executeWithContext's channel error surfaces.

Verified in the node integration suite (Node 22, ESM + CJS): a thrown tool error is captured as an issue with type: Error, the real message, a non-empty stack, and mechanism auto.ai.mastra/handled: false. The node-mastra e2e error test now also asserts the captured issue (not just the errored span), and the corresponding TODO is dropped.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

size-limit report 📦

Path Size % Change Change
@sentry/browser 28.96 kB - -
@sentry/browser - with treeshaking flags 27.26 kB - -
@sentry/browser - with treeshaking flags tracing without tracing 27.15 kB - -
@sentry/browser (incl. Tracing) 50.51 kB - -
@sentry/browser (incl. Tracing + Span Streaming) 50.52 kB - -
@sentry/browser (incl. Tracing, Profiling) 53.5 kB - -
@sentry/browser (incl. Tracing, Replay) 90.07 kB - -
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags 79.16 kB - -
@sentry/browser (incl. Tracing, Replay with Canvas) 94.77 kB - -
@sentry/browser (incl. Tracing, Replay, Feedback) 107.73 kB - -
@sentry/browser (incl. Feedback) 46.46 kB - -
@sentry/browser (incl. sendFeedback) 34.01 kB - -
@sentry/browser (incl. FeedbackAsync) 39.12 kB - -
@sentry/browser (incl. Metrics) 29.98 kB - -
@sentry/browser (incl. Logs) 30.24 kB - -
@sentry/browser (incl. Metrics & Logs) 30.91 kB - -
@sentry/react 30.72 kB - -
@sentry/react (incl. Tracing) 52.81 kB - -
@sentry/vue 36.2 kB - -
@sentry/vue (incl. Tracing) 52.76 kB - -
@sentry/svelte 28.98 kB - -
CDN Bundle 30.7 kB - -
CDN Bundle (incl. Tracing) 51.01 kB - -
CDN Bundle (incl. Logs, Metrics) 32.98 kB - -
CDN Bundle (incl. Tracing, Logs, Metrics) 52.99 kB - -
CDN Bundle (incl. Replay, Logs, Metrics) 73.67 kB - -
CDN Bundle (incl. Tracing, Replay) 88.56 kB - -
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) 90.53 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback) 94.64 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) 96.63 kB - -
CDN Bundle - uncompressed 90.83 kB - -
CDN Bundle (incl. Tracing) - uncompressed 152.33 kB - -
CDN Bundle (incl. Logs, Metrics) - uncompressed 97.41 kB - -
CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed 158.29 kB - -
CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed 226.82 kB - -
CDN Bundle (incl. Tracing, Replay) - uncompressed 271.9 kB - -
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed 277.85 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed 285.6 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed 291.54 kB - -
@sentry/nextjs (client) 55.13 kB - -
@sentry/sveltekit (client) 50.93 kB - -
@sentry/core/server 37.13 kB - -
@sentry/core/browser 13.66 kB - -
@sentry/node 130.8 kB +0.13% +162 B 🔺
@sentry/node/import (ESM hook with diagnostics-channel injection) 81.88 kB - -
@sentry/node - without tracing 89.7 kB +0.04% +27 B 🔺
@sentry/node - without channel injection 109.71 kB +0.16% +169 B 🔺
@sentry/aws-serverless 97.92 kB +0.03% +21 B 🔺
@sentry/cloudflare (withSentry) - minified 203.36 kB - -
@sentry/cloudflare (withSentry) 506.44 kB - -

View base workflow run

@mydea
mydea added this pull request to stack #24369 September 14, 2026 12:41
@mydea
mydea force-pushed the feat/mastra-error-capture branch from 30f6e44 to a2593c6 Compare September 14, 2026 12:43
RulaKhaled added a commit that referenced this pull request Sep 14, 2026
Matches #24374: mechanism `auto.ai.flue` with `handled: false`, and the capture
runs under the operation's own span so the issue lands on the right trace.

Unlike Mastra, the rebuild from `errorInfo` stays. Mastra's `errorInfo` is
`{ name, message }` with no stack, which is why it has to reach for the channel's
real `Error`; Flue's carries the original stack, so there is nothing to gain from
a second seam that might not fire on every path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mydea
mydea marked this pull request as ready for review September 14, 2026 14:26
@mydea
mydea requested review from a team as code owners September 14, 2026 14:26
@mydea
mydea requested review from a team, JPeer264, RulaKhaled and isaacs and removed request for a team September 14, 2026 14:26
@mydea mydea changed the title feat(server-utils): Capture errors thrown in Mastra as Sentry issues feat(server-utils): Capture errors thrown in Mastra Sep 14, 2026
RulaKhaled added a commit that referenced this pull request Sep 14, 2026
Matches #24374: mechanism `auto.ai.flue` with `handled: false`, and the capture
runs under the operation's own span so the issue lands on the right trace.

Unlike Mastra, the rebuild from `errorInfo` stays. Mastra's `errorInfo` is
`{ name, message }` with no stack, which is why it has to reach for the channel's
real `Error`; Flue's carries the original stack, so there is nothing to gain from
a second seam that might not fire on every path.

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

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

Got a couple of points, could be I'm missing something but I see a potential issue here when it comes to reporting errors off the tracing channel error event which we do not generally do.

const span = id ? getSentrySpanForMastraId(id) : undefined;
// `captureException` dedupes on the error instance, so the same error re-thrown through outer
// `executeWithContext` calls is captured only once.
const capture = (): string => captureException(error, { mechanism: { type: 'auto.ai.mastra', handled: false } });

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.

h: I think we should avoid capturing errors on the channel subscriber like we do with other subscriptions.

The tracing channel tells us that the promise/call rejected but doesn't guarantee it was handled by try/catch or .then/.catch or not.

The error subscriber is only good for setting span status, not capturing errors, which should still happen on global unhandled hooks.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

the problem for this specific thing here is, that mastra swallows these errors here - they generally do not bubble up 😬 so right now there is no way to capture these errors at all.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

if a user try-catches this inside of the tool call, it should not bubble here I suppose so I think it should be fine?

function captureMastraError(error: unknown, params: unknown): void {
const id = isObjectLike(params) ? mastraSpanId(params.span) : undefined;
const span = id ? getSentrySpanForMastraId(id) : undefined;
// `captureException` dedupes on the error instance, so the same error re-thrown through outer

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.

m: clanker raised this, Mastra seems to wrap errors with new MastraError so they would probably escape the dedup-by-identity logic we have.

I raised another point about not capturing at all which would make this pointless anyways.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

adjusted to handle this properly, good catch!

@mydea
mydea force-pushed the feat/mastra-error-capture branch from a2593c6 to 9563c57 Compare September 15, 2026 06:58

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

Comment thread packages/server-utils/src/integrations/mastra.ts
Base automatically changed from feat/mastra-active-spans to develop September 15, 2026 07:11
When a Mastra tool or model operation throws, the exporter only reflected it on
the span (error status + stack-less `error.type` from Mastra's serialized
`errorInfo`); the error never became a Sentry issue.

Mastra runs each operation inside `executeWithContext({ span, fn })`, and when
`fn` rejects that channel's `error` event carries the real `Error` — stack and
all. The integration now subscribes to it and captures the error with
`captureException` (mechanism `auto.ai.mastra`, handled: false), associated with
the exporter's span for that operation so it lands on the right trace.
`captureException` dedupes on the error instance, so an error re-thrown through
outer operations is captured once. Capturing needs no async-context binding, so
it rides the attach-only path alongside the exporter registration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@mydea
mydea force-pushed the feat/mastra-error-capture branch from 9563c57 to 172d837 Compare September 15, 2026 07:11

@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 172d837. Configure here.

captureMastraError(data.error, (data.arguments as unknown[] | undefined)?.[0]);
});
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Channel errors captured too broadly

Medium Severity

The new subscriber calls captureException on every executeWithContext channel error event. That event only means the traced call rejected, not that the rejection is unhandled, so failures that still propagate to user try/catch or global hooks can be reported twice or marked handled: false after the app already handled them. Flagged because the review rules file requires this check for instrumentation that captures even when the error can still bubble.

Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit 172d837. Configure here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I think this is not true, this generally doe snot bubble anywhere (at least not anywhere a user can get to it)

Mastra rethrows failures wrapped in `new MastraError({ cause })` — a different
object than the original — so `captureException`'s identity dedup wouldn't stop
the same failure being captured twice if it surfaces at both an inner and an
outer operation.

Track captured errors together with their `.cause` chain (bounded walk) in a
WeakSet, and skip an error if it, anything it wraps, or anything wrapping it was
already captured. `MastraBaseError` sets `this.cause` to the original, so the
chain reliably links the wrapper to the raw error.

Adds unit tests covering capture, wrap dedup, and distinct-error separation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment on lines +150 to +156
const span = id ? getSentrySpanForMastraId(id) : undefined;
const capture = (): string => captureException(error, { mechanism: { type: 'auto.ai.mastra', handled: false } });

// Attach to the operation's span so the issue lands on the right trace, when the span is still open.
if (span) {
withActiveSpan(span, capture);
} else {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bug: A race condition can cause Mastra errors to be captured without being associated with their trace, as the span may be unregistered before the error handler runs.
Severity: MEDIUM

Suggested Fix

To prevent this race condition, ensure the span is accessible when the error is captured. This could be achieved by delaying the unregistration of the span until after the error handler has had a chance to process it, or by modifying the event data to include the span object directly, removing the need to look it up in the registry.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: packages/server-utils/src/integrations/mastra.ts#L150-L156

Potential issue: When a Mastra operation fails, it fires both an `.end` event and an
`.error` event. A race condition exists where the span can be ended and unregistered by
the `.end` event handler before the `.error` event handler runs. When this happens, the
error handler's call to `getSentrySpanForMastraId()` returns `undefined` because the
span has already been removed from the registry. Consequently, the error is captured but
is not associated with its corresponding trace, losing important debugging context.

Did we get this right? 👍 / 👎 to inform future reviews.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

i guess even if this happens it's fine, it will just lack this linking.

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.

2 participants