Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,5 @@ export const mastra = new Mastra({
/**
* TODO
*
* 1. Bubbled-up tool errors aren't captured as issues — only reflected on the span (status + error.type); the exporter leaves captureException to the app.
* 2. Parametrized custom routes aren't route-instrumented on Mastra's Hono server (named from the URL, not a route pattern).
* 1. Parametrized custom routes aren't route-instrumented on Mastra's Hono server (named from the URL, not a route pattern).
*/
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expect, test } from '@playwright/test';
import { collectStreamedSpans, getSpanOp, SerializedStreamedSpan } from '@sentry-internal/test-utils';
import { collectStreamedSpans, getSpanOp, SerializedStreamedSpan, waitForError } from '@sentry-internal/test-utils';
import { runAgentTurn } from './utils';

const APP = 'node-mastra';
Expand Down Expand Up @@ -98,20 +98,18 @@ test('captures Mastra agent spans (invoke_agent, chat, execute_tool) with inputs
expect(attrValue(executeTool!, 'gen_ai.conversation.id')).toBe(thread);
});

test('records a bubbled-up Mastra tool error on the span', async ({ baseURL }) => {
// This asserts the *current* behavior: when a tool throws, Mastra reports it as
// `errorInfo` and the Sentry exporter reflects that on the tool span (error
// status + `error.type`). It does NOT (today) surface as a captured Sentry
// error/issue: the exporter deliberately leaves `captureException` to the app,
// and nothing here re-captures the bubbled-up error. Whether the SDK should
// capture such errors automatically is a follow-up — see
// https://github.com/getsentry/sentry-javascript (Mastra integration).
//
// `fail_now` is unique to this test, so its errored tool span isolates this
// turn's trace from the other tests sharing the proxy.
test('captures a Mastra tool error as an issue and marks the tool span', async ({ baseURL }) => {
// A thrown tool error surfaces both ways: reflected on the exporter's tool span (error status +
// `error.type`) and captured as a Sentry issue with the real error and stack, from the Mastra
// integration (mechanism `auto.ai.mastra`). `fail_now` is unique to this test, so both the errored
// tool span and the captured error isolate this turn.
const erroredToolSpanPromise = collectStreamedSpans(APP, spansOfTrace =>
spansOfTrace.some(span => callsTool('fail_now')(span) && Boolean(attrValue(span, 'error.type'))),
);
const errorPromise = waitForError(
APP,
event => event.exception?.values?.[0]?.value === 'Intentional Mastra tool failure',
);

await runAgentTurn(baseURL!, 'Please call the tool that triggers a failure now.');

Expand All @@ -121,7 +119,14 @@ test('records a bubbled-up Mastra tool error on the span', async ({ baseURL }) =
// The errored tool span comes from the Mastra exporter (no double instrumentation).
expect(attrValue(erroredTool!, 'sentry.origin')).toBe(MASTRA_ORIGIN);
expect(attrValue(erroredTool!, 'gen_ai.tool.name')).toBe('fail_now');
// The error is recorded on the span (status + type), not as a separate issue.
expect(attrValue(erroredTool!, 'error.type')).toBeTruthy();
expect(erroredTool!.status).not.toBe('ok');

// The thrown error is captured as a proper issue with a real stack.
const error = await errorPromise;
const exception = error.exception?.values?.[0];
expect(exception?.value).toBe('Intentional Mastra tool failure');
expect(exception?.mechanism?.type).toBe('auto.ai.mastra');
expect(exception?.mechanism?.handled).toBe(true);
expect((exception?.stacktrace?.frames ?? []).length).toBeGreaterThan(0);
});
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
Expand Up @@ -432,4 +432,32 @@ conditionalTest({ min: 22 })('Mastra integration', () => {
},
MASTRA_NESTING_DEPENDENCIES,
);

createEsmAndCjsTests(
__dirname,
'scenario-error.mjs',
'instrument.mjs',
(createRunner, test) => {
test('captures an error thrown in a Mastra tool as a Sentry issue', async () => {
// The scenario also emits the `mastra-test` transaction and its streamed gen_ai spans; their
// flush order relative to the error event races, so ignore them and match only the issue.
await createRunner()
.ignore('transaction', 'span')
.expect({
event: event => {
const exception = event.exception?.values?.[0];
expect(exception?.type).toBe('Error');
expect(exception?.value).toBe('tool blew up');
// A real stack from the tool, not the exporter's stack-less `errorInfo`.
expect(exception?.stacktrace?.frames?.length).toBeGreaterThan(0);
expect(exception?.mechanism?.type).toBe('auto.ai.mastra');
expect(exception?.mechanism?.handled).toBe(true);
},
})
.start()
.completed();
});
},
MASTRA_NESTING_DEPENDENCIES,
);
});
75 changes: 73 additions & 2 deletions packages/server-utils/src/integrations/mastra.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
});
Expand All @@ -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]);
});
});
Comment thread
sentry[bot] marked this conversation as resolved.
}
Comment thread
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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Skipped wrappers can double-report

Medium Severity

When a later executeWithContext rejection is skipped because its cause was already captured, that wrapper is never marked with the SDK captured flag. If Mastra rethrows the MastraError from generate or stream, the global handler can still open a second issue for the same failure.

Fix in Cursor Fix in Web

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 {
Comment thread
sentry[bot] marked this conversation as resolved.
capture();
}
Comment thread
cursor[bot] marked this conversation as resolved.
}

/**
Expand Down
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);
});
});
Loading