Skip to content

Commit 2fdcb23

Browse files
authored
test(e2e): Add Mastra Jev classifier test to node-mastra (#25115)
## What Adds a node-mastra e2e test that runs a real Jev evaluation through a Mastra `Classifier` (via OpenRouter) and checks the `gen_ai.evaluate` span. ## Why Covers the classifier instrumentation from #25044 against the real provider, not only a mock model.
1 parent 5bb9a68 commit 2fdcb23

6 files changed

Lines changed: 110 additions & 13 deletions

File tree

‎dev-packages/e2e-tests/test-applications/node-mastra/package.json‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,11 @@
1515
"test:dev": "TEST_ENV=development playwright test"
1616
},
1717
"dependencies": {
18-
"@mastra/core": "~1.65.0",
18+
"@mastra/core": "~1.74.0",
1919
"@mastra/libsql": "~1.22.4",
2020
"@mastra/memory": "~1.28.3",
2121
"@mastra/observability": "~1.17.6",
22-
"@openrouter/ai-sdk-provider": "~3.0.0",
22+
"@openrouter/ai-sdk-provider": "~3.1.0",
2323
"@sentry/node": "file:../../packed/sentry-node-packed.tgz",
2424
"ai": "~7.0.97",
2525
"dataloader": "~2.2.3",
@@ -28,6 +28,7 @@
2828
"devDependencies": {
2929
"@playwright/test": "~1.56.0",
3030
"@sentry-internal/test-utils": "link:../../../test-utils",
31+
"@sentry/conventions": "0.26.0",
3132
"@types/node": "24.x",
3233
"mastra": "~1.28.0",
3334
"typescript": "~5.9.0"

‎dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/agents/weather-agent.ts‎

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,10 @@
1-
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
21
import { Agent } from '@mastra/core/agent';
32
import { Memory } from '@mastra/memory';
43
import { LibSQLStore } from '@mastra/libsql';
54
import { countItemsTool } from '../tools/count-items.js';
65
import { failNowTool } from '../tools/fail-now.js';
76
import { getWeatherTool } from '../tools/get-weather.js';
8-
9-
const apiKey = process.env.E2E_OPENROUTER_API_KEY;
10-
if (!apiKey) {
11-
throw new Error('E2E_OPENROUTER_API_KEY is not set');
12-
}
13-
14-
// Call OpenRouter directly (rather than the default Vercel AI Gateway) so the
15-
// e2e test needs only a single OpenRouter key, reusing `E2E_OPENROUTER_API_KEY`.
16-
const openrouter = createOpenRouter({ apiKey });
7+
import { openrouter } from '../openrouter.js';
178

189
// An in-memory libsql store is enough for the test: Mastra requires a storage
1910
// provider before `generate(..., { memory: { thread, resource } })` is accepted,
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { Classifier } from '@mastra/core/classifier';
2+
import { openrouter } from '../openrouter.js';
3+
4+
export const TICKET_CLASSIFIER = 'ticketClassifier';
5+
6+
// A Jev evaluation through OpenRouter's Decisions API. `Classifier` calls the model's `doEvaluate()`
7+
// directly, so its span comes from the Mastra exporter, not from the Vercel AI integration.
8+
export const ticketClassifier = new Classifier({
9+
id: TICKET_CLASSIFIER,
10+
model: openrouter.evaluationModel('typesafe/jev-1.13'),
11+
questions: {
12+
is_bug: {
13+
type: 'boolean',
14+
instructions: 'Is the customer reporting a software defect?',
15+
},
16+
},
17+
});

‎dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/index.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Mastra } from '@mastra/core';
22
import { registerApiRoute } from '@mastra/core/server';
33
import { LibSQLStore } from '@mastra/libsql';
44
import { WEATHER_AGENT, weatherAgent } from './agents/weather-agent.js';
5+
import { TICKET_CLASSIFIER, ticketClassifier } from './classifiers/ticket-classifier.js';
56

67
// The agent is driven through Mastra's built-in `POST /api/agents/:id/generate`
78
// endpoint (see tests/utils.ts). `dataloader` (orchestrion-instrumented) is exercised
@@ -19,12 +20,24 @@ const manualRoute = registerApiRoute('/manual-route', {
1920
},
2021
});
2122

23+
// Evaluates the registered classifier outside of any agent run. Registration lets Mastra start a root
24+
// `classifier_evaluation` span when no Mastra span is active.
25+
const classifyRoute = registerApiRoute('/classify', {
26+
method: 'POST',
27+
async handler(c) {
28+
const { ticket } = await c.req.json<{ ticket: string }>();
29+
const result = await ticketClassifier.evaluate({ state: { ticket } });
30+
return c.json({ answers: result.answers });
31+
},
32+
});
33+
2234
export const mastra = new Mastra({
2335
agents: { [WEATHER_AGENT]: weatherAgent },
36+
classifiers: { [TICKET_CLASSIFIER]: ticketClassifier },
2437
storage: new LibSQLStore({ id: 'mastra-storage', url: ':memory:' }),
2538
server: {
2639
port: 4111,
27-
apiRoutes: [manualRoute],
40+
apiRoutes: [manualRoute, classifyRoute],
2841
},
2942
// No `bundler.externals` override: `mastra build` defaults to externalizing all non-workspace deps,
3043
// so Hono (and the other instrumented modules) stay unbundled and the `--import` orchestrion hook
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
2+
3+
const apiKey = process.env.E2E_OPENROUTER_API_KEY;
4+
if (!apiKey) {
5+
throw new Error('E2E_OPENROUTER_API_KEY is not set');
6+
}
7+
8+
// Call OpenRouter directly (rather than the default Vercel AI Gateway) so the
9+
// e2e test needs only a single OpenRouter key, reusing `E2E_OPENROUTER_API_KEY`.
10+
export const openrouter = createOpenRouter({ apiKey });
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { expect, test } from '@playwright/test';
2+
import {
3+
GEN_AI_INPUT_MESSAGES,
4+
GEN_AI_OPERATION_NAME,
5+
GEN_AI_OUTPUT_MESSAGES,
6+
GEN_AI_PROVIDER_NAME,
7+
GEN_AI_REQUEST_MODEL,
8+
GEN_AI_USAGE_INPUT_TOKENS,
9+
SENTRY_ORIGIN,
10+
URL_FULL,
11+
} from '@sentry/conventions/attributes';
12+
import { GEN_AI_EVALUATE, HTTP_SERVER } from '@sentry/conventions/op';
13+
import { collectStreamedSpans, getSpanOp, SerializedStreamedSpan } from '@sentry-internal/test-utils';
14+
15+
const APP = 'node-mastra';
16+
const TICKET = 'My checkout page shows a blank screen after I click Pay.';
17+
18+
const isEvaluateSpan = (span: SerializedStreamedSpan): boolean => getSpanOp(span) === GEN_AI_EVALUATE;
19+
const isClassifyServerSpan = (span: SerializedStreamedSpan): boolean =>
20+
getSpanOp(span) === HTTP_SERVER && String(span.attributes?.[URL_FULL]?.value ?? '').includes('/classify');
21+
22+
test('captures a Mastra classifier (Jev) evaluation as a gen_ai.evaluate span', async ({ baseURL }) => {
23+
const spansPromise = collectStreamedSpans(
24+
APP,
25+
spansOfTrace => spansOfTrace.some(isEvaluateSpan) && spansOfTrace.some(isClassifyServerSpan),
26+
);
27+
28+
const res = await fetch(`${baseURL}/classify`, {
29+
method: 'POST',
30+
headers: { 'Content-Type': 'application/json' },
31+
body: JSON.stringify({ ticket: TICKET }),
32+
});
33+
expect(res.status).toBe(200);
34+
await res.json();
35+
36+
const spans = await spansPromise;
37+
const serverSpan = spans.find(isClassifyServerSpan)!;
38+
39+
// Only the Mastra exporter reports the evaluation: `Classifier` calls `doEvaluate()` directly, so the
40+
// Vercel AI integration does not see it.
41+
const evaluateSpans = spans.filter(isEvaluateSpan);
42+
expect(evaluateSpans.map(span => ({ name: span.name, origin: span.attributes?.[SENTRY_ORIGIN]?.value }))).toEqual([
43+
{ name: 'evaluate typesafe/jev-1.13', origin: 'auto.ai.mastra' },
44+
]);
45+
46+
const evaluateSpan = evaluateSpans[0]!;
47+
expect(evaluateSpan.parent_span_id).toBe(serverSpan.span_id);
48+
expect(evaluateSpan.attributes?.[GEN_AI_OPERATION_NAME]?.value).toBe('evaluate');
49+
expect(evaluateSpan.attributes?.[GEN_AI_REQUEST_MODEL]?.value).toBe('typesafe/jev-1.13');
50+
expect(evaluateSpan.attributes?.[GEN_AI_PROVIDER_NAME]?.value).toBe('openrouter');
51+
expect(evaluateSpan.attributes?.[GEN_AI_USAGE_INPUT_TOKENS]?.value).toBeGreaterThan(0);
52+
expect(JSON.parse(String(evaluateSpan.attributes?.[GEN_AI_INPUT_MESSAGES]?.value))).toEqual([
53+
{
54+
type: 'evaluation',
55+
state: { ticket: TICKET },
56+
questions: {
57+
is_bug: { type: 'boolean', instructions: 'Is the customer reporting a software defect?' },
58+
},
59+
},
60+
]);
61+
// The probability comes from the live model, so only its shape is stable.
62+
expect(JSON.parse(String(evaluateSpan.attributes?.[GEN_AI_OUTPUT_MESSAGES]?.value))).toEqual([
63+
{ type: 'evaluation', answers: { is_bug: { type: 'boolean', probability: expect.any(Number) } } },
64+
]);
65+
});

0 commit comments

Comments
 (0)