-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
feat(server-utils): Capture errors thrown in Mastra #24374
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b1bb273
51b3812
5079bb2
72d7128
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| import express from 'express'; | ||
| import * as Sentry from '@sentry/node'; | ||
| import { z } from 'zod'; | ||
| import { Mastra } from '@mastra/core'; | ||
| import { Agent } from '@mastra/core/agent'; | ||
| import { createTool } from '@mastra/core/tools'; | ||
| import { Observability } from '@mastra/observability'; | ||
| import { SentryMastraExporter } from '@sentry/node'; | ||
|
|
||
| // A tool that throws. The model recovers on the next step (so `generate` resolves cleanly), but the | ||
| // thrown error still surfaces through Mastra's `executeWithContext` and should be captured as an issue. | ||
| function startMockProvider(responses) { | ||
| const app = express(); | ||
| app.use(express.json()); | ||
| let call = 0; | ||
| app.post('/v1/chat/completions', (req, res) => { | ||
| const response = responses[Math.min(call, responses.length - 1)]; | ||
| call++; | ||
| res.json({ | ||
| id: response.id, | ||
| object: 'chat.completion', | ||
| created: 1, | ||
| model: req.body.model, | ||
| choices: [ | ||
| { | ||
| index: 0, | ||
| finish_reason: response.toolCalls ? 'tool_calls' : 'stop', | ||
| message: { | ||
| role: 'assistant', | ||
| content: response.content ?? null, | ||
| ...(response.toolCalls ? { tool_calls: response.toolCalls } : {}), | ||
| }, | ||
| }, | ||
| ], | ||
| usage: response.usage, | ||
| }); | ||
| }); | ||
| const server = app.listen(0); | ||
| return { url: `http://localhost:${server.address().port}/v1`, close: () => server.close() }; | ||
| } | ||
|
|
||
| const provider = startMockProvider([ | ||
| { | ||
| id: 'chatcmpl-tool', | ||
| toolCalls: [{ id: 'call_1', type: 'function', function: { name: 'fail_now', arguments: '{}' } }], | ||
| usage: { prompt_tokens: 20, completion_tokens: 5, total_tokens: 25 }, | ||
| }, | ||
| { | ||
| id: 'chatcmpl-final', | ||
| content: 'Sorry, that failed.', | ||
| usage: { prompt_tokens: 30, completion_tokens: 8, total_tokens: 38 }, | ||
| }, | ||
| ]); | ||
|
|
||
| async function run() { | ||
| const agent = new Agent({ | ||
| id: 'failing_agent', | ||
| name: 'failing_agent', | ||
| instructions: 'Call the failing tool.', | ||
| model: { id: 'openai/gpt-4o-mini', url: provider.url, apiKey: 'test' }, | ||
| tools: { | ||
| fail_now: createTool({ | ||
| id: 'fail_now', | ||
| description: 'Always throws', | ||
| inputSchema: z.object({}), | ||
| execute: async () => { | ||
| throw new Error('tool blew up'); | ||
| }, | ||
| }), | ||
| }, | ||
| }); | ||
|
|
||
| const mastra = new Mastra({ | ||
| agents: { failing_agent: agent }, | ||
| logger: false, | ||
| observability: new Observability({ | ||
| configs: { default: { serviceName: 'mastra-test', exporters: [new SentryMastraExporter()] } }, | ||
| }), | ||
| }); | ||
|
|
||
| await Sentry.startSpan({ op: 'function', name: 'mastra-test' }, async () => { | ||
| await mastra.getAgent('failing_agent').generate('Please fail.', { maxSteps: 3 }); | ||
| }); | ||
|
|
||
| await mastra.observability.shutdown(); | ||
| provider.close(); | ||
| } | ||
|
|
||
| run(); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,7 +3,15 @@ import { createRequire } from 'node:module'; | |
| import { join } from 'node:path'; | ||
| import { fileURLToPath } from 'node:url'; | ||
| import type { IntegrationFn } from '@sentry/core'; | ||
| import { consoleSandbox, debug, defineIntegration, GLOBAL_OBJ, isObjectLike } from '@sentry/core'; | ||
| import { | ||
| captureException, | ||
| consoleSandbox, | ||
| debug, | ||
| defineIntegration, | ||
| GLOBAL_OBJ, | ||
| isObjectLike, | ||
| withActiveSpan, | ||
| } from '@sentry/core'; | ||
| import { | ||
| COMMUNITY_MASTRA_SENTRY_EXPORTER_NAME, | ||
| MASTRA_EXPORTER_BRAND, | ||
|
|
@@ -67,7 +75,8 @@ const _mastraIntegration = ((options: MastraOptions = {}) => { | |
| return { | ||
| name: MASTRA_INTEGRATION_NAME, | ||
| setup(client) { | ||
| // Attaching the exporter opens no spans, so a missing async-context binding must not defer it. | ||
| // Attaching the exporter and capturing errors open no spans, so a missing async-context binding | ||
| // must not defer them. | ||
| invokeOrchestrionInstrumentation(client, mastraModuleNames, instrumentExporter, [options], { | ||
| requiresTracingChannelBinding: false, | ||
| }); | ||
|
|
@@ -85,6 +94,68 @@ function instrumentExporter(options: MastraOptions): void { | |
| attachExporter(self, options); | ||
| }); | ||
| }); | ||
|
|
||
| captureExecuteWithContextErrors(); | ||
| } | ||
|
|
||
| /** | ||
| * Capture errors thrown by Mastra operations as Sentry issues. Mastra runs each operation's work | ||
| * inside `executeWithContext({ span, fn })`; when `fn` rejects, the channel's `error` carries the real | ||
| * `Error` (with a stack), so we capture that rather than the exporter's stack-less `errorInfo`. | ||
| * Associated with the exporter's span for that operation so it lands on the right trace. Capturing needs | ||
| * no async context binding, so it rides the attach-only path. | ||
| */ | ||
| function captureExecuteWithContextErrors(): void { | ||
| diagnosticsChannel | ||
| .tracingChannel<ExecuteWithContextChannelContext>(CHANNELS.MASTRA_EXECUTE_WITH_CONTEXT) | ||
| .error.subscribe(message => { | ||
| safeChannelCallback(() => { | ||
| const data = message as ExecuteWithContextChannelContext & { error: unknown }; | ||
| captureMastraError(data.error, (data.arguments as unknown[] | undefined)?.[0]); | ||
| }); | ||
| }); | ||
| } | ||
|
cursor[bot] marked this conversation as resolved.
|
||
|
|
||
| /** Bound on the `cause` walk; a self- or cyclic `cause` from a wrapped error would otherwise hang. */ | ||
| const MAX_CAUSE_CHAIN_DEPTH = 10; | ||
|
|
||
| // Errors we've already captured, plus everything they wrap. Mastra re-throws failures wrapped in a | ||
| // `new MastraError({ cause })`, so the same failure surfaces at outer operations as a *different* | ||
| // object — `captureException`'s identity dedup can't see that, but the shared `cause` can. | ||
| const capturedErrors = new WeakSet<object>(); | ||
|
|
||
| function errorCauseChain(error: unknown): object[] { | ||
| const chain: object[] = []; | ||
| let current = error; | ||
| for (let depth = 0; depth < MAX_CAUSE_CHAIN_DEPTH && isObjectLike(current); depth++) { | ||
| chain.push(current); | ||
| const cause = (current as { cause?: unknown }).cause; | ||
| if (cause === current) { | ||
| break; | ||
| } | ||
| current = cause; | ||
| } | ||
| return chain; | ||
| } | ||
|
|
||
| function captureMastraError(error: unknown, params: unknown): void { | ||
| const chain = errorCauseChain(error); | ||
| // Skip if this error — or anything it wraps, or anything wrapping it — was already captured. | ||
| if (chain.some(link => capturedErrors.has(link))) { | ||
| return; | ||
| } | ||
| chain.forEach(link => capturedErrors.add(link)); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Skipped wrappers can double-reportMedium Severity When a later Triggered by project rule: PR Review Guidelines for Cursor Bot Reviewed by Cursor Bugbot for commit 72d7128. Configure here. |
||
|
|
||
| const id = isObjectLike(params) ? mastraSpanId(params.span) : undefined; | ||
| const span = id ? getSentrySpanForMastraId(id) : undefined; | ||
| const capture = (): string => captureException(error, { mechanism: { type: 'auto.ai.mastra', handled: true } }); | ||
|
|
||
| // 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 { | ||
|
sentry[bot] marked this conversation as resolved.
|
||
| capture(); | ||
| } | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| /** | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| import { tracingChannel } from 'node:diagnostics_channel'; | ||
| import { GLOBAL_OBJ, setCurrentClient } from '@sentry/core'; | ||
| import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; | ||
| import { mastraIntegration } from '../../../src/integrations/mastra'; | ||
| import { CHANNELS } from '../../../src/orchestrion/channels'; | ||
| import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; | ||
|
|
||
| const channel = tracingChannel<{ error: unknown; arguments: unknown[] }>(CHANNELS.MASTRA_EXECUTE_WITH_CONTEXT); | ||
|
|
||
| describe('mastraIntegration error capture', () => { | ||
| let client: TestClient; | ||
|
|
||
| beforeEach(() => { | ||
| // Treat `@mastra/core` as already injected so the channel subscription activates synchronously. | ||
| GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = { runtime: ['@mastra/core'] }; | ||
| client = new TestClient( | ||
| getDefaultTestClientOptions({ dsn: 'https://public@dsn.ingest.sentry.io/1337', tracesSampleRate: 1 }), | ||
| ); | ||
| setCurrentClient(client); | ||
| client.init(); | ||
| mastraIntegration().setup?.(client); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| delete GLOBAL_OBJ.__SENTRY_ORCHESTRION__; | ||
| vi.restoreAllMocks(); | ||
| }); | ||
|
|
||
| it('captures an error thrown by a Mastra operation as an issue', () => { | ||
| const captureException = vi.spyOn(client, 'captureException'); | ||
|
|
||
| const error = new Error('tool blew up'); | ||
| channel.error.publish({ error, arguments: [{}] }); | ||
|
|
||
| expect(captureException).toHaveBeenCalledTimes(1); | ||
| // The captured value is the thrown error itself (real stack), with the Mastra mechanism. | ||
| expect(captureException).toHaveBeenCalledWith( | ||
| error, | ||
| expect.objectContaining({ mechanism: { type: 'auto.ai.mastra', handled: true } }), | ||
| expect.anything(), | ||
| ); | ||
| }); | ||
|
|
||
| it('captures the same error only once when Mastra re-wraps it as a MastraError', () => { | ||
| const captureException = vi.spyOn(client, 'captureException'); | ||
|
|
||
| const original = new Error('tool blew up'); | ||
| // Mastra rethrows failures wrapped in `new MastraError({ cause })` — a *different* object. | ||
| const wrapped = new Error('Tool execution failed'); | ||
| (wrapped as Error & { cause?: unknown }).cause = original; | ||
|
|
||
| // The raw error surfaces at the inner operation, the wrapper at an outer one. | ||
| channel.error.publish({ error: original, arguments: [{}] }); | ||
| channel.error.publish({ error: wrapped, arguments: [{}] }); | ||
|
|
||
| expect(captureException).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it('captures unrelated errors separately', () => { | ||
| const captureException = vi.spyOn(client, 'captureException'); | ||
|
|
||
| channel.error.publish({ error: new Error('first'), arguments: [{}] }); | ||
| channel.error.publish({ error: new Error('second'), arguments: [{}] }); | ||
|
|
||
| expect(captureException).toHaveBeenCalledTimes(2); | ||
| }); | ||
| }); |


Uh oh!
There was an error while loading. Please reload this page.