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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
dist
40 changes: 40 additions & 0 deletions dev-packages/e2e-tests/test-applications/node-mistral/build.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Produces the prod-mode artifact: a single bundle whose `@mistralai/mistralai`, `dataloader` and
// `express` copies were transformed at build time by `sentryEsbuildPlugin`. Nothing is left for a
// runtime hook to do, which is what `enableRuntimeChannelInjection: false` in `instrument.mjs`
// asserts.
//
// `@sentry/node` stays external: the SDK is the subscriber, not a transform target, and inlining it
// would force its CommonJS `require('node:async_hooks')` through esbuild's ESM interop for no gain.
// CJS output for the same reason the `node-esbuild` app uses it. Left unminified so the injected
// snippet keeps its identifiers.
import { rmSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { sentryEsbuildPlugin } from '@sentry/node/esbuild';
import { build } from 'esbuild';

const __dirname = dirname(fileURLToPath(import.meta.url));

rmSync(join(__dirname, 'dist'), { recursive: true, force: true });

await build({
entryPoints: [join(__dirname, 'src', 'app.mjs')],
outfile: join(__dirname, 'dist', 'app.cjs'),
bundle: true,
platform: 'node',
format: 'cjs',
target: 'node18',
external: ['@sentry/node'],
minify: false,
logLevel: 'info',
plugins: [
sentryEsbuildPlugin({
telemetry: false,
sourcemaps: { disable: true },
release: { create: false, finalize: false, inject: false },
}),
],
});

// eslint-disable-next-line no-console
console.log('built dist/app.cjs with sentryEsbuildPlugin');
36 changes: 36 additions & 0 deletions dev-packages/e2e-tests/test-applications/node-mistral/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"name": "node-mistral",
"description": "Mistral AI gen_ai spans, errors, span nesting and co-instrumented dataloader spans, exercised through both the runtime loader (dev) and a bundler-instrumented build (prod)",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node --import ./src/instrument.mjs src/app.mjs",
"start:bundled": "node dist/app.cjs",
"build": "node build.mjs",
"clean": "npx rimraf node_modules dist pnpm-lock.yaml",
"test:build": "pnpm install && pnpm build",
"test:assert": "pnpm test:prod && pnpm test:dev",
"test:prod": "TEST_ENV=production playwright test",
"test:dev": "TEST_ENV=development playwright test"
},
"dependencies": {
"@mistralai/mistralai": "^2.6.4",
"@sentry/node": "file:../../packed/sentry-node-packed.tgz",
"dataloader": "^2.2.2",
"express": "^4.21.2"
},
"devDependencies": {
"@playwright/test": "~1.56.0",
"@sentry-internal/test-utils": "link:../../../test-utils",
"@sentry/bundler-plugins": "file:../../packed/sentry-bundler-plugins-packed.tgz",
"@sentry/core": "file:../../packed/sentry-core-packed.tgz",
"esbuild": "0.28.2"
},
"sentryTest": {
"optional": true
},
"volta": {
"extends": "../../package.json"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { getPlaywrightConfig } from '@sentry-internal/test-utils';

// The suite runs twice, once per instrumentation path, the way `node-mastra` splits dev and prod:
//
// production - `dist/app.cjs`, whose Mistral, dataloader and express copies were transformed at
// build time by `sentryEsbuildPlugin`. `instrument.mjs` turns runtime injection off
// there, so the bundler plugin is the only thing that can have instrumented them.
// development - unbundled ESM behind the runtime `--import` hook.
const isDev = process.env.TEST_ENV === 'development';

const config = getPlaywrightConfig({
startCommand: isDev ? 'pnpm start' : 'pnpm start:bundled',
});

export default config;
180 changes: 180 additions & 0 deletions dev-packages/e2e-tests/test-applications/node-mistral/src/app.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
// `instrument.mjs` is imported for its side effect in the prod bundle; in dev `--import` has already
// run it, and a second import is a no-op because ES modules are evaluated once.
import './instrument.mjs';

import { Mistral } from '@mistralai/mistralai';
import * as Sentry from '@sentry/node';
import DataLoader from 'dataloader';
import express from 'express';

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

// The Mistral SDK talks to OpenRouter rather than api.mistral.ai, so the suite needs only the one
// OpenRouter key the other AI e2e apps already use. OpenRouter serves an OpenAI-compatible
// `/v1/chat/completions`, which is the endpoint `chat.complete` and `chat.stream` post to, and the
// SDK's response schemas are lenient enough to accept it (`usage` has a `catchall`, `finish_reason`
// is an open enum). What is under test is the SDK's own code path, which is what Sentry instruments.
const client = new Mistral({ apiKey, serverURL: 'https://openrouter.ai/api' });

// Same model the eve and mastra apps drive through this key. The model is incidental here; the
// Mistral SDK request/response path is the thing being instrumented.
const MODEL = 'openai/gpt-4o-mini';

// Kept short so a live model stays cheap and quick, and so streamed responses still arrive in more
// than one chunk.
const SHORT_ANSWER = 'Answer in at most five words.';

const userLoader = new DataLoader(async keys => keys.map(key => ({ id: key, name: `user-${key}` })));

async function main() {
const port = Number(process.env.PORT ?? 3030);
const app = express();

app.get('/chat', async (req, res) => {
// A manual span wrapping the SDK call: the gen_ai span has to nest inside this one, and this one
// has to nest inside the auto-instrumented request span.
const answer = await Sentry.startSpan({ name: 'ai-workflow', op: 'function' }, async () => {
const completion = await client.chat.complete({
model: MODEL,
messages: [
{ role: 'system', content: 'You are a helpful assistant used by an automated test.' },
{ role: 'user', content: `What is the capital of France? ${SHORT_ANSWER}` },
],
temperature: 0,
maxTokens: 32,
});

// A manual sibling of the gen_ai span, so the assertions can tell "child of the manual span"
// apart from "child of whatever ran last".
return Sentry.startSpan(
{ name: 'post-process', op: 'function' },
() => completion.choices?.[0]?.message?.content ?? '',
);
});

res.send({ answer });
});

app.get('/chat-stream', async (req, res) => {
const chunks = [];

await Sentry.startSpan({ name: 'ai-stream-workflow', op: 'function' }, async () => {
const stream = await client.chat.stream({
model: MODEL,
messages: [{ role: 'user', content: `Name three colours. ${SHORT_ANSWER}` }],
temperature: 0,
maxTokens: 32,
});

for await (const event of stream) {
const content = event.data?.choices?.[0]?.delta?.content;
if (typeof content === 'string') {
chunks.push(content);
}
}
});

res.send({ answer: chunks.join('') });
});

// `tee()` acquires its reader through internal slots rather than the public `getReader`, so it is
// the drain path most likely to escape instrumentation. Both branches are drained so the response
// only comes back once the stream is finished.
app.get('/chat-stream-tee', async (req, res) => {
const branches = await Sentry.startSpan({ name: 'ai-tee-workflow', op: 'function' }, async () => {
const stream = await client.chat.stream({
model: MODEL,
messages: [{ role: 'user', content: `Name three colours. ${SHORT_ANSWER}` }],
temperature: 0,
maxTokens: 32,
});

const [left, right] = stream.tee();

const drain = async branch => {
const parts = [];
for await (const event of branch) {
const content = event.data?.choices?.[0]?.delta?.content;
if (typeof content === 'string') {
parts.push(content);
}
}
return parts.join('');
};

return Promise.all([drain(left), drain(right)]);
});

res.send({ left: branches[0], right: branches[1] });
});

// Relays the stream through a transform, the shape an edge handler would use to forward tokens.
app.get('/chat-stream-pipe', async (req, res) => {
const answer = await Sentry.startSpan({ name: 'ai-pipe-workflow', op: 'function' }, async () => {
const stream = await client.chat.stream({
model: MODEL,
messages: [{ role: 'user', content: `Name three colours. ${SHORT_ANSWER}` }],
temperature: 0,
maxTokens: 32,
});

const relayed = stream.pipeThrough(
new TransformStream({
transform(event, controller) {
controller.enqueue(event.data?.choices?.[0]?.delta?.content ?? '');
},
}),
);

const parts = [];
for await (const part of relayed) {
parts.push(part);
}
return parts.join('');
});

res.send({ answer });
});

// A model id the upstream will reject, so the failure is a real API error rather than a simulated
// one. The caller-supplied id makes each request identifiable in the spans it produces.
app.get('/chat-error', async (req, res, next) => {
const model = `no-such-model/${req.query.id ?? 'default'}`;

try {
await client.chat.complete({ model, messages: [{ role: 'user', content: 'This will fail' }] });
res.send({ ok: true });
} catch (error) {
// Rethrown through the express error handler so the SDK captures it the way a real app would.
next(new Error(`Mistral call failed for ${model}: ${error.message}`));
}
});

// A dataloader (orchestrion-instrumented, like Mistral) and a Mistral call in one request, so the
// assertions can prove both sets of spans land in the same trace.
app.get('/dataloader-and-chat', async (req, res) => {
const user = await userLoader.load(`${req.query.id ?? '1'}`);

const completion = await client.chat.complete({
model: MODEL,
messages: [{ role: 'user', content: `Say hello to ${user.name}. ${SHORT_ANSWER}` }],
temperature: 0,
maxTokens: 32,
});

res.send({ user, answer: completion.choices?.[0]?.message?.content ?? '' });
});

Sentry.setupExpressErrorHandler(app);

app.use((error, req, res, _next) => {
res.status(500).send({ message: error.message });
});

app.listen(port);
}

void main();
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Shared Sentry bootstrap for both modes.
//
// dev - loaded through `node --import`, so the runtime channel-injection hook transforms
// `@mistralai/mistralai`, `dataloader` and `express` as they load.
// prod - bundled into `dist/app.cjs` by `build.mjs`, where `sentryEsbuildPlugin` applies the same
// transforms at build time. Runtime injection is switched off there so the bundler plugin is
// the only possible injector and a passing prod test really proves the build-time path.
import * as Sentry from '@sentry/node';

// `production` is the bundled build, where `sentryEsbuildPlugin` already injected the channels.
const isDev = process.env.TEST_ENV === 'development';

Sentry.init({
environment: 'qa',
dsn: process.env.E2E_TEST_DSN,
debug: !!process.env.DEBUG,
tunnel: 'http://localhost:3031/',
tracesSampleRate: 1,
traceLifecycle: 'stream',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

l:

Suggested change
traceLifecycle: 'stream',

enableRuntimeChannelInjection: isDev,
integrations: [Sentry.spanStreamingIntegration()],

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

l: I think this can be removed too? This should be used only in browsers AFAIK

Suggested change
integrations: [Sentry.spanStreamingIntegration()],

});

Sentry.setTag('e2e.mode', isDev ? 'development' : 'production');
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { startEventProxyServer } from '@sentry-internal/test-utils';

startEventProxyServer({
port: 3031,
proxyServerName: 'node-mistral',
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { expect, test } from '@playwright/test';
import { collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils';
import { APP, attr, expectCommonChatAttributes, isChatSpan } from './utils';

test('emits a gen_ai.chat span for a non-streaming call', async ({ baseURL, request }) => {
const spansPromise = collectStreamedSpansUntilSegment(APP, 'GET /chat');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Shared routes use non-unique waiters

Medium Severity

Several tests wait only on GET /chat or GET /chat-stream, with no per-request id. collectStreamedSpans can treat a late envelope from an earlier hit of the same route as the trace under test, especially on the development retries. The /chat-error tests already uniquify with the model id; these routes do not.

Additional Locations (2)
Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit 7919617. Configure here.


const response = await request.get(`${baseURL}/chat`);
expect(response.status()).toBe(200);
expect((await response.json()).answer).toBeTruthy();

const spans = await spansPromise;
const chatSpan = spans.find(isChatSpan);

expect(chatSpan).toBeDefined();
expectCommonChatAttributes(chatSpan!);
expect(attr(chatSpan!, 'gen_ai.request.stream')).toBe(false);
expect(attr(chatSpan!, 'gen_ai.request.temperature')).toBe(0);
expect(attr(chatSpan!, 'gen_ai.request.max_tokens')).toBe(32);
});

test('emits a gen_ai.chat span for a streaming call', async ({ baseURL, request }) => {
const spansPromise = collectStreamedSpansUntilSegment(APP, 'GET /chat-stream');

const response = await request.get(`${baseURL}/chat-stream`);
expect(response.status()).toBe(200);
expect((await response.json()).answer).toBeTruthy();

const spans = await spansPromise;
const streamSpan = spans.find(isChatSpan);

expect(streamSpan).toBeDefined();
expectCommonChatAttributes(streamSpan!);
// Set from the called method: v2's `stream` request field is optional and the app never passes it.
expect(attr(streamSpan!, 'gen_ai.request.stream')).toBe(true);
expect(attr(streamSpan!, 'gen_ai.response.streaming')).toBe(true);
});

test('records inputs and outputs in the shape the gen_ai conventions specify', async ({ baseURL, request }) => {
const spansPromise = collectStreamedSpansUntilSegment(APP, 'GET /chat');

await request.get(`${baseURL}/chat`);

const spans = await spansPromise;
const chatSpan = spans.find(isChatSpan)!;

// The system message is split out from the rest of the prompt.
expect(attr(chatSpan, 'gen_ai.system_instructions')).toContain('automated test');
expect(attr(chatSpan, 'gen_ai.input.messages')).toContain('capital of France');

// A stringified array of messages, not one concatenated string.
const responseText = JSON.parse(attr(chatSpan, 'gen_ai.response.text') as string);
expect(Array.isArray(responseText)).toBe(true);
expect(responseText).toHaveLength(1);
expect(typeof responseText[0]).toBe('string');

const outputMessages = JSON.parse(attr(chatSpan, 'gen_ai.output.messages') as string);
expect(outputMessages).toEqual([
{
role: 'assistant',
parts: [{ type: 'text', content: expect.any(String) }],
finish_reason: expect.any(String),
},
]);
});
Loading
Loading