-
-
Notifications
You must be signed in to change notification settings - Fork 1.9k
test(e2e): Add a Mistral E2E app covering both instrumentation paths #24378
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| dist |
| 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'); |
| 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; |
| 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', | ||||
| enableRuntimeChannelInjection: isDev, | ||||
| integrations: [Sentry.spanStreamingIntegration()], | ||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
|
||||
| }); | ||||
|
|
||||
| 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'); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Shared routes use non-unique waitersMedium Severity Several tests wait only on Additional Locations (2)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), | ||
| }, | ||
| ]); | ||
| }); | ||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
l: