From b54625482010310310217aec0ab9ecab02c453b1 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 10 Sep 2026 16:14:09 +0200 Subject: [PATCH 1/9] mastra app --- .github/workflows/build.yml | 1 - dev-packages/e2e-tests/.env.example | 2 +- .../test-applications/node-mastra/.gitignore | 7 ++ .../node-mastra/instrument.mjs | 20 ++++ .../node-mastra/package.json | 49 ++++++++ .../node-mastra/playwright.config.mjs | 16 +++ .../src/mastra/agents/weather-agent.ts | 37 ++++++ .../node-mastra/src/mastra/index.ts | 45 +++++++ .../node-mastra/src/mastra/tools/fail-now.ts | 12 ++ .../src/mastra/tools/get-weather.ts | 16 +++ .../node-mastra/start-event-proxy.mjs | 6 + .../node-mastra/tests/mastra.test.ts | 112 ++++++++++++++++++ .../node-mastra/tests/utils.ts | 28 +++++ .../node-mastra/tsconfig.json | 13 ++ 14 files changed, 362 insertions(+), 2 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/node-mastra/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/node-mastra/instrument.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-mastra/package.json create mode 100644 dev-packages/e2e-tests/test-applications/node-mastra/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/agents/weather-agent.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/index.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/tools/fail-now.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/tools/get-weather.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-mastra/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-mastra/tests/mastra.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-mastra/tests/utils.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-mastra/tsconfig.json diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 23fb501d72b3..70256b2a3276 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/dev-packages/e2e-tests/.env.example b/dev-packages/e2e-tests/.env.example index 13eff7352024..1a709a9b02b9 100644 --- a/dev-packages/e2e-tests/.env.example +++ b/dev-packages/e2e-tests/.env.example @@ -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= diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/.gitignore b/dev-packages/e2e-tests/test-applications/node-mastra/.gitignore new file mode 100644 index 000000000000..b1a2eb61d19d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-mastra/.gitignore @@ -0,0 +1,7 @@ +node_modules +.mastra +.data +*.tsbuildinfo +results.junit.xml +test-results +playwright-report diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/instrument.mjs b/dev-packages/e2e-tests/test-applications/node-mastra/instrument.mjs new file mode 100644 index 000000000000..92de795c84a6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-mastra/instrument.mjs @@ -0,0 +1,20 @@ +import * as Sentry from '@sentry/node'; + +// Preloaded via `node --import ./instrument.mjs` (through `mastra start --custom-args`, +// see package.json). This is the standard @sentry/node pattern for a bundled +// server: `Sentry.init` must run before the app (and `@mastra/core`) load, and it +// must NOT be bundled — Mastra's Rollup drops a side-effect-only import of an +// instrument module from the built entry, so an in-bundle `import './instrument'` +// never runs. +// +// This file is NOT bundled: it stays at the app root and is loaded from +// `node_modules` at runtime, the same @sentry/node copy the `@sentry/node/import` +// orchestrion loader uses — so the Mastra integration's constructor subscriber +// (set up here by `init`) and the constructor transform (set up by the loader) +// share one SDK instance and one diagnostics channel. +Sentry.init({ + environment: 'qa', + dsn: process.env.E2E_TEST_DSN, + tunnel: 'http://localhost:3031/', // proxy server + tracesSampleRate: 1.0, +}); diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/package.json b/dev-packages/e2e-tests/test-applications/node-mastra/package.json new file mode 100644 index 000000000000..99179210df3e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-mastra/package.json @@ -0,0 +1,49 @@ +{ + "name": "node-mastra", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "mastra build", + "start": "mastra start --custom-args='--import=../../instrument.mjs'", + "clean": "npx rimraf node_modules .mastra pnpm-lock.yaml", + "test": "playwright test", + "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" + }, + "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", + "zod": "4.5.4" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@sentry/core": "file:../../packed/sentry-core-packed.tgz", + "@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)" + } + ] + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/node-mastra/playwright.config.mjs new file mode 100644 index 000000000000..32a3e2a36e60 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-mastra/playwright.config.mjs @@ -0,0 +1,16 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig( + { + startCommand: 'pnpm start', + // Mastra's built Hono server listens on 4111 by default (set explicitly 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; diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/agents/weather-agent.ts b/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/agents/weather-agent.ts new file mode 100644 index 000000000000..18d05868735d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/agents/weather-agent.ts @@ -0,0 +1,37 @@ +import { createOpenRouter } from '@openrouter/ai-sdk-provider'; +import { Agent } from '@mastra/core/agent'; +import { Memory } from '@mastra/memory'; +import { LibSQLStore } from '@mastra/libsql'; +import { failNowTool } from '../tools/fail-now.js'; +import { getWeatherTool } from '../tools/get-weather.js'; + +// 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: process.env.E2E_OPENROUTER_API_KEY, +}); + +// 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.', + 'Do not ask follow-up questions.', + ].join('\n'), + model: openrouter('openai/gpt-4o-mini'), + tools: { get_weather: getWeatherTool, fail_now: failNowTool }, + memory, +}); diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/index.ts b/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/index.ts new file mode 100644 index 000000000000..7c74b4268462 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/index.ts @@ -0,0 +1,45 @@ +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'; + +// `Sentry.init` is NOT imported here — it is preloaded via `--import ./instrument.mjs` +// (see the `start` script). Mastra's Rollup drops a side-effect-only import of an +// instrument module from the built entry, so init has to run as a preload, before +// this bundle (and `@mastra/core`) load. That preload also keeps `@sentry/*` out of +// the bundle entirely, so no `transpilePackages`/`externals` are needed for Sentry. + +interface RunBody { + message: string; + thread?: string; + resource?: string; +} + +// A custom route (rather than the built-in `/api/agents/:id/generate`) so we can +// pass the modern nested `memory: { thread, resource }` shape to `generate()`. +// That is what makes Mastra engage memory and stamp the thread id onto the +// AI-tracing spans (as `metadata.threadId` / `attributes.conversationId`, which the +// exporter maps to `gen_ai.conversation.id`); the built-in endpoint's top-level +// `threadId`/`resourceId` do not populate tracing metadata. +const runRoute = registerApiRoute('/run', { + method: 'POST', + async handler(c) { + const mastra = c.get('mastra'); + const { message, thread, resource } = (await c.req.json()) as RunBody; + const agent = mastra.getAgent(WEATHER_AGENT); + const result = await agent.generate( + message, + thread ? { memory: { thread, resource: resource ?? 'e2e-user' } } : {}, + ); + return c.json({ text: result.text }); + }, +}); + +export const mastra = new Mastra({ + agents: { [WEATHER_AGENT]: weatherAgent }, + storage: new LibSQLStore({ id: 'mastra-storage', url: ':memory:' }), + server: { + port: 4111, + apiRoutes: [runRoute], + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/tools/fail-now.ts b/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/tools/fail-now.ts new file mode 100644 index 000000000000..1b1b344a14d1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/tools/fail-now.ts @@ -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'); + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/tools/get-weather.ts b/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/tools/get-weather.ts new file mode 100644 index 000000000000..8c381f2b616a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/tools/get-weather.ts @@ -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({ context }) { + return { city: context.city, condition: 'Sunny', temperatureC: 22 }; + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-mastra/start-event-proxy.mjs new file mode 100644 index 000000000000..b48987211d63 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-mastra/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'node-mastra', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/tests/mastra.test.ts b/dev-packages/e2e-tests/test-applications/node-mastra/tests/mastra.test.ts new file mode 100644 index 000000000000..bee4dc74e24f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-mastra/tests/mastra.test.ts @@ -0,0 +1,112 @@ +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; + +const MASTRA_ORIGIN = 'auto.ai.mastra'; +const isOp = + (op: string) => + (span: SerializedStreamedSpan): boolean => + getSpanOp(span) === op; +const isGenAiSpan = (span: SerializedStreamedSpan): boolean => String(getSpanOp(span) ?? '').startsWith('gen_ai.'); +const describeSpan = (span: SerializedStreamedSpan): { op: string | undefined; name?: string; origin: unknown } => ({ + op: getSpanOp(span), + name: span.name, + origin: attrValue(span, 'sentry.origin'), +}); + +test('captures Mastra agent spans (invoke_agent, chat, execute_tool) with inputs/outputs and conversation id', async ({ + baseURL, +}) => { + const thread = `e2e-thread-${Date.now()}`; + + // The agent run, its model call and the tool call all land in one trace. The + // parent `invoke_agent` span can flush in a separate envelope from its + // children, so accumulate the trace's spans across envelopes until every + // expected gen_ai op has arrived. Match on op alone (not origin), so a + // double-instrumented span is collected too and caught by the assertions below. + const traceSpansPromise = collectStreamedSpans(APP, spansOfTrace => + ['gen_ai.invoke_agent', 'gen_ai.chat', 'gen_ai.execute_tool'].every(op => spansOfTrace.some(isOp(op))), + ); + + await runAgentTurn(baseURL!, 'What is the weather in Paris?', { thread, resource: 'e2e-user' }); + + const traceSpans = await traceSpansPromise; + + // No double instrumentation - ensure there are no gen ai spans with another origin + expect( + traceSpans + .filter(span => isGenAiSpan(span) && attrValue(span, 'sentry.origin') !== MASTRA_ORIGIN) + .map(describeSpan), + ).toEqual([]); + + const invokeAgent = traceSpans.find(isOp('gen_ai.invoke_agent')); + const chat = traceSpans.find(isOp('gen_ai.chat')); + const executeTool = traceSpans.find(isOp('gen_ai.execute_tool')); + + // Agent span. + expect(attrValue(invokeAgent!, 'gen_ai.operation.name')).toBe('invoke_agent'); + expect(attrValue(invokeAgent!, 'gen_ai.agent.name')).toBe('weatherAgent'); + + // Model span. The request model is the id passed to `openrouter(...)`; the + // provider string is asserted loosely because it depends on the AI SDK + // provider, not on Sentry. + expect(attrValue(chat!, 'gen_ai.operation.name')).toBe('chat'); + expect(String(attrValue(chat!, 'gen_ai.request.model') ?? '')).toContain('gpt-4o-mini'); + expect(attrValue(chat!, 'gen_ai.provider.name')).toBeTruthy(); + expect(typeof attrValue(chat!, 'gen_ai.usage.input_tokens')).toBe('number'); + expect(typeof attrValue(chat!, 'gen_ai.usage.output_tokens')).toBe('number'); + expect(typeof attrValue(chat!, 'gen_ai.usage.total_tokens')).toBe('number'); + + // Tool span. + expect(attrValue(executeTool!, 'gen_ai.operation.name')).toBe('execute_tool'); + expect(attrValue(executeTool!, 'gen_ai.tool.name')).toBe('get_weather'); + + // Inputs and outputs (recorded with the SDK's default data collection). + expect(String(attrValue(invokeAgent!, 'gen_ai.input.messages') ?? '')).toContain('Paris'); + expect(String(attrValue(invokeAgent!, 'gen_ai.output.messages') ?? '')).not.toBe(''); + expect(String(attrValue(executeTool!, 'gen_ai.tool.call.arguments') ?? '')).toContain('Paris'); + // NOTE: current behavior — the tool *arguments* are captured, but the tool + // *result* is not. Mastra does not populate `output` on the exported + // `tool_call` span at export time (the exporter maps `span.output` to + // `gen_ai.tool.call.result`), so it comes through empty even though the tool + // returns `{ city, condition: 'Sunny', temperatureC: 22 }`. Asserting the + // empty value documents the gap; if Mastra starts emitting tool output this + // will fail and should become `.toContain('Sunny')`. Follow-up: capture tool + // results for the Mastra integration. + expect(String(attrValue(executeTool!, 'gen_ai.tool.call.result') ?? '')).toBe(''); + + // Conversation id: the exporter maps Mastra's `metadata.threadId` to + // `gen_ai.conversation.id` on every mapped span. + expect(attrValue(invokeAgent!, 'gen_ai.conversation.id')).toBe(thread); + expect(attrValue(chat!, 'gen_ai.conversation.id')).toBe(thread); + 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). + const erroredToolSpanPromise = collectStreamedSpans(APP, spansOfTrace => + spansOfTrace.some(span => isOp('gen_ai.execute_tool')(span) && Boolean(attrValue(span, 'error.type'))), + ); + + await runAgentTurn(baseURL!, 'Please call the tool that triggers a failure now.'); + + const spans = await erroredToolSpanPromise; + const erroredTool = spans.find(span => isOp('gen_ai.execute_tool')(span) && Boolean(attrValue(span, 'error.type'))); + + // 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'); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/tests/utils.ts b/dev-packages/e2e-tests/test-applications/node-mastra/tests/utils.ts new file mode 100644 index 000000000000..0decc8933e66 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-mastra/tests/utils.ts @@ -0,0 +1,28 @@ +import { expect } from '@playwright/test'; + +/** + * Drive one agent turn through the app's custom `/run` route and wait for it to + * settle. `generate` runs to completion server-side before the response is sent, + * so once the POST resolves the run is finished and its spans have been created; + * we then only wait for the envelopes to reach the proxy. + * + * `thread`/`resource` are passed to `generate` as `memory: { thread, resource }`, + * which is what makes Mastra stamp the thread id onto the spans (→ + * `gen_ai.conversation.id`). + */ +export async function runAgentTurn( + baseURL: string, + message: string, + options: { thread?: string; resource?: string } = {}, +): Promise<{ status: number }> { + const res = await fetch(`${baseURL}/run`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message, ...options }), + }); + // 200 on success; a bubbled-up tool error yields a 500 (the error test relies + // on that). Either is fine — callers assert on spans/errors, not this status. + expect([200, 500]).toContain(res.status); + await res.text(); + return { status: res.status }; +} diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-mastra/tsconfig.json new file mode 100644 index 000000000000..7b6fdb709045 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-mastra/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["node"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src/**/*.ts"] +} From 9063f13514bab63eab039db3256903034ad9e54f Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 11 Sep 2026 10:18:36 +0200 Subject: [PATCH 2/9] get it to work --- .../node-mastra/package.json | 10 +++-- .../node-mastra/playwright.config.mjs | 16 +++++-- .../src/mastra/agents/weather-agent.ts | 9 ++-- .../node-mastra/src/mastra/index.ts | 35 ++++++++++++++-- .../node-mastra/tests/dataloader.test.ts | 32 ++++++++++++++ .../node-mastra/tests/mastra.test.ts | 42 ++++++++++++++----- 6 files changed, 120 insertions(+), 24 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/node-mastra/tests/dataloader.test.ts diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/package.json b/dev-packages/e2e-tests/test-applications/node-mastra/package.json index 99179210df3e..40393cca80ef 100644 --- a/dev-packages/e2e-tests/test-applications/node-mastra/package.json +++ b/dev-packages/e2e-tests/test-applications/node-mastra/package.json @@ -5,12 +5,14 @@ "type": "module", "scripts": { "build": "mastra build", - "start": "mastra start --custom-args='--import=../../instrument.mjs'", + "start": "mastra start --custom-args=\"--import=$PWD/instrument.mjs\"", + "dev": "mastra dev --custom-args=\"--import=$PWD/instrument.mjs\"", "clean": "npx rimraf node_modules .mastra pnpm-lock.yaml", - "test": "playwright test", "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" + "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", @@ -20,12 +22,12 @@ "@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", - "@sentry/core": "file:../../packed/sentry-core-packed.tgz", "@types/node": "24.x", "mastra": "~1.28.0", "typescript": "~5.9.0" diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/node-mastra/playwright.config.mjs index 32a3e2a36e60..d46cb5b6285c 100644 --- a/dev-packages/e2e-tests/test-applications/node-mastra/playwright.config.mjs +++ b/dev-packages/e2e-tests/test-applications/node-mastra/playwright.config.mjs @@ -1,10 +1,20 @@ 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: 'pnpm start', - // Mastra's built Hono server listens on 4111 by default (set explicitly in - // src/mastra/index.ts), not the 3030 the helper assumes. + 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 diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/agents/weather-agent.ts b/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/agents/weather-agent.ts index 18d05868735d..faaeb9d4e2ff 100644 --- a/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/agents/weather-agent.ts +++ b/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/agents/weather-agent.ts @@ -5,11 +5,14 @@ import { LibSQLStore } from '@mastra/libsql'; 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: process.env.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, diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/index.ts b/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/index.ts index 7c74b4268462..d82c65b571f6 100644 --- a/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/index.ts +++ b/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/index.ts @@ -1,13 +1,16 @@ import { Mastra } from '@mastra/core'; import { registerApiRoute } from '@mastra/core/server'; import { LibSQLStore } from '@mastra/libsql'; +import DataLoader from 'dataloader'; import { WEATHER_AGENT, weatherAgent } from './agents/weather-agent.js'; // `Sentry.init` is NOT imported here — it is preloaded via `--import ./instrument.mjs` // (see the `start` script). Mastra's Rollup drops a side-effect-only import of an // instrument module from the built entry, so init has to run as a preload, before -// this bundle (and `@mastra/core`) load. That preload also keeps `@sentry/*` out of -// the bundle entirely, so no `transpilePackages`/`externals` are needed for Sentry. +// this bundle (and `@mastra/core`) load. No `@sentry/*` is imported into the bundle: +// the preload resolves the SDK from the app root, while the bundle would resolve it +// from `.mastra/output` — a second copy of the same version, which conflicts when a +// bundle-created span is flushed by the preload's client. interface RunBody { message: string; @@ -35,11 +38,37 @@ const runRoute = registerApiRoute('/run', { }, }); +// Exercises orchestrion-instrumented `dataloader` directly in a route handler. +// `dataloader`'s `load` uses `requiresParentSpan`, so it only emits a `cache.get` +// span when a span is active. The handler runs inside the request's active +// `http.server` span (the same active span Mastra's own spans parent to), which is +// created by the preloaded SDK — so the dataloader span is created by that same SDK +// copy and flushes cleanly. (Driving dataloader through the agent instead produces +// no span: Mastra runs tools with inactive spans, so there is no active parent.) +const dataloaderRoute = registerApiRoute('/dataloader', { + method: 'POST', + async handler(c) { + const loader = new DataLoader(async keys => keys.map(key => key.length)); + const counts = await Promise.all(['a', 'bb', 'ccc'].map(name => loader.load(name))); + return c.json({ counts }); + }, +}); + export const mastra = new Mastra({ agents: { [WEATHER_AGENT]: weatherAgent }, storage: new LibSQLStore({ id: 'mastra-storage', url: ':memory:' }), server: { port: 4111, - apiRoutes: [runRoute], + apiRoutes: [runRoute, dataloaderRoute], + }, + bundler: { + // Force `dataloader` external. Mastra externalizes framework packages like + // `@mastra/core` by default (so orchestrion can hook them), but inlines small + // pure-JS deps like `dataloader` — and the runtime transform can only + // instrument a real module, not an inlined one. Keeping it external makes the + // orchestrion `dataloader` instrumentation work (see tests/dataloader.test.ts). + // `externals` is merged with Mastra's analyzed externals, so this does not + // affect `@mastra/core` et al. (Mirrors eve's `externalDependencies: ['dataloader']`.) + externals: ['dataloader'], }, }); diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/tests/dataloader.test.ts b/dev-packages/e2e-tests/test-applications/node-mastra/tests/dataloader.test.ts new file mode 100644 index 000000000000..5edf46479ce6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-mastra/tests/dataloader.test.ts @@ -0,0 +1,32 @@ +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; + +// `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'; + +// Driven by the dedicated `/dataloader` route, which uses `dataloader` inside an +// explicit active span. (The agent flow can't be used here: Mastra runs tools with +// inactive spans, and dataloader's `load` needs an active parent to emit a span.) +test('captures orchestrion-instrumented dataloader spans', async ({ baseURL }) => { + const spansPromise = collectStreamedSpans(APP, spansOfTrace => spansOfTrace.some(isDataloaderSpan)); + + const res = await fetch(`${baseURL}/dataloader`, { method: 'POST' }); + expect(res.status).toBe(200); + await res.text(); + + 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'); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/tests/mastra.test.ts b/dev-packages/e2e-tests/test-applications/node-mastra/tests/mastra.test.ts index bee4dc74e24f..551a0ff9f0e4 100644 --- a/dev-packages/e2e-tests/test-applications/node-mastra/tests/mastra.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-mastra/tests/mastra.test.ts @@ -17,35 +17,52 @@ const describeSpan = (span: SerializedStreamedSpan): { op: string | undefined; n name: span.name, origin: attrValue(span, 'sentry.origin'), }); +// All tests share one app + proxy, so we scope each to its own trace by the unique +// tool it triggers — otherwise a test could match another test's agent-run trace +// (they all share the same gen_ai ops). +const callsTool = + (toolName: string) => + (span: SerializedStreamedSpan): boolean => + isOp('gen_ai.execute_tool')(span) && attrValue(span, 'gen_ai.tool.name') === toolName; test('captures Mastra agent spans (invoke_agent, chat, execute_tool) with inputs/outputs and conversation id', async ({ baseURL, }) => { const thread = `e2e-thread-${Date.now()}`; - // The agent run, its model call and the tool call all land in one trace. The - // parent `invoke_agent` span can flush in a separate envelope from its - // children, so accumulate the trace's spans across envelopes until every - // expected gen_ai op has arrived. Match on op alone (not origin), so a - // double-instrumented span is collected too and caught by the assertions below. - const traceSpansPromise = collectStreamedSpans(APP, spansOfTrace => - ['gen_ai.invoke_agent', 'gen_ai.chat', 'gen_ai.execute_tool'].every(op => spansOfTrace.some(isOp(op))), + // Accumulate this turn's trace — identified by its `get_weather` tool call — + // across envelopes until the agent/model spans have also arrived. Tool spans are + // matched by op alone (not origin), so a double-instrumented span is collected + // too and caught by the assertions below. + const traceSpansPromise = collectStreamedSpans( + APP, + spansOfTrace => + spansOfTrace.some(callsTool('get_weather')) && + ['gen_ai.invoke_agent', 'gen_ai.chat'].every(op => spansOfTrace.some(isOp(op))), ); await runAgentTurn(baseURL!, 'What is the weather in Paris?', { thread, resource: 'e2e-user' }); const traceSpans = await traceSpansPromise; - // No double instrumentation - ensure there are no gen ai spans with another origin + // No double instrumentation. Mastra drives the Vercel AI SDK internally, so + // Sentry's vercel-ai integration could also emit spans for the same calls. The + // Mastra integration must be the only source of AI spans: every gen_ai span is + // `auto.ai.mastra`, and there must be no vercel-ai spans in the trace at all. expect( traceSpans .filter(span => isGenAiSpan(span) && attrValue(span, 'sentry.origin') !== MASTRA_ORIGIN) .map(describeSpan), ).toEqual([]); + expect( + traceSpans + .filter(span => String(attrValue(span, 'sentry.origin') ?? '').startsWith('auto.vercelai')) + .map(describeSpan), + ).toEqual([]); const invokeAgent = traceSpans.find(isOp('gen_ai.invoke_agent')); const chat = traceSpans.find(isOp('gen_ai.chat')); - const executeTool = traceSpans.find(isOp('gen_ai.execute_tool')); + const executeTool = traceSpans.find(callsTool('get_weather')); // Agent span. expect(attrValue(invokeAgent!, 'gen_ai.operation.name')).toBe('invoke_agent'); @@ -94,14 +111,17 @@ test('records a bubbled-up Mastra tool error on the span', async ({ baseURL }) = // 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. const erroredToolSpanPromise = collectStreamedSpans(APP, spansOfTrace => - spansOfTrace.some(span => isOp('gen_ai.execute_tool')(span) && Boolean(attrValue(span, 'error.type'))), + spansOfTrace.some(span => callsTool('fail_now')(span) && Boolean(attrValue(span, 'error.type'))), ); await runAgentTurn(baseURL!, 'Please call the tool that triggers a failure now.'); const spans = await erroredToolSpanPromise; - const erroredTool = spans.find(span => isOp('gen_ai.execute_tool')(span) && Boolean(attrValue(span, 'error.type'))); + const erroredTool = spans.find(span => callsTool('fail_now')(span) && Boolean(attrValue(span, 'error.type'))); // The errored tool span comes from the Mastra exporter (no double instrumentation). expect(attrValue(erroredTool!, 'sentry.origin')).toBe(MASTRA_ORIGIN); From d96cb49c88894c43b82b23b9e2de7f6daca886ba Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 11 Sep 2026 12:49:47 +0200 Subject: [PATCH 3/9] test fixes --- .../node-mastra/instrument.mjs | 20 ------- .../node-mastra/package.json | 6 +- .../node-mastra/src/mastra/index.ts | 57 ++++++++++--------- .../src/mastra/public/instrument.mjs | 8 +++ 4 files changed, 41 insertions(+), 50 deletions(-) delete mode 100644 dev-packages/e2e-tests/test-applications/node-mastra/instrument.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/public/instrument.mjs diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/instrument.mjs b/dev-packages/e2e-tests/test-applications/node-mastra/instrument.mjs deleted file mode 100644 index 92de795c84a6..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-mastra/instrument.mjs +++ /dev/null @@ -1,20 +0,0 @@ -import * as Sentry from '@sentry/node'; - -// Preloaded via `node --import ./instrument.mjs` (through `mastra start --custom-args`, -// see package.json). This is the standard @sentry/node pattern for a bundled -// server: `Sentry.init` must run before the app (and `@mastra/core`) load, and it -// must NOT be bundled — Mastra's Rollup drops a side-effect-only import of an -// instrument module from the built entry, so an in-bundle `import './instrument'` -// never runs. -// -// This file is NOT bundled: it stays at the app root and is loaded from -// `node_modules` at runtime, the same @sentry/node copy the `@sentry/node/import` -// orchestrion loader uses — so the Mastra integration's constructor subscriber -// (set up here by `init`) and the constructor transform (set up by the loader) -// share one SDK instance and one diagnostics channel. -Sentry.init({ - environment: 'qa', - dsn: process.env.E2E_TEST_DSN, - tunnel: 'http://localhost:3031/', // proxy server - tracesSampleRate: 1.0, -}); diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/package.json b/dev-packages/e2e-tests/test-applications/node-mastra/package.json index 40393cca80ef..20340b7e81a6 100644 --- a/dev-packages/e2e-tests/test-applications/node-mastra/package.json +++ b/dev-packages/e2e-tests/test-applications/node-mastra/package.json @@ -4,9 +4,9 @@ "private": true, "type": "module", "scripts": { - "build": "mastra build", - "start": "mastra start --custom-args=\"--import=$PWD/instrument.mjs\"", - "dev": "mastra dev --custom-args=\"--import=$PWD/instrument.mjs\"", + "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\"", "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", diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/index.ts b/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/index.ts index d82c65b571f6..55f10edc0d10 100644 --- a/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/index.ts +++ b/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/index.ts @@ -1,17 +1,10 @@ +import { startSpan } from '@sentry/node'; import { Mastra } from '@mastra/core'; import { registerApiRoute } from '@mastra/core/server'; import { LibSQLStore } from '@mastra/libsql'; import DataLoader from 'dataloader'; import { WEATHER_AGENT, weatherAgent } from './agents/weather-agent.js'; -// `Sentry.init` is NOT imported here — it is preloaded via `--import ./instrument.mjs` -// (see the `start` script). Mastra's Rollup drops a side-effect-only import of an -// instrument module from the built entry, so init has to run as a preload, before -// this bundle (and `@mastra/core`) load. No `@sentry/*` is imported into the bundle: -// the preload resolves the SDK from the app root, while the bundle would resolve it -// from `.mastra/output` — a second copy of the same version, which conflicts when a -// bundle-created span is flushed by the preload's client. - interface RunBody { message: string; thread?: string; @@ -38,19 +31,21 @@ const runRoute = registerApiRoute('/run', { }, }); -// Exercises orchestrion-instrumented `dataloader` directly in a route handler. -// `dataloader`'s `load` uses `requiresParentSpan`, so it only emits a `cache.get` -// span when a span is active. The handler runs inside the request's active -// `http.server` span (the same active span Mastra's own spans parent to), which is -// created by the preloaded SDK — so the dataloader span is created by that same SDK -// copy and flushes cleanly. (Driving dataloader through the agent instead produces -// no span: Mastra runs tools with inactive spans, so there is no active parent.) +// Exercises the auto-instrumented `dataloader` in a route handler. `dataloader`'s +// `load` needs an active parent span to emit a `cache.get` span — the handler runs +// inside the request's active `http.server` span (created by the preloaded SDK), so +// the span is created and flushed by that same SDK copy. (Driving dataloader through +// the agent instead produces no span: Mastra runs tools with inactive spans, so +// there is no active parent; and creating one with `startSpan` here isn't possible — +// see the header note on the two-copies limitation.) const dataloaderRoute = registerApiRoute('/dataloader', { method: 'POST', - async handler(c) { - const loader = new DataLoader(async keys => keys.map(key => key.length)); - const counts = await Promise.all(['a', 'bb', 'ccc'].map(name => loader.load(name))); - return c.json({ counts }); + handler(c) { + return startSpan({ name: 'dataloader-test', op: 'test' }, async () => { + const loader = new DataLoader(async keys => keys.map(key => key.length)); + const counts = await Promise.all(['a', 'bb', 'ccc'].map(name => loader.load(name))); + return c.json({ counts }); + }); }, }); @@ -62,13 +57,21 @@ export const mastra = new Mastra({ apiRoutes: [runRoute, dataloaderRoute], }, bundler: { - // Force `dataloader` external. Mastra externalizes framework packages like - // `@mastra/core` by default (so orchestrion can hook them), but inlines small - // pure-JS deps like `dataloader` — and the runtime transform can only - // instrument a real module, not an inlined one. Keeping it external makes the - // orchestrion `dataloader` instrumentation work (see tests/dataloader.test.ts). - // `externals` is merged with Mastra's analyzed externals, so this does not - // affect `@mastra/core` et al. (Mirrors eve's `externalDependencies: ['dataloader']`.) - externals: ['dataloader'], + // `dataloader` must stay a real module so it can be instrumented. `@mastra/core` + // is forced external too (diagnostic): if Mastra inlined it, the orchestrion + // runtime hook could never transform its constructor. + externals: ['dataloader', '@mastra/core', '@sentry/node'], }, }); + +/** + * TODO + * + * + * 1. Tool result not captured — Mastra doesn't populate output on exported tool_call spans, so gen_ai.tool.call.result is empty (arguments are captured). + 2. Bubbled-up tool errors aren't captured as issues — only reflected on the span (status + error.type); the exporter leaves captureException to the app. + 3. Conversation id needs the nested memory: { thread, resource } API — the built-in REST endpoint's top-level threadId/resourceId don't populate tracing metadata. + 4. Two SDK copies when creating spans from the Mastra bundle — mastra build's separate output install + preload-init means bundle-created spans (startSpan) conflict with the preload's client. Auto-instrumentation + is unaffected; manual Sentry span APIs in bundled Mastra code are the sharp edge. (This is why dataloader is exercised via the active http.server span, not a manual startSpan.) + 5. dataloader/requiresParentSpan — orchestrion span-openers need an active parent; Mastra runs tools with inactive spans, so they don't emit in the agent flow. + */ diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/public/instrument.mjs b/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/public/instrument.mjs new file mode 100644 index 000000000000..b774d9306950 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/public/instrument.mjs @@ -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, +}); From ea812cf003752f20200661a47791b251d42165b6 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 11 Sep 2026 12:52:52 +0200 Subject: [PATCH 4/9] remove bundler for tests --- .../test-applications/node-mastra/src/mastra/index.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/index.ts b/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/index.ts index 55f10edc0d10..5068c2e32954 100644 --- a/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/index.ts +++ b/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/index.ts @@ -56,12 +56,6 @@ export const mastra = new Mastra({ port: 4111, apiRoutes: [runRoute, dataloaderRoute], }, - bundler: { - // `dataloader` must stay a real module so it can be instrumented. `@mastra/core` - // is forced external too (diagnostic): if Mastra inlined it, the orchestrion - // runtime hook could never transform its constructor. - externals: ['dataloader', '@mastra/core', '@sentry/node'], - }, }); /** From c8902d693b2b732405e70c5f17142d850fef5f0a Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Mon, 14 Sep 2026 10:41:04 +0200 Subject: [PATCH 5/9] fix tests --- .../src/mastra/agents/weather-agent.ts | 4 +- .../node-mastra/src/mastra/index.ts | 70 ++++++------------- .../src/mastra/tools/count-items.ts | 26 +++++++ .../src/mastra/tools/get-weather.ts | 4 +- .../node-mastra/tests/dataloader.test.ts | 25 ++++--- .../node-mastra/tests/manual-route.test.ts | 39 +++++++++++ .../node-mastra/tests/mastra.test.ts | 13 ++-- .../node-mastra/tests/utils.ts | 32 +++++---- 8 files changed, 134 insertions(+), 79 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/tools/count-items.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-mastra/tests/manual-route.test.ts diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/agents/weather-agent.ts b/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/agents/weather-agent.ts index faaeb9d4e2ff..8b33b4cc4d5e 100644 --- a/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/agents/weather-agent.ts +++ b/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/agents/weather-agent.ts @@ -2,6 +2,7 @@ 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'; @@ -32,9 +33,10 @@ export const weatherAgent = new Agent({ '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 }, + tools: { get_weather: getWeatherTool, fail_now: failNowTool, count_items: countItemsTool }, memory, }); diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/index.ts b/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/index.ts index 5068c2e32954..533f3801bd77 100644 --- a/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/index.ts +++ b/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/index.ts @@ -1,51 +1,19 @@ -import { startSpan } from '@sentry/node'; import { Mastra } from '@mastra/core'; import { registerApiRoute } from '@mastra/core/server'; import { LibSQLStore } from '@mastra/libsql'; -import DataLoader from 'dataloader'; import { WEATHER_AGENT, weatherAgent } from './agents/weather-agent.js'; -interface RunBody { - message: string; - thread?: string; - resource?: string; -} +// 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 custom route (rather than the built-in `/api/agents/:id/generate`) so we can -// pass the modern nested `memory: { thread, resource }` shape to `generate()`. -// That is what makes Mastra engage memory and stamp the thread id onto the -// AI-tracing spans (as `metadata.threadId` / `attributes.conversationId`, which the -// exporter maps to `gen_ai.conversation.id`); the built-in endpoint's top-level -// `threadId`/`resourceId` do not populate tracing metadata. -const runRoute = registerApiRoute('/run', { - method: 'POST', - async handler(c) { - const mastra = c.get('mastra'); - const { message, thread, resource } = (await c.req.json()) as RunBody; - const agent = mastra.getAgent(WEATHER_AGENT); - const result = await agent.generate( - message, - thread ? { memory: { thread, resource: resource ?? 'e2e-user' } } : {}, - ); - return c.json({ text: result.text }); - }, -}); - -// Exercises the auto-instrumented `dataloader` in a route handler. `dataloader`'s -// `load` needs an active parent span to emit a `cache.get` span — the handler runs -// inside the request's active `http.server` span (created by the preloaded SDK), so -// the span is created and flushed by that same SDK copy. (Driving dataloader through -// the agent instead produces no span: Mastra runs tools with inactive spans, so -// there is no active parent; and creating one with `startSpan` here isn't possible — -// see the header note on the two-copies limitation.) -const dataloaderRoute = registerApiRoute('/dataloader', { - method: 'POST', +// 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 startSpan({ name: 'dataloader-test', op: 'test' }, async () => { - const loader = new DataLoader(async keys => keys.map(key => key.length)); - const counts = await Promise.all(['a', 'bb', 'ccc'].map(name => loader.load(name))); - return c.json({ counts }); - }); + return c.json({ ok: true }); }, }); @@ -54,7 +22,17 @@ export const mastra = new Mastra({ storage: new LibSQLStore({ id: 'mastra-storage', url: ':memory:' }), server: { port: 4111, - apiRoutes: [runRoute, dataloaderRoute], + apiRoutes: [manualRoute], + }, + // `dataloader` MUST stay an external (real node_modules module) rather than be + // bundled inline. Sentry's orchestrion instrumentation transforms `dataloader`'s + // on-disk `index.js` at require time to publish tracing channels; a copy inlined + // into the Mastra bundle is never transformed, so no `cache.get` span is emitted. + // (When `dataloader` was imported from the entry `index.ts` Mastra kept it + // external automatically; reaching it only through the bundled `count_items` tool + // makes Mastra inline it unless it is listed here.) + bundler: { + externals: ['dataloader'], }, }); @@ -64,8 +42,6 @@ export const mastra = new Mastra({ * * 1. Tool result not captured — Mastra doesn't populate output on exported tool_call spans, so gen_ai.tool.call.result is empty (arguments are captured). 2. Bubbled-up tool errors aren't captured as issues — only reflected on the span (status + error.type); the exporter leaves captureException to the app. - 3. Conversation id needs the nested memory: { thread, resource } API — the built-in REST endpoint's top-level threadId/resourceId don't populate tracing metadata. - 4. Two SDK copies when creating spans from the Mastra bundle — mastra build's separate output install + preload-init means bundle-created spans (startSpan) conflict with the preload's client. Auto-instrumentation - is unaffected; manual Sentry span APIs in bundled Mastra code are the sharp edge. (This is why dataloader is exercised via the active http.server span, not a manual startSpan.) - 5. dataloader/requiresParentSpan — orchestrion span-openers need an active parent; Mastra runs tools with inactive spans, so they don't emit in the agent flow. - */ + 5. dataloader/requiresParentSpan — orchestrion span-openers need an active parent; 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. + 6. parametrized routes for mastra? + */ diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/tools/count-items.ts b/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/tools/count-items.ts new file mode 100644 index 000000000000..827241434f22 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/tools/count-items.ts @@ -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(async keys => keys.map(key => key.length)); + const counts = await Promise.all(inputData.names.map(name => loader.load(name))); + return { counts }; + }); + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/tools/get-weather.ts b/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/tools/get-weather.ts index 8c381f2b616a..bdcdf8cf1601 100644 --- a/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/tools/get-weather.ts +++ b/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/tools/get-weather.ts @@ -10,7 +10,7 @@ export const getWeatherTool = createTool({ condition: z.string(), temperatureC: z.number(), }), - async execute({ context }) { - return { city: context.city, condition: 'Sunny', temperatureC: 22 }; + async execute(inputData) { + return { city: inputData.city, condition: 'Sunny', temperatureC: 22 }; }, }); diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/tests/dataloader.test.ts b/dev-packages/e2e-tests/test-applications/node-mastra/tests/dataloader.test.ts index 5edf46479ce6..ac06a5e3f56b 100644 --- a/dev-packages/e2e-tests/test-applications/node-mastra/tests/dataloader.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-mastra/tests/dataloader.test.ts @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpans, getSpanOp, SerializedStreamedSpan } from '@sentry-internal/test-utils'; +import { runAgentTurn } from './utils'; const APP = 'node-mastra'; @@ -13,15 +14,23 @@ const attrValue = (span: SerializedStreamedSpan, key: string): unknown => span.a const isDataloaderSpan = (span: SerializedStreamedSpan): boolean => getSpanOp(span) === 'cache.get' && attrValue(span, 'sentry.origin') === 'auto.db.dataloader'; -// Driven by the dedicated `/dataloader` route, which uses `dataloader` inside an -// explicit active span. (The agent flow can't be used here: Mastra runs tools with -// inactive spans, and dataloader's `load` needs an active parent to emit a span.) -test('captures orchestrion-instrumented dataloader spans', async ({ baseURL }) => { - const spansPromise = collectStreamedSpans(APP, spansOfTrace => spansOfTrace.some(isDataloaderSpan)); +// 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'; - const res = await fetch(`${baseURL}/dataloader`, { method: 'POST' }); - expect(res.status).toBe(200); - await res.text(); +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); diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/tests/manual-route.test.ts b/dev-packages/e2e-tests/test-applications/node-mastra/tests/manual-route.test.ts new file mode 100644 index 000000000000..e3aa759000cc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-mastra/tests/manual-route.test.ts @@ -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(); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/tests/mastra.test.ts b/dev-packages/e2e-tests/test-applications/node-mastra/tests/mastra.test.ts index 551a0ff9f0e4..cdbeea0c738d 100644 --- a/dev-packages/e2e-tests/test-applications/node-mastra/tests/mastra.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-mastra/tests/mastra.test.ts @@ -86,15 +86,10 @@ test('captures Mastra agent spans (invoke_agent, chat, execute_tool) with inputs expect(String(attrValue(invokeAgent!, 'gen_ai.input.messages') ?? '')).toContain('Paris'); expect(String(attrValue(invokeAgent!, 'gen_ai.output.messages') ?? '')).not.toBe(''); expect(String(attrValue(executeTool!, 'gen_ai.tool.call.arguments') ?? '')).toContain('Paris'); - // NOTE: current behavior — the tool *arguments* are captured, but the tool - // *result* is not. Mastra does not populate `output` on the exported - // `tool_call` span at export time (the exporter maps `span.output` to - // `gen_ai.tool.call.result`), so it comes through empty even though the tool - // returns `{ city, condition: 'Sunny', temperatureC: 22 }`. Asserting the - // empty value documents the gap; if Mastra starts emitting tool output this - // will fail and should become `.toContain('Sunny')`. Follow-up: capture tool - // results for the Mastra integration. - expect(String(attrValue(executeTool!, 'gen_ai.tool.call.result') ?? '')).toBe(''); + // Both the tool arguments and the tool result are captured. The exporter maps + // Mastra's tool `output` to `gen_ai.tool.call.result`; here the tool returns + // `{ city, condition: 'Sunny', temperatureC: 22 }`. + expect(String(attrValue(executeTool!, 'gen_ai.tool.call.result') ?? '')).toContain('Sunny'); // Conversation id: the exporter maps Mastra's `metadata.threadId` to // `gen_ai.conversation.id` on every mapped span. diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/tests/utils.ts b/dev-packages/e2e-tests/test-applications/node-mastra/tests/utils.ts index 0decc8933e66..62ee3b287436 100644 --- a/dev-packages/e2e-tests/test-applications/node-mastra/tests/utils.ts +++ b/dev-packages/e2e-tests/test-applications/node-mastra/tests/utils.ts @@ -1,28 +1,36 @@ import { expect } from '@playwright/test'; +const AGENT_ID = 'weatherAgent'; + /** - * Drive one agent turn through the app's custom `/run` route and wait for it to - * settle. `generate` runs to completion server-side before the response is sent, - * so once the POST resolves the run is finished and its spans have been created; - * we then only wait for the envelopes to reach the proxy. + * Drive one agent turn through Mastra's built-in generate endpoint and wait for it + * to settle. `generate` runs to completion server-side before the response is sent, + * so once the POST resolves the run is finished and its spans have been created; we + * then only wait for the envelopes to reach the proxy. * - * `thread`/`resource` are passed to `generate` as `memory: { thread, resource }`, - * which is what makes Mastra stamp the thread id onto the spans (→ - * `gen_ai.conversation.id`). + * `thread`/`resource` are sent as the nested `memory: { thread, resource }` shape — + * the modern `generate` path. That is what makes Mastra stamp the thread id onto the + * spans (→ `gen_ai.conversation.id`). NOTE: top-level `threadId`/`resourceId` would + * instead route to the deprecated `generateLegacy` path, which the AI instrumentation + * does not cover — so the nested shape is required here. */ export async function runAgentTurn( baseURL: string, message: string, options: { thread?: string; resource?: string } = {}, ): Promise<{ status: number }> { - const res = await fetch(`${baseURL}/run`, { + const res = await fetch(`${baseURL}/api/agents/${AGENT_ID}/generate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ message, ...options }), + body: JSON.stringify({ + messages: message, + ...(options.thread ? { memory: { thread: options.thread, resource: options.resource ?? 'e2e-user' } } : {}), + }), }); - // 200 on success; a bubbled-up tool error yields a 500 (the error test relies - // on that). Either is fine — callers assert on spans/errors, not this status. - expect([200, 500]).toContain(res.status); + // A 404 means the endpoint/agent id is wrong — surface that instead of timing out + // later on missing spans. Success is 200; a bubbled-up tool error yields a 500, + // which the error test exercises on purpose. + expect(res.status).not.toBe(404); await res.text(); return { status: res.status }; } From dd226aac93bc4183bb84ffa01dd98bc7593c7779 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Mon, 14 Sep 2026 10:41:51 +0200 Subject: [PATCH 6/9] update todos --- .../test-applications/node-mastra/src/mastra/index.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/index.ts b/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/index.ts index 533f3801bd77..24fa3c98f495 100644 --- a/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/index.ts +++ b/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/index.ts @@ -40,8 +40,7 @@ export const mastra = new Mastra({ * TODO * * - * 1. Tool result not captured — Mastra doesn't populate output on exported tool_call spans, so gen_ai.tool.call.result is empty (arguments are captured). - 2. Bubbled-up tool errors aren't captured as issues — only reflected on the span (status + error.type); the exporter leaves captureException to the app. - 5. dataloader/requiresParentSpan — orchestrion span-openers need an active parent; 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. - 6. parametrized routes for mastra? + 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? */ From babf76f939ced160925791646249781650fb32d3 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Mon, 14 Sep 2026 10:47:00 +0200 Subject: [PATCH 7/9] cleanup --- .../test-applications/node-mastra/src/mastra/index.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/index.ts b/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/index.ts index 24fa3c98f495..91455e1fc07f 100644 --- a/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/index.ts +++ b/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/index.ts @@ -24,16 +24,6 @@ export const mastra = new Mastra({ port: 4111, apiRoutes: [manualRoute], }, - // `dataloader` MUST stay an external (real node_modules module) rather than be - // bundled inline. Sentry's orchestrion instrumentation transforms `dataloader`'s - // on-disk `index.js` at require time to publish tracing channels; a copy inlined - // into the Mastra bundle is never transformed, so no `cache.get` span is emitted. - // (When `dataloader` was imported from the entry `index.ts` Mastra kept it - // external automatically; reaching it only through the bundled `count_items` tool - // makes Mastra inline it unless it is listed here.) - bundler: { - externals: ['dataloader'], - }, }); /** From 06e931c6aec5dd56530f1e14e92a7c78ec6d6523 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Mon, 14 Sep 2026 11:16:40 +0200 Subject: [PATCH 8/9] feat(node): Name Mastra server routes from their route pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mastra serves its API and custom `registerApiRoute`s on an internal Hono server, so Sentry's HTTP instrumentation named the incoming `http.server` span from the raw URL (high cardinality, no `http.route`). `@sentry/hono` can't help — it instruments a Hono app you own, and Mastra never exposes its app instance. The Mastra integration now injects a route-naming Hono middleware into the Mastra server config at construction time (via the orchestrion `Mastra` constructor channel, before Mastra reads `config.server`). After the handler runs it resolves the matched route pattern from the Hono context and upgrades the root `http.server` span to `${method} ${route}` with `http.route` set and name source `route` — reusing `setHttpServerSpanRouteAttribute`. Works for built-in API routes (`POST /api/agents/:agentId/generate`) and custom routes (`GET /echo/:id`) alike, with zero app code. Disable with `mastraIntegration({ instrumentServerRoutes: false })`. Adds a parametrized route + `http.server` route-naming coverage to the node-mastra e2e app, and unit tests for the injection and route resolution. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01RaYGdTstWVZKU9X4NjtYV1 --- CHANGELOG.md | 1 + .../node-mastra/src/mastra/index.ts | 19 ++- .../node-mastra/tests/manual-route.test.ts | 61 +++++--- .../src/integrations/mastra-route-naming.ts | 122 ++++++++++++++++ .../server-utils/src/integrations/mastra.ts | 30 +++- .../mastra/route-naming-optout.test.ts | 25 ++++ .../integrations/mastra/route-naming.test.ts | 136 ++++++++++++++++++ 7 files changed, 366 insertions(+), 28 deletions(-) create mode 100644 packages/server-utils/src/integrations/mastra-route-naming.ts create mode 100644 packages/server-utils/test/integrations/mastra/route-naming-optout.test.ts create mode 100644 packages/server-utils/test/integrations/mastra/route-naming.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d65296eaca89..74c6b7cf1772 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Work in this release was contributed by @psh4607, @thijsw, @trinitiwowka, @nehap - `DenoMysql` => `Mysql` - `DenoPostgres` => `Postgres` - feat(node): Add first-party Mastra integration ([#23823](https://github.com/getsentry/sentry-javascript/pull/23823)). Enabled by default; disable with `defaultIntegrations: integrations => integrations.filter(i => i.name !== 'Mastra')`. +- feat(node): Name Mastra server routes from their route pattern. The Mastra integration now names incoming requests' `http.server` span after the matched Hono route (e.g. `POST /api/agents/:agentId/generate`, `GET /echo/:id`) with `http.route` set and name source `route`, instead of the raw URL — for both built-in API routes and custom `registerApiRoute`s. This keeps route transactions low-cardinality. Disable with `mastraIntegration({ instrumentServerRoutes: false })`. - feat(node): Enable the `dataloader` and `knex` integrations by default. Both were previously opt-in — `dataloader` was removed from the defaults in v8 due to an upstream OpenTelemetry bug that has since been fixed, and `knex` was never enabled by default. You no longer need to add `dataloaderIntegration()` or `knexIntegration()` manually. Disable either with `defaultIntegrations: integrations => integrations.filter(i => i.name !== 'Dataloader' /* or 'Knex' */)`. - **feat(browser): Add `bfcacheMetricsIntegration` to track back/forward cache health** diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/index.ts b/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/index.ts index 91455e1fc07f..333e7a43171c 100644 --- a/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/index.ts +++ b/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/index.ts @@ -7,9 +7,9 @@ import { WEATHER_AGENT, weatherAgent } from './agents/weather-agent.js'; // 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. +// A plain (static) custom route: verifies requests to app-registered routes are wrapped in an +// `http.server` span. With the Mastra integration's route naming, the span is named `GET /manual-route` +// (name source `route`, `http.route` set) — see tests/manual-route.test.ts. const manualRoute = registerApiRoute('/manual-route', { method: 'GET', handler(c) { @@ -17,12 +17,22 @@ const manualRoute = registerApiRoute('/manual-route', { }, }); +// A parametrized custom route: verifies the integration names the `http.server` span from the matched +// route *pattern* (`GET /echo/:id`), not the raw URL — so `/echo/42` and `/echo/99` collapse to one +// low-cardinality transaction with `http.route: /echo/:id`. +const echoRoute = registerApiRoute('/echo/:id', { + method: 'GET', + handler(c) { + return c.json({ id: c.req.param('id') }); + }, +}); + export const mastra = new Mastra({ agents: { [WEATHER_AGENT]: weatherAgent }, storage: new LibSQLStore({ id: 'mastra-storage', url: ':memory:' }), server: { port: 4111, - apiRoutes: [manualRoute], + apiRoutes: [manualRoute, echoRoute], }, }); @@ -32,5 +42,4 @@ export const mastra = new Mastra({ * 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? */ diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/tests/manual-route.test.ts b/dev-packages/e2e-tests/test-applications/node-mastra/tests/manual-route.test.ts index e3aa759000cc..940a8e97978f 100644 --- a/dev-packages/e2e-tests/test-applications/node-mastra/tests/manual-route.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-mastra/tests/manual-route.test.ts @@ -5,35 +5,54 @@ 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)); +// Custom routes registered on the Mastra (Hono) server — see `src/mastra/index.ts`. These verify the +// Mastra integration's zero-config route naming: incoming requests get an `http.server` span named +// from the matched route *pattern* (name source `route`, `http.route` set), for both static and +// parametrized routes, with no app-level middleware. Runs in both the prod (`mastra start`) and dev +// (`mastra dev`) variants. +const serverSpanForUrl = + (urlIncludes: string) => + (span: SerializedStreamedSpan): boolean => + getSpanOp(span) === 'http.server' && String(attrValue(span, 'url.full') ?? '').includes(urlIncludes); + +test('names a static custom route from its route pattern', async ({ baseURL }) => { + const spansPromise = collectStreamedSpans(APP, spansOfTrace => spansOfTrace.some(serverSpanForUrl('/manual-route'))); 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); + const serverSpan = (await spansPromise).find(serverSpanForUrl('/manual-route')); 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(); + + // The Mastra integration upgrades the span from the raw URL to the route pattern: for a static + // route these are identical, but the name source is `route` and `http.route` is set. + expect(serverSpan!.name).toBe('GET /manual-route'); + expect(attrValue(serverSpan!, 'http.route')).toBe('/manual-route'); + expect(attrValue(serverSpan!, 'sentry.segment.name.source')).toBe('route'); +}); + +test('names a parametrized custom route from its route pattern (low cardinality)', async ({ baseURL }) => { + // Two different ids must collapse to the same `/echo/:id` transaction. + for (const id of ['42', '99']) { + const spansPromise = collectStreamedSpans(APP, spansOfTrace => + spansOfTrace.some(span => serverSpanForUrl(`/echo/${id}`)(span)), + ); + + const res = await fetch(`${baseURL}/echo/${id}`, { method: 'GET' }); + expect(res.status).toBe(200); + await res.json(); + + const serverSpan = (await spansPromise).find(serverSpanForUrl(`/echo/${id}`)); + + expect(serverSpan).toBeDefined(); + // Name and route are the pattern, not the concrete URL (`/echo/42`). + expect(serverSpan!.name).toBe('GET /echo/:id'); + expect(attrValue(serverSpan!, 'http.route')).toBe('/echo/:id'); + expect(attrValue(serverSpan!, 'sentry.segment.name.source')).toBe('route'); + } }); diff --git a/packages/server-utils/src/integrations/mastra-route-naming.ts b/packages/server-utils/src/integrations/mastra-route-naming.ts new file mode 100644 index 000000000000..b546aac2352e --- /dev/null +++ b/packages/server-utils/src/integrations/mastra-route-naming.ts @@ -0,0 +1,122 @@ +import { setHttpServerSpanRouteAttribute } from '../utils/setHttpServerSpanRouteAttribute'; + +// Mastra serves its API (and any custom `registerApiRoute`) on a Hono server. Sentry's HTTP server +// instrumentation names the incoming `http.server` span from the raw URL (`sentry.segment.name.source` +// = `url`), because — unlike Express/Fastify — nothing feeds it the matched route pattern. Left alone, +// `/echo/42` and `/echo/99` become distinct high-cardinality transactions. +// +// Mastra owns its Hono app internally and never exposes the instance, so `@sentry/hono` (which wraps an +// app you construct) cannot be applied to it. What Mastra *does* expose is a middleware seam +// (`server.middleware`). We inject the middleware below into the Mastra config at construction time +// (see `injectMastraRouteNamingMiddleware`), read the matched route from the Hono `Context` after the +// handler has run, and upgrade the root `http.server` span to `${method} ${route}` with +// `http.route` set and name source `route` — mirroring what the framework instrumentations do. + +// Hono wraps a sub-app's handlers under this key for a custom `onError`; unwrap it so arity is read off +// the real handler, not the `(c, next)` wrapper. See `@sentry/hono`'s `isMiddleware`. +const COMPOSED_HANDLER = '__COMPOSED_HANDLER'; + +// The subset of Hono's `Context` we rely on. Typed structurally so `@sentry/server-utils` does not need +// a `hono` dependency (it runs inside Mastra's Hono request, so the real shape is present at runtime). +interface HonoRouteContextLike { + req: { + method: string; + routeIndex: number; + routePath: string; + matchedRoutes: Array<{ handler: unknown; path: string }>; + }; +} + +type NextFn = () => Promise; +type MiddlewareHandlerLike = (context: HonoRouteContextLike, next: NextFn) => Promise; + +// Hono has no "is middleware" flag, so infer from arity: middleware is `(c, next)` (>= 2 args), route +// handlers are `(c)` (< 2). Mirrors `@sentry/hono`'s `isMiddleware`. +function isMiddleware(handler: unknown): boolean { + if (typeof handler !== 'function') { + return false; + } + const composed = (handler as unknown as Record)[COMPOSED_HANDLER]; + const original = typeof composed === 'function' ? composed : handler; + return (original as (...args: unknown[]) => unknown).length >= 2; +} + +/** + * Resolve the matched route pattern (e.g. `/echo/:id`) for the current request. Picks the matched + * *handler* rather than trusting `routePath` alone, so a catch-all middleware registered after the + * handlers, or a middleware that short-circuits before them, does not mask the real route. Mirrors + * `@sentry/hono`'s `resolveRouteName` using only the public `Context` API. + */ +function resolveRouteName(context: HonoRouteContextLike): string | undefined { + const routes = context.req.matchedRoutes || []; + + const current = routes[context.req.routeIndex]; + if (current && isRouteHandler(current.handler)) { + return current.path; + } + + for (let i = routes.length - 1; i >= 0; i--) { + const route = routes[i]; + if (route && isRouteHandler(route.handler)) { + return route.path; + } + } + + return context.req.routePath || undefined; +} + +function isRouteHandler(handler: unknown): boolean { + return typeof handler === 'function' && !isMiddleware(handler); +} + +/** + * A Hono middleware that, after the handler runs, upgrades the root `http.server` span to the matched + * route pattern. Safe to run on every request: {@link setHttpServerSpanRouteAttribute} no-ops when + * there is no active/root `http.server` span, and a throw here never breaks the request. + */ +export function createMastraRouteNamingMiddleware(): MiddlewareHandlerLike { + return async function sentryMastraRouteNaming(context, next) { + await next(); + try { + const route = resolveRouteName(context); + if (route) { + setHttpServerSpanRouteAttribute(route); + } + } catch { + // Never let route naming break the request. + } + }; +} + +interface MastraServerConfigLike { + middleware?: unknown; +} + +interface MastraConstructorConfigLike { + server?: MastraServerConfigLike; +} + +/** + * Prepend the route-naming middleware to a Mastra constructor config's `server.middleware`, mutating + * the config in place. Called from the `Mastra` constructor's orchestrion `start` channel, before + * Mastra reads `config.server` — so no reference to the (internal) Hono app is needed. `path: '*'` + * covers built-in API routes and custom `registerApiRoute`s alike. + */ +export function injectMastraRouteNamingMiddleware(config: unknown): void { + if (typeof config !== 'object' || config === null) { + return; + } + + const typedConfig = config as MastraConstructorConfigLike; + const server = (typedConfig.server ??= {}); + const entry = { handler: createMastraRouteNamingMiddleware(), path: '*' }; + + if (Array.isArray(server.middleware)) { + server.middleware.unshift(entry); + } else if (server.middleware) { + // A single middleware (function or `{ handler, path }`) — normalize to an array. + server.middleware = [entry, server.middleware]; + } else { + server.middleware = [entry]; + } +} diff --git a/packages/server-utils/src/integrations/mastra.ts b/packages/server-utils/src/integrations/mastra.ts index 0d50981f421f..a5ccfc5b6214 100644 --- a/packages/server-utils/src/integrations/mastra.ts +++ b/packages/server-utils/src/integrations/mastra.ts @@ -17,6 +17,7 @@ import { CHANNELS } from '../orchestrion/channels'; import { mastraModuleNames } from '../orchestrion/config/mastra'; import { invokeOrchestrionInstrumentation } from '../orchestrion/instrumentation'; import { safeChannelCallback } from '../tracing-channel'; +import { injectMastraRouteNamingMiddleware } from './mastra-route-naming'; export interface MastraOptions extends MastraExporterOptions { /** @@ -24,6 +25,14 @@ export interface MastraOptions extends MastraExporterOptions { * `true`. Uses an `@mastra/observability` the app already has; the SDK never installs it. */ bootstrapObservability?: boolean; + + /** + * Give the incoming `http.server` span the matched route pattern (e.g. + * `POST /api/agents/:agentId/generate`) with `http.route` and name source `route`, instead of the + * raw URL. Works for Mastra's built-in API routes and custom `registerApiRoute`s. Defaults to + * `true`. Set `false` to leave route naming to the app. + */ + instrumentServerRoutes?: boolean; } interface MastraObservabilityInstance { @@ -60,7 +69,20 @@ const _mastraIntegration = ((options: MastraOptions = {}) => { }) satisfies IntegrationFn; function instrumentMastra(options: MastraOptions): void { - diagnosticsChannel.tracingChannel(CHANNELS.MASTRA_CONSTRUCTOR).end.subscribe(message => { + const channel = diagnosticsChannel.tracingChannel(CHANNELS.MASTRA_CONSTRUCTOR); + + // `start` fires before the constructor body reads `config.server`, so mutating the config here adds + // our route-naming middleware to the Hono server without needing a reference to the (internal) app. + if (options.instrumentServerRoutes !== false) { + channel.start.subscribe(message => { + safeChannelCallback(() => { + const { arguments: constructorArgs } = message as ConstructorChannelContext; + injectMastraRouteNamingMiddleware(constructorArgs?.[0]); + }); + }); + } + + channel.end.subscribe(message => { safeChannelCallback(() => { const { self } = message as ConstructorChannelContext; attachExporter(self, options); @@ -80,7 +102,11 @@ function attachExporter(instance: unknown, options: MastraOptions): void { return; } - const { bootstrapObservability: _bootstrapObservability, ...exporterOptions } = options; + const { + bootstrapObservability: _bootstrapObservability, + instrumentServerRoutes: _instrumentServerRoutes, + ...exporterOptions + } = options; const exporter = new SentryMastraExporter(exporterOptions); const defaultInstance = mastra.observability?.getDefaultInstance?.(); diff --git a/packages/server-utils/test/integrations/mastra/route-naming-optout.test.ts b/packages/server-utils/test/integrations/mastra/route-naming-optout.test.ts new file mode 100644 index 000000000000..355df5519460 --- /dev/null +++ b/packages/server-utils/test/integrations/mastra/route-naming-optout.test.ts @@ -0,0 +1,25 @@ +import { tracingChannel } from 'node:diagnostics_channel'; +import type { Client } from '@sentry/core'; +import { GLOBAL_OBJ } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mastraIntegration } from '../../../src/integrations/mastra'; +import { CHANNELS } from '../../../src/orchestrion/channels'; + +// `instrumentMastra` subscribes at most once per process, so this opt-out case — which must NOT +// subscribe a `start` handler at all — needs its own file to avoid a default integration from another +// test leaving a subscriber on the process-global diagnostics channel. +describe('mastraIntegration route-naming opt-out', () => { + beforeEach(() => { + GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = { runtime: ['@mastra/core'] }; + }); + afterEach(() => { + delete GLOBAL_OBJ.__SENTRY_ORCHESTRION__; + }); + + it('does not inject middleware when instrumentServerRoutes is false', () => { + mastraIntegration({ instrumentServerRoutes: false }).setup?.({ on: () => () => undefined } as unknown as Client); + const config: any = { server: {} }; + tracingChannel(CHANNELS.MASTRA_CONSTRUCTOR).start.publish({ arguments: [config] }); + expect(config.server.middleware).toBeUndefined(); + }); +}); diff --git a/packages/server-utils/test/integrations/mastra/route-naming.test.ts b/packages/server-utils/test/integrations/mastra/route-naming.test.ts new file mode 100644 index 000000000000..e0a2a158d542 --- /dev/null +++ b/packages/server-utils/test/integrations/mastra/route-naming.test.ts @@ -0,0 +1,136 @@ +import { tracingChannel } from 'node:diagnostics_channel'; +import type { Client } from '@sentry/core'; +import { GLOBAL_OBJ } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const setHttpServerSpanRouteAttribute = vi.hoisted(() => vi.fn()); +vi.mock('../../../src/utils/setHttpServerSpanRouteAttribute', () => ({ setHttpServerSpanRouteAttribute })); + +import { + createMastraRouteNamingMiddleware, + injectMastraRouteNamingMiddleware, +} from '../../../src/integrations/mastra-route-naming'; +import { mastraIntegration } from '../../../src/integrations/mastra'; +import { CHANNELS } from '../../../src/orchestrion/channels'; + +// A route handler is `(c)` (arity 1); a middleware is `(c, next)` (arity 2). +const handler = (_c: unknown): void => undefined; +const middleware = (_c: unknown, _next: unknown): void => undefined; + +function fakeContext(opts: { + method?: string; + routeIndex: number; + routePath?: string; + matchedRoutes: Array<{ handler: unknown; path: string }>; +}): any { + return { + req: { + method: opts.method ?? 'GET', + routeIndex: opts.routeIndex, + routePath: opts.routePath ?? '', + matchedRoutes: opts.matchedRoutes, + }, + }; +} + +const next = (): Promise => Promise.resolve(); + +describe('injectMastraRouteNamingMiddleware', () => { + it('creates server.middleware as an array when absent', () => { + const config: any = {}; + injectMastraRouteNamingMiddleware(config); + expect(Array.isArray(config.server.middleware)).toBe(true); + expect(config.server.middleware).toHaveLength(1); + expect(config.server.middleware[0].path).toBe('*'); + expect(typeof config.server.middleware[0].handler).toBe('function'); + }); + + it('prepends to an existing middleware array', () => { + const existing = { handler: middleware, path: '/api/*' }; + const config: any = { server: { middleware: [existing] } }; + injectMastraRouteNamingMiddleware(config); + expect(config.server.middleware).toHaveLength(2); + expect(config.server.middleware[0].path).toBe('*'); // ours runs first + expect(config.server.middleware[1]).toBe(existing); + }); + + it('normalizes a single (non-array) middleware into an array', () => { + const single = () => undefined; + const config: any = { server: { middleware: single } }; + injectMastraRouteNamingMiddleware(config); + expect(Array.isArray(config.server.middleware)).toBe(true); + expect(config.server.middleware[1]).toBe(single); + }); + + it('is a no-op for a non-object config', () => { + expect(() => injectMastraRouteNamingMiddleware(undefined)).not.toThrow(); + expect(() => injectMastraRouteNamingMiddleware(null)).not.toThrow(); + }); +}); + +describe('createMastraRouteNamingMiddleware', () => { + beforeEach(() => setHttpServerSpanRouteAttribute.mockClear()); + + it('uses the matched handler at routeIndex', async () => { + const mw = createMastraRouteNamingMiddleware(); + await mw( + fakeContext({ + routeIndex: 1, + matchedRoutes: [ + { handler: middleware, path: '/*' }, + { handler, path: '/echo/:id' }, + ], + }), + next, + ); + expect(setHttpServerSpanRouteAttribute).toHaveBeenCalledWith('/echo/:id'); + }); + + it('falls back to the last handler when routeIndex points at a middleware', async () => { + const mw = createMastraRouteNamingMiddleware(); + await mw( + fakeContext({ + routeIndex: 2, // a trailing catch-all middleware + matchedRoutes: [ + { handler: middleware, path: '/*' }, + { handler, path: '/echo/:id' }, + { handler: middleware, path: '/*' }, + ], + }), + next, + ); + expect(setHttpServerSpanRouteAttribute).toHaveBeenCalledWith('/echo/:id'); + }); + + it('does not set a route when only middleware matched (no handler)', async () => { + const mw = createMastraRouteNamingMiddleware(); + await mw( + fakeContext({ + routeIndex: 0, + routePath: '', + matchedRoutes: [{ handler: middleware, path: '/*' }], + }), + next, + ); + expect(setHttpServerSpanRouteAttribute).not.toHaveBeenCalled(); + }); +}); + +describe('mastraIntegration route-naming injection', () => { + beforeEach(() => { + GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = { runtime: ['@mastra/core'] }; + }); + afterEach(() => { + delete GLOBAL_OBJ.__SENTRY_ORCHESTRION__; + }); + + // `instrumentMastra` subscribes at most once per process, so the opt-out case (which must not + // subscribe at all) lives in its own file to get clean module state — see route-naming-optout.test.ts. + it('injects the middleware into the constructor config by default', () => { + mastraIntegration().setup?.({ on: () => () => undefined } as unknown as Client); + const config: any = { server: {} }; + tracingChannel(CHANNELS.MASTRA_CONSTRUCTOR).start.publish({ arguments: [config] }); + expect(Array.isArray(config.server.middleware)).toBe(true); + expect(config.server.middleware[0].path).toBe('*'); + }); +}); From 9f14001e04831dd04694e372e2e30dc3bfaceec1 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Mon, 14 Sep 2026 11:50:22 +0200 Subject: [PATCH 9/9] Revert "feat(node): Name Mastra server routes from their route pattern" This reverts commit 06e931c6aec5dd56530f1e14e92a7c78ec6d6523. --- CHANGELOG.md | 1 - .../node-mastra/src/mastra/index.ts | 19 +-- .../node-mastra/tests/manual-route.test.ts | 61 +++----- .../src/integrations/mastra-route-naming.ts | 122 ---------------- .../server-utils/src/integrations/mastra.ts | 30 +--- .../mastra/route-naming-optout.test.ts | 25 ---- .../integrations/mastra/route-naming.test.ts | 136 ------------------ 7 files changed, 28 insertions(+), 366 deletions(-) delete mode 100644 packages/server-utils/src/integrations/mastra-route-naming.ts delete mode 100644 packages/server-utils/test/integrations/mastra/route-naming-optout.test.ts delete mode 100644 packages/server-utils/test/integrations/mastra/route-naming.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 74c6b7cf1772..d65296eaca89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,6 @@ Work in this release was contributed by @psh4607, @thijsw, @trinitiwowka, @nehap - `DenoMysql` => `Mysql` - `DenoPostgres` => `Postgres` - feat(node): Add first-party Mastra integration ([#23823](https://github.com/getsentry/sentry-javascript/pull/23823)). Enabled by default; disable with `defaultIntegrations: integrations => integrations.filter(i => i.name !== 'Mastra')`. -- feat(node): Name Mastra server routes from their route pattern. The Mastra integration now names incoming requests' `http.server` span after the matched Hono route (e.g. `POST /api/agents/:agentId/generate`, `GET /echo/:id`) with `http.route` set and name source `route`, instead of the raw URL — for both built-in API routes and custom `registerApiRoute`s. This keeps route transactions low-cardinality. Disable with `mastraIntegration({ instrumentServerRoutes: false })`. - feat(node): Enable the `dataloader` and `knex` integrations by default. Both were previously opt-in — `dataloader` was removed from the defaults in v8 due to an upstream OpenTelemetry bug that has since been fixed, and `knex` was never enabled by default. You no longer need to add `dataloaderIntegration()` or `knexIntegration()` manually. Disable either with `defaultIntegrations: integrations => integrations.filter(i => i.name !== 'Dataloader' /* or 'Knex' */)`. - **feat(browser): Add `bfcacheMetricsIntegration` to track back/forward cache health** diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/index.ts b/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/index.ts index 333e7a43171c..91455e1fc07f 100644 --- a/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/index.ts +++ b/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/index.ts @@ -7,9 +7,9 @@ import { WEATHER_AGENT, weatherAgent } from './agents/weather-agent.js'; // 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 (static) custom route: verifies requests to app-registered routes are wrapped in an -// `http.server` span. With the Mastra integration's route naming, the span is named `GET /manual-route` -// (name source `route`, `http.route` set) — see tests/manual-route.test.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) { @@ -17,22 +17,12 @@ const manualRoute = registerApiRoute('/manual-route', { }, }); -// A parametrized custom route: verifies the integration names the `http.server` span from the matched -// route *pattern* (`GET /echo/:id`), not the raw URL — so `/echo/42` and `/echo/99` collapse to one -// low-cardinality transaction with `http.route: /echo/:id`. -const echoRoute = registerApiRoute('/echo/:id', { - method: 'GET', - handler(c) { - return c.json({ id: c.req.param('id') }); - }, -}); - export const mastra = new Mastra({ agents: { [WEATHER_AGENT]: weatherAgent }, storage: new LibSQLStore({ id: 'mastra-storage', url: ':memory:' }), server: { port: 4111, - apiRoutes: [manualRoute, echoRoute], + apiRoutes: [manualRoute], }, }); @@ -42,4 +32,5 @@ export const mastra = new Mastra({ * 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? */ diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/tests/manual-route.test.ts b/dev-packages/e2e-tests/test-applications/node-mastra/tests/manual-route.test.ts index 940a8e97978f..e3aa759000cc 100644 --- a/dev-packages/e2e-tests/test-applications/node-mastra/tests/manual-route.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-mastra/tests/manual-route.test.ts @@ -5,54 +5,35 @@ const APP = 'node-mastra'; const attrValue = (span: SerializedStreamedSpan, key: string): unknown => span.attributes?.[key]?.value; -// Custom routes registered on the Mastra (Hono) server — see `src/mastra/index.ts`. These verify the -// Mastra integration's zero-config route naming: incoming requests get an `http.server` span named -// from the matched route *pattern* (name source `route`, `http.route` set), for both static and -// parametrized routes, with no app-level middleware. Runs in both the prod (`mastra start`) and dev -// (`mastra dev`) variants. -const serverSpanForUrl = - (urlIncludes: string) => - (span: SerializedStreamedSpan): boolean => - getSpanOp(span) === 'http.server' && String(attrValue(span, 'url.full') ?? '').includes(urlIncludes); - -test('names a static custom route from its route pattern', async ({ baseURL }) => { - const spansPromise = collectStreamedSpans(APP, spansOfTrace => spansOfTrace.some(serverSpanForUrl('/manual-route'))); +// 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 serverSpan = (await spansPromise).find(serverSpanForUrl('/manual-route')); + 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); - - // The Mastra integration upgrades the span from the raw URL to the route pattern: for a static - // route these are identical, but the name source is `route` and `http.route` is set. - expect(serverSpan!.name).toBe('GET /manual-route'); - expect(attrValue(serverSpan!, 'http.route')).toBe('/manual-route'); - expect(attrValue(serverSpan!, 'sentry.segment.name.source')).toBe('route'); -}); - -test('names a parametrized custom route from its route pattern (low cardinality)', async ({ baseURL }) => { - // Two different ids must collapse to the same `/echo/:id` transaction. - for (const id of ['42', '99']) { - const spansPromise = collectStreamedSpans(APP, spansOfTrace => - spansOfTrace.some(span => serverSpanForUrl(`/echo/${id}`)(span)), - ); - - const res = await fetch(`${baseURL}/echo/${id}`, { method: 'GET' }); - expect(res.status).toBe(200); - await res.json(); - - const serverSpan = (await spansPromise).find(serverSpanForUrl(`/echo/${id}`)); - - expect(serverSpan).toBeDefined(); - // Name and route are the pattern, not the concrete URL (`/echo/42`). - expect(serverSpan!.name).toBe('GET /echo/:id'); - expect(attrValue(serverSpan!, 'http.route')).toBe('/echo/:id'); - expect(attrValue(serverSpan!, 'sentry.segment.name.source')).toBe('route'); - } + 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(); }); diff --git a/packages/server-utils/src/integrations/mastra-route-naming.ts b/packages/server-utils/src/integrations/mastra-route-naming.ts deleted file mode 100644 index b546aac2352e..000000000000 --- a/packages/server-utils/src/integrations/mastra-route-naming.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { setHttpServerSpanRouteAttribute } from '../utils/setHttpServerSpanRouteAttribute'; - -// Mastra serves its API (and any custom `registerApiRoute`) on a Hono server. Sentry's HTTP server -// instrumentation names the incoming `http.server` span from the raw URL (`sentry.segment.name.source` -// = `url`), because — unlike Express/Fastify — nothing feeds it the matched route pattern. Left alone, -// `/echo/42` and `/echo/99` become distinct high-cardinality transactions. -// -// Mastra owns its Hono app internally and never exposes the instance, so `@sentry/hono` (which wraps an -// app you construct) cannot be applied to it. What Mastra *does* expose is a middleware seam -// (`server.middleware`). We inject the middleware below into the Mastra config at construction time -// (see `injectMastraRouteNamingMiddleware`), read the matched route from the Hono `Context` after the -// handler has run, and upgrade the root `http.server` span to `${method} ${route}` with -// `http.route` set and name source `route` — mirroring what the framework instrumentations do. - -// Hono wraps a sub-app's handlers under this key for a custom `onError`; unwrap it so arity is read off -// the real handler, not the `(c, next)` wrapper. See `@sentry/hono`'s `isMiddleware`. -const COMPOSED_HANDLER = '__COMPOSED_HANDLER'; - -// The subset of Hono's `Context` we rely on. Typed structurally so `@sentry/server-utils` does not need -// a `hono` dependency (it runs inside Mastra's Hono request, so the real shape is present at runtime). -interface HonoRouteContextLike { - req: { - method: string; - routeIndex: number; - routePath: string; - matchedRoutes: Array<{ handler: unknown; path: string }>; - }; -} - -type NextFn = () => Promise; -type MiddlewareHandlerLike = (context: HonoRouteContextLike, next: NextFn) => Promise; - -// Hono has no "is middleware" flag, so infer from arity: middleware is `(c, next)` (>= 2 args), route -// handlers are `(c)` (< 2). Mirrors `@sentry/hono`'s `isMiddleware`. -function isMiddleware(handler: unknown): boolean { - if (typeof handler !== 'function') { - return false; - } - const composed = (handler as unknown as Record)[COMPOSED_HANDLER]; - const original = typeof composed === 'function' ? composed : handler; - return (original as (...args: unknown[]) => unknown).length >= 2; -} - -/** - * Resolve the matched route pattern (e.g. `/echo/:id`) for the current request. Picks the matched - * *handler* rather than trusting `routePath` alone, so a catch-all middleware registered after the - * handlers, or a middleware that short-circuits before them, does not mask the real route. Mirrors - * `@sentry/hono`'s `resolveRouteName` using only the public `Context` API. - */ -function resolveRouteName(context: HonoRouteContextLike): string | undefined { - const routes = context.req.matchedRoutes || []; - - const current = routes[context.req.routeIndex]; - if (current && isRouteHandler(current.handler)) { - return current.path; - } - - for (let i = routes.length - 1; i >= 0; i--) { - const route = routes[i]; - if (route && isRouteHandler(route.handler)) { - return route.path; - } - } - - return context.req.routePath || undefined; -} - -function isRouteHandler(handler: unknown): boolean { - return typeof handler === 'function' && !isMiddleware(handler); -} - -/** - * A Hono middleware that, after the handler runs, upgrades the root `http.server` span to the matched - * route pattern. Safe to run on every request: {@link setHttpServerSpanRouteAttribute} no-ops when - * there is no active/root `http.server` span, and a throw here never breaks the request. - */ -export function createMastraRouteNamingMiddleware(): MiddlewareHandlerLike { - return async function sentryMastraRouteNaming(context, next) { - await next(); - try { - const route = resolveRouteName(context); - if (route) { - setHttpServerSpanRouteAttribute(route); - } - } catch { - // Never let route naming break the request. - } - }; -} - -interface MastraServerConfigLike { - middleware?: unknown; -} - -interface MastraConstructorConfigLike { - server?: MastraServerConfigLike; -} - -/** - * Prepend the route-naming middleware to a Mastra constructor config's `server.middleware`, mutating - * the config in place. Called from the `Mastra` constructor's orchestrion `start` channel, before - * Mastra reads `config.server` — so no reference to the (internal) Hono app is needed. `path: '*'` - * covers built-in API routes and custom `registerApiRoute`s alike. - */ -export function injectMastraRouteNamingMiddleware(config: unknown): void { - if (typeof config !== 'object' || config === null) { - return; - } - - const typedConfig = config as MastraConstructorConfigLike; - const server = (typedConfig.server ??= {}); - const entry = { handler: createMastraRouteNamingMiddleware(), path: '*' }; - - if (Array.isArray(server.middleware)) { - server.middleware.unshift(entry); - } else if (server.middleware) { - // A single middleware (function or `{ handler, path }`) — normalize to an array. - server.middleware = [entry, server.middleware]; - } else { - server.middleware = [entry]; - } -} diff --git a/packages/server-utils/src/integrations/mastra.ts b/packages/server-utils/src/integrations/mastra.ts index a5ccfc5b6214..0d50981f421f 100644 --- a/packages/server-utils/src/integrations/mastra.ts +++ b/packages/server-utils/src/integrations/mastra.ts @@ -17,7 +17,6 @@ import { CHANNELS } from '../orchestrion/channels'; import { mastraModuleNames } from '../orchestrion/config/mastra'; import { invokeOrchestrionInstrumentation } from '../orchestrion/instrumentation'; import { safeChannelCallback } from '../tracing-channel'; -import { injectMastraRouteNamingMiddleware } from './mastra-route-naming'; export interface MastraOptions extends MastraExporterOptions { /** @@ -25,14 +24,6 @@ export interface MastraOptions extends MastraExporterOptions { * `true`. Uses an `@mastra/observability` the app already has; the SDK never installs it. */ bootstrapObservability?: boolean; - - /** - * Give the incoming `http.server` span the matched route pattern (e.g. - * `POST /api/agents/:agentId/generate`) with `http.route` and name source `route`, instead of the - * raw URL. Works for Mastra's built-in API routes and custom `registerApiRoute`s. Defaults to - * `true`. Set `false` to leave route naming to the app. - */ - instrumentServerRoutes?: boolean; } interface MastraObservabilityInstance { @@ -69,20 +60,7 @@ const _mastraIntegration = ((options: MastraOptions = {}) => { }) satisfies IntegrationFn; function instrumentMastra(options: MastraOptions): void { - const channel = diagnosticsChannel.tracingChannel(CHANNELS.MASTRA_CONSTRUCTOR); - - // `start` fires before the constructor body reads `config.server`, so mutating the config here adds - // our route-naming middleware to the Hono server without needing a reference to the (internal) app. - if (options.instrumentServerRoutes !== false) { - channel.start.subscribe(message => { - safeChannelCallback(() => { - const { arguments: constructorArgs } = message as ConstructorChannelContext; - injectMastraRouteNamingMiddleware(constructorArgs?.[0]); - }); - }); - } - - channel.end.subscribe(message => { + diagnosticsChannel.tracingChannel(CHANNELS.MASTRA_CONSTRUCTOR).end.subscribe(message => { safeChannelCallback(() => { const { self } = message as ConstructorChannelContext; attachExporter(self, options); @@ -102,11 +80,7 @@ function attachExporter(instance: unknown, options: MastraOptions): void { return; } - const { - bootstrapObservability: _bootstrapObservability, - instrumentServerRoutes: _instrumentServerRoutes, - ...exporterOptions - } = options; + const { bootstrapObservability: _bootstrapObservability, ...exporterOptions } = options; const exporter = new SentryMastraExporter(exporterOptions); const defaultInstance = mastra.observability?.getDefaultInstance?.(); diff --git a/packages/server-utils/test/integrations/mastra/route-naming-optout.test.ts b/packages/server-utils/test/integrations/mastra/route-naming-optout.test.ts deleted file mode 100644 index 355df5519460..000000000000 --- a/packages/server-utils/test/integrations/mastra/route-naming-optout.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { tracingChannel } from 'node:diagnostics_channel'; -import type { Client } from '@sentry/core'; -import { GLOBAL_OBJ } from '@sentry/core'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { mastraIntegration } from '../../../src/integrations/mastra'; -import { CHANNELS } from '../../../src/orchestrion/channels'; - -// `instrumentMastra` subscribes at most once per process, so this opt-out case — which must NOT -// subscribe a `start` handler at all — needs its own file to avoid a default integration from another -// test leaving a subscriber on the process-global diagnostics channel. -describe('mastraIntegration route-naming opt-out', () => { - beforeEach(() => { - GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = { runtime: ['@mastra/core'] }; - }); - afterEach(() => { - delete GLOBAL_OBJ.__SENTRY_ORCHESTRION__; - }); - - it('does not inject middleware when instrumentServerRoutes is false', () => { - mastraIntegration({ instrumentServerRoutes: false }).setup?.({ on: () => () => undefined } as unknown as Client); - const config: any = { server: {} }; - tracingChannel(CHANNELS.MASTRA_CONSTRUCTOR).start.publish({ arguments: [config] }); - expect(config.server.middleware).toBeUndefined(); - }); -}); diff --git a/packages/server-utils/test/integrations/mastra/route-naming.test.ts b/packages/server-utils/test/integrations/mastra/route-naming.test.ts deleted file mode 100644 index e0a2a158d542..000000000000 --- a/packages/server-utils/test/integrations/mastra/route-naming.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { tracingChannel } from 'node:diagnostics_channel'; -import type { Client } from '@sentry/core'; -import { GLOBAL_OBJ } from '@sentry/core'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -const setHttpServerSpanRouteAttribute = vi.hoisted(() => vi.fn()); -vi.mock('../../../src/utils/setHttpServerSpanRouteAttribute', () => ({ setHttpServerSpanRouteAttribute })); - -import { - createMastraRouteNamingMiddleware, - injectMastraRouteNamingMiddleware, -} from '../../../src/integrations/mastra-route-naming'; -import { mastraIntegration } from '../../../src/integrations/mastra'; -import { CHANNELS } from '../../../src/orchestrion/channels'; - -// A route handler is `(c)` (arity 1); a middleware is `(c, next)` (arity 2). -const handler = (_c: unknown): void => undefined; -const middleware = (_c: unknown, _next: unknown): void => undefined; - -function fakeContext(opts: { - method?: string; - routeIndex: number; - routePath?: string; - matchedRoutes: Array<{ handler: unknown; path: string }>; -}): any { - return { - req: { - method: opts.method ?? 'GET', - routeIndex: opts.routeIndex, - routePath: opts.routePath ?? '', - matchedRoutes: opts.matchedRoutes, - }, - }; -} - -const next = (): Promise => Promise.resolve(); - -describe('injectMastraRouteNamingMiddleware', () => { - it('creates server.middleware as an array when absent', () => { - const config: any = {}; - injectMastraRouteNamingMiddleware(config); - expect(Array.isArray(config.server.middleware)).toBe(true); - expect(config.server.middleware).toHaveLength(1); - expect(config.server.middleware[0].path).toBe('*'); - expect(typeof config.server.middleware[0].handler).toBe('function'); - }); - - it('prepends to an existing middleware array', () => { - const existing = { handler: middleware, path: '/api/*' }; - const config: any = { server: { middleware: [existing] } }; - injectMastraRouteNamingMiddleware(config); - expect(config.server.middleware).toHaveLength(2); - expect(config.server.middleware[0].path).toBe('*'); // ours runs first - expect(config.server.middleware[1]).toBe(existing); - }); - - it('normalizes a single (non-array) middleware into an array', () => { - const single = () => undefined; - const config: any = { server: { middleware: single } }; - injectMastraRouteNamingMiddleware(config); - expect(Array.isArray(config.server.middleware)).toBe(true); - expect(config.server.middleware[1]).toBe(single); - }); - - it('is a no-op for a non-object config', () => { - expect(() => injectMastraRouteNamingMiddleware(undefined)).not.toThrow(); - expect(() => injectMastraRouteNamingMiddleware(null)).not.toThrow(); - }); -}); - -describe('createMastraRouteNamingMiddleware', () => { - beforeEach(() => setHttpServerSpanRouteAttribute.mockClear()); - - it('uses the matched handler at routeIndex', async () => { - const mw = createMastraRouteNamingMiddleware(); - await mw( - fakeContext({ - routeIndex: 1, - matchedRoutes: [ - { handler: middleware, path: '/*' }, - { handler, path: '/echo/:id' }, - ], - }), - next, - ); - expect(setHttpServerSpanRouteAttribute).toHaveBeenCalledWith('/echo/:id'); - }); - - it('falls back to the last handler when routeIndex points at a middleware', async () => { - const mw = createMastraRouteNamingMiddleware(); - await mw( - fakeContext({ - routeIndex: 2, // a trailing catch-all middleware - matchedRoutes: [ - { handler: middleware, path: '/*' }, - { handler, path: '/echo/:id' }, - { handler: middleware, path: '/*' }, - ], - }), - next, - ); - expect(setHttpServerSpanRouteAttribute).toHaveBeenCalledWith('/echo/:id'); - }); - - it('does not set a route when only middleware matched (no handler)', async () => { - const mw = createMastraRouteNamingMiddleware(); - await mw( - fakeContext({ - routeIndex: 0, - routePath: '', - matchedRoutes: [{ handler: middleware, path: '/*' }], - }), - next, - ); - expect(setHttpServerSpanRouteAttribute).not.toHaveBeenCalled(); - }); -}); - -describe('mastraIntegration route-naming injection', () => { - beforeEach(() => { - GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = { runtime: ['@mastra/core'] }; - }); - afterEach(() => { - delete GLOBAL_OBJ.__SENTRY_ORCHESTRION__; - }); - - // `instrumentMastra` subscribes at most once per process, so the opt-out case (which must not - // subscribe at all) lives in its own file to get clean module state — see route-naming-optout.test.ts. - it('injects the middleware into the constructor config by default', () => { - mastraIntegration().setup?.({ on: () => () => undefined } as unknown as Client); - const config: any = { server: {} }; - tracingChannel(CHANNELS.MASTRA_CONSTRUCTOR).start.publish({ arguments: [config] }); - expect(Array.isArray(config.server.middleware)).toBe(true); - expect(config.server.middleware[0].path).toBe('*'); - }); -});