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
1 change: 0 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1149,7 +1149,6 @@ jobs:
REACT_APP_E2E_TEST_DSN: ${{ secrets.E2E_TEST_DSN }}
E2E_TEST_SENTRY_ORG_SLUG: 'sentry-javascript-sdks'
E2E_TEST_SENTRY_PROJECT: 'sentry-javascript-e2e-tests'
# Used by the `node-eve` test app to make real model calls through OpenRouter
E2E_OPENROUTER_API_KEY: ${{ secrets.E2E_OPENROUTER_API_KEY }}
strategy:
fail-fast: false
Expand Down
2 changes: 1 addition & 1 deletion dev-packages/e2e-tests/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,6 @@ E2E_TEST_SENTRY_ORG_SLUG=
# A Sentry project slug
E2E_TEST_SENTRY_PROJECT=

# An OpenRouter API key, used by the `node-eve` test app to make real model calls.
# An OpenRouter API key to make real model calls.
# Only needed to run that test app locally.
E2E_OPENROUTER_API_KEY=
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
node_modules
.mastra
.data
*.tsbuildinfo
results.junit.xml
test-results
playwright-report
51 changes: 51 additions & 0 deletions dev-packages/e2e-tests/test-applications/node-mastra/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
{
"name": "node-mastra",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "mastra build && rm -rf .mastra/output/node_modules/@sentry",
"start": "mastra start --custom-args=\"--import=./instrument.mjs\"",
"dev": "mastra dev --custom-args=\"--import=./instrument.mjs\"",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Sentry preload import path is wrong

High Severity

--import=./instrument.mjs cannot resolve instrument.mjs. Node resolves that relative specifier from the process cwd, but the file only exists at src/mastra/public/instrument.mjs. Mastra copies public/ into .mastra/output/public/ as static assets, not the output root, and mastra dev does not copy it at all. Both start commands fail before Sentry.init() runs.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit babf76f. 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.

this is correct and fine

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.

this is wrong, this works indeed

"clean": "npx rimraf node_modules .mastra pnpm-lock.yaml",
"test:build": "pnpm install && pnpm build",
"test:build-latest": "pnpm install && pnpm add @mastra/core@latest @mastra/observability@latest @mastra/memory@latest @mastra/libsql@latest mastra@latest && pnpm build",
"test:assert": "pnpm test:prod && pnpm test:dev",
"test:prod": "TEST_ENV=production playwright test",
"test:dev": "TEST_ENV=development playwright test"
},
"dependencies": {
"@mastra/core": "~1.65.0",
"@mastra/libsql": "~1.22.4",
"@mastra/memory": "~1.28.3",
"@mastra/observability": "~1.17.6",
"@openrouter/ai-sdk-provider": "~3.0.0",
"@sentry/node": "file:../../packed/sentry-node-packed.tgz",
"ai": "~7.0.97",
"dataloader": "~2.2.3",
"zod": "4.5.4"
},
"devDependencies": {
"@playwright/test": "~1.56.0",
"@sentry-internal/test-utils": "link:../../../test-utils",
"@types/node": "24.x",
"mastra": "~1.28.0",
"typescript": "~5.9.0"
},
"engines": {
"node": "24.x"
},
"volta": {
"node": "24.15.0",
"extends": "../../package.json"
},
"sentryTest": {
"optional": true,
"optionalVariants": [
{
"build-command": "pnpm test:build-latest",
"label": "node-mastra (latest)"
}
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { getPlaywrightConfig } from '@sentry-internal/test-utils';

const testEnv = process.env.TEST_ENV;

if (!testEnv) {
throw new Error('No test env defined');
}

// Run the same tests against both Mastra run modes: production (`mastra start`,
// the built Hono server) and development (`mastra dev`, Rollup watch server).
const startCommand = testEnv === 'development' ? 'pnpm dev' : 'pnpm start';

const config = getPlaywrightConfig(
{
startCommand,
// Both `mastra start` and `mastra dev` serve on 4111 (set via `server.port`
// in src/mastra/index.ts), not the 3030 the helper assumes.
port: 4111,
},
// Each test drives a real OpenRouter tool-calling turn (two model calls) and
// then waits for the Mastra spans to flush, which does not fit the default
// 30s test timeout when the provider is slow.
{ timeout: 90_000 },
);

export default config;
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
import { Agent } from '@mastra/core/agent';
import { Memory } from '@mastra/memory';
import { LibSQLStore } from '@mastra/libsql';
import { countItemsTool } from '../tools/count-items.js';
import { failNowTool } from '../tools/fail-now.js';
import { getWeatherTool } from '../tools/get-weather.js';

const apiKey = process.env.E2E_OPENROUTER_API_KEY;
if (!apiKey) {
throw new Error('E2E_OPENROUTER_API_KEY is not set');
}

// Call OpenRouter directly (rather than the default Vercel AI Gateway) so the
// e2e test needs only a single OpenRouter key, reusing `E2E_OPENROUTER_API_KEY`.
const openrouter = createOpenRouter({ apiKey });

// An in-memory libsql store is enough for the test: Mastra requires a storage
// provider before `generate(..., { memory: { thread, resource } })` is accepted,
// and that thread id is what the Sentry exporter maps to `gen_ai.conversation.id`.
const memory = new Memory({
storage: new LibSQLStore({ id: 'libsql-storage', url: ':memory:' }),
});

export const WEATHER_AGENT = 'weatherAgent';

export const weatherAgent = new Agent({
// `id` (registry key + REST `:agentId`), `name` (used for `gen_ai.agent.name`)
// are kept identical so the generate endpoint and span assertions line up.
id: WEATHER_AGENT,
name: WEATHER_AGENT,
instructions: [
'You are a concise assistant used by an automated end-to-end test.',
'When the user asks about the weather in a place, call the `get_weather` tool for that place and answer in one short sentence using its result.',
'When the user asks you to trigger a failure, call the `fail_now` tool.',
'When the user asks you to count items, call the `count_items` tool with the item names.',
'Do not ask follow-up questions.',
].join('\n'),
model: openrouter('openai/gpt-4o-mini'),
tools: { get_weather: getWeatherTool, fail_now: failNowTool, count_items: countItemsTool },
memory,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Mastra } from '@mastra/core';
import { registerApiRoute } from '@mastra/core/server';
import { LibSQLStore } from '@mastra/libsql';
import { WEATHER_AGENT, weatherAgent } from './agents/weather-agent.js';

// The agent is driven through Mastra's built-in `POST /api/agents/:id/generate`
// endpoint (see tests/utils.ts). `dataloader` (orchestrion-instrumented) is now
// exercised through the agent's `count_items` tool (see src/mastra/tools/count-items.ts).

// A plain custom route to verify that requests to app-registered routes are
// wrapped in an `http.server` span with the correct route attributes (method,
// route pattern, status code) — independent of the agent/AI instrumentation.
const manualRoute = registerApiRoute('/manual-route', {
method: 'GET',
handler(c) {
return c.json({ ok: true });
},
});

export const mastra = new Mastra({
agents: { [WEATHER_AGENT]: weatherAgent },
storage: new LibSQLStore({ id: 'mastra-storage', url: ':memory:' }),
server: {
port: 4111,
apiRoutes: [manualRoute],
},
});

/**
* 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. Mastra runs tools with inactive spans, so the `count_items` tool must open its own active span (startSpan) for dataloader's `cache.get` span to emit.
3. parametrized routes for mastra?
*/
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import * as Sentry from '@sentry/node';

Sentry.init({
environment: 'qa',
dsn: process.env.E2E_TEST_DSN,
tunnel: 'http://localhost:3031/', // proxy server
tracesSampleRate: 1.0,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { startSpan } from '@sentry/node';
import { createTool } from '@mastra/core/tools';
import DataLoader from 'dataloader';
import { z } from 'zod';

// Uses the orchestrion-instrumented `dataloader` inside a tool, to prove orchestrion
// works for a non-Mastra package driven through the agent's tool-call flow — and
// that a tool can use bundle-side `Sentry.startSpan` (single SDK copy; see
// src/mastra/index.ts).
//
// The `startSpan` wrapper is required: Mastra runs tools with inactive spans, and
// `dataloader`'s `load` only emits its `cache.get` span when a span is active. So
// the tool opens one explicitly, and `dataloader.load` nests under it.
export const countItemsTool = createTool({
id: 'count_items',
description: 'Count the number of letters in each given name. Call this when asked to count items.',
inputSchema: z.object({ names: z.array(z.string()).min(1) }),
outputSchema: z.object({ counts: z.array(z.number()) }),
async execute(inputData) {
return startSpan({ name: 'count-items', op: 'function' }, async () => {
const loader = new DataLoader<string, number>(async keys => keys.map(key => key.length));
const counts = await Promise.all(inputData.names.map(name => loader.load(name)));
return { counts };
});
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { createTool } from '@mastra/core/tools';
import { z } from 'zod';

export const failNowTool = createTool({
id: 'fail_now',
description: 'Always throws an error. Call this when the user asks to trigger a failure.',
inputSchema: z.object({}),
outputSchema: z.object({}),
async execute() {
throw new Error('Intentional Mastra tool failure');
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { createTool } from '@mastra/core/tools';
import { z } from 'zod';

export const getWeatherTool = createTool({
id: 'get_weather',
description: 'Get the current weather for a city.',
inputSchema: z.object({ city: z.string().min(1) }),
outputSchema: z.object({
city: z.string(),
condition: z.string(),
temperatureC: z.number(),
}),
async execute(inputData) {
return { city: inputData.city, condition: 'Sunny', temperatureC: 22 };
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { startEventProxyServer } from '@sentry-internal/test-utils';

startEventProxyServer({
port: 3031,
proxyServerName: 'node-mastra',
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { expect, test } from '@playwright/test';
import { collectStreamedSpans, getSpanOp, SerializedStreamedSpan } from '@sentry-internal/test-utils';
import { runAgentTurn } from './utils';

const APP = 'node-mastra';

const attrValue = (span: SerializedStreamedSpan, key: string): unknown => span.attributes?.[key]?.value;

// `dataloader` is instrumented by Sentry through orchestrion — the same runtime
// module transform that instruments `@mastra/core`. This exercises a non-Mastra
// orchestrion package to confirm the runtime injection works broadly, not just for
// the Mastra constructor. Runs unconditionally in both the prod (`mastra start`)
// and dev (`mastra dev`) variants.
const isDataloaderSpan = (span: SerializedStreamedSpan): boolean =>
getSpanOp(span) === 'cache.get' && attrValue(span, 'sentry.origin') === 'auto.db.dataloader';

// The `count_items` tool uses `dataloader` internally. Mastra runs tools with
// inactive spans, so the tool opens its own active span via `Sentry.startSpan`
// (bundle-side, single SDK copy) — dataloader's `load` then emits its `cache.get`
// span nested under it. This proves orchestrion instrumentation reaches code run
// through the agent's tool-call flow.
const callsCountItems = (span: SerializedStreamedSpan): boolean =>
getSpanOp(span) === 'gen_ai.execute_tool' && attrValue(span, 'gen_ai.tool.name') === 'count_items';

test('captures orchestrion-instrumented dataloader spans from an agent tool', async ({ baseURL }) => {
// Scope to this turn's trace by both the unique `count_items` tool call and the
// dataloader span it produces, so we don't match another test's agent trace.
const spansPromise = collectStreamedSpans(
APP,
spansOfTrace => spansOfTrace.some(callsCountItems) && spansOfTrace.some(isDataloaderSpan),
);

await runAgentTurn(baseURL!, 'Use the count_items tool to count these names: apple, banana, cherry.');

const spans = await spansPromise;
const dataloaderSpan = spans.find(isDataloaderSpan);

expect(dataloaderSpan).toBeDefined();
expect(getSpanOp(dataloaderSpan!)).toBe('cache.get');
expect(attrValue(dataloaderSpan!, 'sentry.origin')).toBe('auto.db.dataloader');
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { expect, test } from '@playwright/test';
import { collectStreamedSpans, getSpanOp, SerializedStreamedSpan } from '@sentry-internal/test-utils';

const APP = 'node-mastra';

const attrValue = (span: SerializedStreamedSpan, key: string): unknown => span.attributes?.[key]?.value;

// A plain custom route registered on the Mastra (Hono) server — see the
// `/manual-route` handler in src/mastra/index.ts. This verifies that ordinary HTTP
// requests to app-registered routes are wrapped in an `http.server` span with the
// expected attributes, independent of the agent / AI instrumentation. Runs in both
// the prod (`mastra start`) and dev (`mastra dev`) variants.
const isManualRouteServerSpan = (span: SerializedStreamedSpan): boolean =>
getSpanOp(span) === 'http.server' && String(attrValue(span, 'url.full') ?? '').includes('/manual-route');

test('wraps a custom Mastra route in an http.server span with correct attributes', async ({ baseURL }) => {
const spansPromise = collectStreamedSpans(APP, spansOfTrace => spansOfTrace.some(isManualRouteServerSpan));

const res = await fetch(`${baseURL}/manual-route`, { method: 'GET' });
expect(res.status).toBe(200);
await res.json();

const spans = await spansPromise;
const serverSpan = spans.find(isManualRouteServerSpan);

expect(serverSpan).toBeDefined();
expect(getSpanOp(serverSpan!)).toBe('http.server');
expect(serverSpan!.name).toBe('GET');
expect(attrValue(serverSpan!, 'http.request.method')).toBe('GET');
expect(attrValue(serverSpan!, 'http.response.status_code')).toBe(200);
expect(String(attrValue(serverSpan!, 'url.full') ?? '')).toContain('/manual-route');

// Codifies current behavior: the transaction name is derived from the URL path,
// not a route pattern. Mastra serves custom routes through Hono, which Sentry
// does not route-instrument the way it does Express — so there is no `http.route`
// attribute and the name source is `url` (an Express route would give `route`).
expect(attrValue(serverSpan!, 'sentry.segment.name.source')).toBe('url');
expect(attrValue(serverSpan!, 'http.route')).toBeUndefined();
});
Loading
Loading