diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index 5603e438039b..6613140bdc23 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -44,6 +44,7 @@ "@langchain/core": "^0.3.80", "@langchain/langgraph": "^0.2.32", "@langchain/openai": "^0.5.0", + "@mistralai/mistralai": "2.6.4", "@modelcontextprotocol/client": "^2.0.0", "@modelcontextprotocol/server": "^2.0.0", "@nestjs/common": "^11", diff --git a/dev-packages/node-integration-tests/suites/tracing/mistral/instrument-manual.mjs b/dev-packages/node-integration-tests/suites/tracing/mistral/instrument-manual.mjs new file mode 100644 index 000000000000..f1a5eac39be7 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mistral/instrument-manual.mjs @@ -0,0 +1,13 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + dataCollection: { genAI: { inputs: true, outputs: true } }, + transport: loggingTransport, + // `instrumentMistralAiClient` is the manual path for runtimes without the orchestrion hook. + // Drop the automatic integration so the scenario exercises it alone. + integrations: integrations => integrations.filter(integration => integration.name !== 'Mistral'), +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/mistral/instrument-with-options.mjs b/dev-packages/node-integration-tests/suites/tracing/mistral/instrument-with-options.mjs new file mode 100644 index 000000000000..707c1d886a60 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mistral/instrument-with-options.mjs @@ -0,0 +1,15 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + integrations: [ + Sentry.mistralAIIntegration({ + recordInputs: true, + recordOutputs: true, + }), + ], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/mistral/instrument-with-pii.mjs b/dev-packages/node-integration-tests/suites/tracing/mistral/instrument-with-pii.mjs new file mode 100644 index 000000000000..1c507ba84b9c --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mistral/instrument-with-pii.mjs @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + dataCollection: { genAI: { inputs: true, outputs: true } }, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/mistral/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/mistral/instrument.mjs new file mode 100644 index 000000000000..cc192fb89834 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mistral/instrument.mjs @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + dataCollection: { genAI: { inputs: false, outputs: false } }, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/mistral/scenario-agents.mjs b/dev-packages/node-integration-tests/suites/tracing/mistral/scenario-agents.mjs new file mode 100644 index 000000000000..61bf5ab07187 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mistral/scenario-agents.mjs @@ -0,0 +1,124 @@ +import { Mistral } from '@mistralai/mistralai'; +import * as Sentry from '@sentry/node'; +import express from 'express'; + +function startMockServer() { + const app = express(); + app.use(express.json()); + + app.post('/v1/agents/completions', (req, res) => { + const { agent_id: agentId, stream } = req.body; + + if (agentId === 'error-agent') { + res.status(404).set('x-request-id', 'mock-request-123').end('Agent not found'); + return; + } + + if (stream) { + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + + const chunks = [ + { + id: 'agentcmpl-stream-123', + object: 'chat.completion.chunk', + created: 1677652300, + model: 'mistral-large-latest', + choices: [ + { + index: 0, + delta: { role: 'assistant', content: '' }, + finish_reason: null, + }, + ], + }, + { + id: 'agentcmpl-stream-123', + object: 'chat.completion.chunk', + created: 1677652300, + model: 'mistral-large-latest', + choices: [ + { + index: 0, + delta: { content: 'Hello from Mistral agent streaming!' }, + finish_reason: null, + }, + ], + }, + { + id: 'agentcmpl-stream-123', + object: 'chat.completion.chunk', + created: 1677652300, + model: 'mistral-large-latest', + choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], + usage: { prompt_tokens: 12, completion_tokens: 18, total_tokens: 30 }, + }, + ]; + + chunks.forEach((chunk, index) => { + setTimeout(() => { + res.write(`data: ${JSON.stringify(chunk)}\n\n`); + if (index === chunks.length - 1) { + res.write('data: [DONE]\n\n'); + res.end(); + } + }, index * 10); + }); + } else { + res.send({ + id: 'agentcmpl-mock123', + object: 'chat.completion', + created: 1677652288, + model: 'mistral-large-latest', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: 'Hello from Mistral agent!', + }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 10, completion_tokens: 15, total_tokens: 25 }, + }); + } + }); + + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +async function run() { + const server = await startMockServer(); + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const client = new Mistral({ + apiKey: 'mock-api-key', + serverURL: `http://localhost:${server.address().port}`, + }); + + await client.agents.complete({ + agentId: 'ag-mock-123', + messages: [{ role: 'user', content: 'Who is the best French painter?' }], + }); + + const stream = await client.agents.stream({ + agentId: 'ag-mock-123', + messages: [{ role: 'user', content: 'Tell me about streaming' }], + }); + + for await (const event of stream) { + void event; + } + }); + + await Sentry.flush(2000); + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/mistral/scenario-chat.mjs b/dev-packages/node-integration-tests/suites/tracing/mistral/scenario-chat.mjs new file mode 100644 index 000000000000..37f0877e3dcd --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mistral/scenario-chat.mjs @@ -0,0 +1,137 @@ +import { Mistral } from '@mistralai/mistralai'; +import * as Sentry from '@sentry/node'; +import express from 'express'; + +function startMockServer() { + const app = express(); + app.use(express.json()); + + app.post('/v1/chat/completions', (req, res) => { + const { model, stream } = req.body; + + // error-model returns 404 (not retried by the SDK) so the span records an error + if (model === 'error-model') { + res.status(404).set('x-request-id', 'mock-request-123').end('Model not found'); + return; + } + + if (stream) { + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + + const chunks = [ + { + id: 'chatcmpl-stream-123', + object: 'chat.completion.chunk', + created: 1677652300, + model, + choices: [ + { + index: 0, + delta: { role: 'assistant', content: '' }, + finish_reason: null, + }, + ], + }, + { + id: 'chatcmpl-stream-123', + object: 'chat.completion.chunk', + created: 1677652300, + model, + choices: [ + { + index: 0, + delta: { content: 'Hello from Mistral streaming!' }, + finish_reason: null, + }, + ], + }, + { + id: 'chatcmpl-stream-123', + object: 'chat.completion.chunk', + created: 1677652300, + model, + choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], + usage: { prompt_tokens: 12, completion_tokens: 18, total_tokens: 30 }, + }, + ]; + + chunks.forEach((chunk, index) => { + setTimeout(() => { + res.write(`data: ${JSON.stringify(chunk)}\n\n`); + if (index === chunks.length - 1) { + res.write('data: [DONE]\n\n'); + res.end(); + } + }, index * 10); + }); + } else { + res.send({ + id: 'chatcmpl-mock123', + object: 'chat.completion', + created: 1677652288, + model, + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Hello from Mistral mock!' }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 10, completion_tokens: 15, total_tokens: 25 }, + }); + } + }); + + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +async function run() { + const server = await startMockServer(); + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const client = new Mistral({ + apiKey: 'mock-api-key', + serverURL: `http://localhost:${server.address().port}`, + }); + + await client.chat.complete({ + model: 'mistral-small-latest', + messages: [ + { role: 'system', content: 'You are a helpful assistant.' }, + { role: 'user', content: 'What is the capital of France?' }, + ], + temperature: 0.7, + maxTokens: 100, + }); + + try { + await client.chat.complete({ + model: 'error-model', + messages: [{ role: 'user', content: 'This will fail' }], + }); + } catch { + // expected + } + + const stream = await client.chat.stream({ + model: 'mistral-large-latest', + messages: [{ role: 'user', content: 'Tell me about streaming' }], + temperature: 0.8, + }); + + for await (const event of stream) { + void event; + } + }); + + await Sentry.flush(2000); + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/mistral/scenario-embeddings.mjs b/dev-packages/node-integration-tests/suites/tracing/mistral/scenario-embeddings.mjs new file mode 100644 index 000000000000..f05ac044c411 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mistral/scenario-embeddings.mjs @@ -0,0 +1,67 @@ +import { Mistral } from '@mistralai/mistralai'; +import * as Sentry from '@sentry/node'; +import express from 'express'; + +function startMockServer() { + const app = express(); + app.use(express.json()); + + app.post('/v1/embeddings', (req, res) => { + const { model, inputs } = req.body; + + if (model === 'error-model') { + res.status(404).set('x-request-id', 'mock-request-123').end('Model not found'); + return; + } + + // Distinct id per call shape so tests can target the single-input span unambiguously. + res.send({ + id: Array.isArray(inputs) ? 'embd-mock-multi' : 'embd-mock123', + object: 'list', + model, + data: [{ object: 'embedding', embedding: [0.1, 0.2, 0.3], index: 0 }], + usage: { prompt_tokens: 8, total_tokens: 8 }, + }); + }); + + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +async function run() { + const server = await startMockServer(); + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const client = new Mistral({ + apiKey: 'mock-api-key', + serverURL: `http://localhost:${server.address().port}`, + }); + + await client.embeddings.create({ + model: 'mistral-embed', + inputs: 'Embedding test!', + }); + + try { + await client.embeddings.create({ + model: 'error-model', + inputs: 'Error embedding test!', + }); + } catch { + // expected + } + + await client.embeddings.create({ + model: 'mistral-embed', + inputs: ['First input text', 'Second input text'], + }); + }); + + await Sentry.flush(2000); + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/mistral/scenario-manual.mjs b/dev-packages/node-integration-tests/suites/tracing/mistral/scenario-manual.mjs new file mode 100644 index 000000000000..0d84649ece77 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mistral/scenario-manual.mjs @@ -0,0 +1,99 @@ +import { Mistral } from '@mistralai/mistralai'; +import * as Sentry from '@sentry/node'; +import express from 'express'; + +function startMockServer() { + const app = express(); + app.use(express.json()); + + app.post('/v1/chat/completions', (req, res) => { + const { model, stream } = req.body; + + if (stream) { + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + + const chunks = [ + { + id: 'chatcmpl-manual-stream-123', + object: 'chat.completion.chunk', + created: 1677652300, + model, + choices: [{ index: 0, delta: { role: 'assistant', content: 'Manual ' }, finish_reason: null }], + }, + { + id: 'chatcmpl-manual-stream-123', + object: 'chat.completion.chunk', + created: 1677652300, + model, + choices: [{ index: 0, delta: { content: 'streaming!' }, finish_reason: 'stop' }], + usage: { prompt_tokens: 5, completion_tokens: 7, total_tokens: 12 }, + }, + ]; + + chunks.forEach((chunk, index) => { + setTimeout(() => { + res.write(`data: ${JSON.stringify(chunk)}\n\n`); + if (index === chunks.length - 1) { + res.write('data: [DONE]\n\n'); + res.end(); + } + }, index * 10); + }); + } else { + res.send({ + id: 'chatcmpl-manual-123', + object: 'chat.completion', + created: 1677652288, + model, + choices: [ + { index: 0, message: { role: 'assistant', content: 'Hello from the manual client!' }, finish_reason: 'stop' }, + ], + usage: { prompt_tokens: 10, completion_tokens: 15, total_tokens: 25 }, + }); + } + }); + + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +async function run() { + const server = await startMockServer(); + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const client = Sentry.instrumentMistralAiClient( + new Mistral({ apiKey: 'mock-api-key', serverURL: `http://localhost:${server.address().port}` }), + { recordInputs: true, recordOutputs: true }, + ); + + await client.chat.complete({ + model: 'mistral-small-latest', + messages: [{ role: 'user', content: 'What is the capital of France?' }], + }); + + const stream = await client.chat.stream({ + model: 'mistral-large-latest', + messages: [{ role: 'user', content: 'Tell me about streaming' }], + }); + + // `EventStream` extends `ReadableStream`, so draining it through a reader has to keep working and + // has to end the span. Instrumentation that swapped in a bare async generator would throw here. + const reader = stream.getReader(); + for (;;) { + const { done } = await reader.read(); + if (done) { + break; + } + } + }); + + await Sentry.flush(2000); + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/mistral/scenario-parse.mjs b/dev-packages/node-integration-tests/suites/tracing/mistral/scenario-parse.mjs new file mode 100644 index 000000000000..a80f0df6819a --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mistral/scenario-parse.mjs @@ -0,0 +1,90 @@ +import { Mistral } from '@mistralai/mistralai'; +import * as Sentry from '@sentry/node'; +import express from 'express'; +import { z } from 'zod'; + +// `chat.parse` and `chat.parseStream` are the structured-output entry points. They call the +// underlying request functions directly rather than `this.complete` / `this.stream`, so they need +// their own orchestrion entries — and cannot produce a duplicate span from the sibling method. +function startMockServer() { + const app = express(); + app.use(express.json()); + + app.post('/v1/chat/completions', (req, res) => { + const { model, stream } = req.body; + + if (stream) { + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + + const chunks = [ + { + id: 'chatcmpl-parse-stream-123', + object: 'chat.completion.chunk', + created: 1677652300, + model, + choices: [{ index: 0, delta: { role: 'assistant', content: '{"city":"Paris"}' }, finish_reason: 'stop' }], + usage: { prompt_tokens: 4, completion_tokens: 6, total_tokens: 10 }, + }, + ]; + + chunks.forEach((chunk, index) => { + setTimeout(() => { + res.write(`data: ${JSON.stringify(chunk)}\n\n`); + if (index === chunks.length - 1) { + res.write('data: [DONE]\n\n'); + res.end(); + } + }, index * 10); + }); + return; + } + + res.send({ + id: 'chatcmpl-parse-123', + object: 'chat.completion', + created: 1677652288, + model, + choices: [{ index: 0, message: { role: 'assistant', content: '{"city":"Paris"}' }, finish_reason: 'stop' }], + usage: { prompt_tokens: 5, completion_tokens: 7, total_tokens: 12 }, + }); + }); + + return new Promise(resolve => { + const server = app.listen(0, () => resolve(server)); + }); +} + +async function run() { + const server = await startMockServer(); + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const client = new Mistral({ + apiKey: 'mock-api-key', + serverURL: `http://localhost:${server.address().port}`, + }); + + const responseFormat = z.object({ city: z.string() }); + + await client.chat.parse({ + model: 'mistral-small-latest', + messages: [{ role: 'user', content: 'Which city?' }], + responseFormat, + }); + + const stream = await client.chat.parseStream({ + model: 'mistral-large-latest', + messages: [{ role: 'user', content: 'Which city?' }], + responseFormat, + }); + + for await (const event of stream) { + void event; + } + }); + + await Sentry.flush(2000); + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/mistral/scenario-tools.mjs b/dev-packages/node-integration-tests/suites/tracing/mistral/scenario-tools.mjs new file mode 100644 index 000000000000..c6bf3542d0b7 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mistral/scenario-tools.mjs @@ -0,0 +1,136 @@ +import { Mistral } from '@mistralai/mistralai'; +import * as Sentry from '@sentry/node'; +import express from 'express'; + +const weatherTool = { + type: 'function', + function: { + name: 'get_weather', + description: 'Get the current weather for a city', + parameters: { + type: 'object', + properties: { city: { type: 'string' } }, + required: ['city'], + }, + }, +}; + +function startMockServer() { + const app = express(); + app.use(express.json()); + + app.post('/v1/chat/completions', (req, res) => { + const { model, stream } = req.body; + + if (stream) { + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + + // Tool call streamed across chunks — the argument string arrives fragmented. + const toolCall = frag => [ + { index: 0, id: 'call_1', type: 'function', function: { name: 'get_weather', arguments: frag } }, + ]; + const chunks = [ + { + id: 'chatcmpl-tools-stream-123', + object: 'chat.completion.chunk', + created: 1677652300, + model, + choices: [{ index: 0, delta: { role: 'assistant', tool_calls: toolCall('') }, finish_reason: null }], + }, + { + id: 'chatcmpl-tools-stream-123', + object: 'chat.completion.chunk', + created: 1677652300, + model, + choices: [{ index: 0, delta: { tool_calls: toolCall('{"city":') }, finish_reason: null }], + }, + { + id: 'chatcmpl-tools-stream-123', + object: 'chat.completion.chunk', + created: 1677652300, + model, + choices: [{ index: 0, delta: { tool_calls: toolCall('"Paris"}') }, finish_reason: null }], + }, + { + id: 'chatcmpl-tools-stream-123', + object: 'chat.completion.chunk', + created: 1677652300, + model, + choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }], + usage: { prompt_tokens: 12, completion_tokens: 8, total_tokens: 20 }, + }, + ]; + + chunks.forEach((chunk, index) => { + setTimeout(() => { + res.write(`data: ${JSON.stringify(chunk)}\n\n`); + if (index === chunks.length - 1) { + res.write('data: [DONE]\n\n'); + res.end(); + } + }, index * 10); + }); + } else { + res.send({ + id: 'chatcmpl-tools-123', + object: 'chat.completion', + created: 1677652288, + model, + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: '', + tool_calls: [ + { id: 'call_1', type: 'function', function: { name: 'get_weather', arguments: '{"city":"Paris"}' } }, + ], + }, + finish_reason: 'tool_calls', + }, + ], + usage: { prompt_tokens: 12, completion_tokens: 8, total_tokens: 20 }, + }); + } + }); + + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +async function run() { + const server = await startMockServer(); + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const client = new Mistral({ + apiKey: 'mock-api-key', + serverURL: `http://localhost:${server.address().port}`, + }); + + await client.chat.complete({ + model: 'mistral-small-latest', + messages: [{ role: 'user', content: 'What is the weather in Paris?' }], + tools: [weatherTool], + }); + + const stream = await client.chat.stream({ + model: 'mistral-small-latest', + messages: [{ role: 'user', content: 'What is the weather in Paris?' }], + tools: [weatherTool], + }); + + for await (const event of stream) { + void event; + } + }); + + await Sentry.flush(2000); + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/mistral/test.ts b/dev-packages/node-integration-tests/suites/tracing/mistral/test.ts new file mode 100644 index 000000000000..49fe09771da1 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mistral/test.ts @@ -0,0 +1,316 @@ +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; +import { + GEN_AI_AGENT_NAME, + GEN_AI_EMBEDDINGS_INPUT, + GEN_AI_INPUT_MESSAGES, + GEN_AI_OPERATION_NAME, + GEN_AI_PROVIDER_NAME, + GEN_AI_REQUEST_MAX_TOKENS, + GEN_AI_REQUEST_MODEL, + GEN_AI_REQUEST_TEMPERATURE, + GEN_AI_RESPONSE_FINISH_REASONS, + GEN_AI_RESPONSE_ID, + GEN_AI_RESPONSE_MODEL, + GEN_AI_RESPONSE_STREAMING, + GEN_AI_OUTPUT_MESSAGES, + GEN_AI_RESPONSE_TEXT, + GEN_AI_RESPONSE_TOOL_CALLS, + GEN_AI_SYSTEM_INSTRUCTIONS, + GEN_AI_TOOL_DEFINITIONS, + GEN_AI_USAGE_INPUT_TOKENS, + GEN_AI_USAGE_OUTPUT_TOKENS, + GEN_AI_USAGE_TOTAL_TOKENS, +} from '@sentry/conventions/attributes'; +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmTests } from '../../../utils/runner'; + +const PROVIDER = 'mistralai'; +const ORIGIN = 'auto.ai.mistralai'; +// Not exported from `@sentry/conventions` yet; the SDK defines it in `ai/core/gen-ai-attributes`. +const GEN_AI_REQUEST_STREAM = 'gen_ai.request.stream'; + +// ESM-only: `@mistralai/mistralai` v2 ships no CJS build, so CJS consumers load it via `require(esm)`, +// whose auto-instrumentation is inconsistent across Node versions. The SDK's native mode is ESM, so we +// only run the suite there. +describe('Mistral integration', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmTests(__dirname, 'scenario-chat.mjs', 'instrument.mjs', (createRunner, test) => { + test('creates chat spans with genAI recording disabled', async () => { + await createRunner() + .expect({ + span: container => { + const chatSpan = container.items.find(s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123'); + expect(chatSpan).toBeDefined(); + expect(chatSpan!.name).toBe('chat mistral-small-latest'); + expect(chatSpan!.status).toBe('ok'); + expect(chatSpan!.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('chat'); + expect(chatSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]?.value).toBe('gen_ai.chat'); + expect(chatSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]?.value).toBe(ORIGIN); + expect(chatSpan!.attributes[GEN_AI_PROVIDER_NAME]?.value).toBe(PROVIDER); + expect(chatSpan!.attributes[GEN_AI_REQUEST_MODEL]?.value).toBe('mistral-small-latest'); + expect(chatSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE]?.value).toBe(0.7); + expect(chatSpan!.attributes[GEN_AI_REQUEST_MAX_TOKENS]?.value).toBe(100); + expect(chatSpan!.attributes[GEN_AI_RESPONSE_MODEL]?.value).toBe('mistral-small-latest'); + expect(chatSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]?.value).toBe('["stop"]'); + expect(chatSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value).toBe(10); + expect(chatSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]?.value).toBe(15); + expect(chatSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]?.value).toBe(25); + // recording disabled → no prompt/response content + expect(chatSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toBeUndefined(); + expect(chatSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toBeUndefined(); + + const streamSpan = container.items.find( + s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-stream-123', + ); + expect(streamSpan).toBeDefined(); + expect(streamSpan!.name).toBe('chat mistral-large-latest'); + expect(streamSpan!.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('chat'); + expect(streamSpan!.attributes[GEN_AI_RESPONSE_STREAMING]?.value).toBe(true); + expect(streamSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]?.value).toBe(30); + + // `chat.stream()` takes no `stream` request field, so the flag has to come from the method. + expect(streamSpan!.attributes[GEN_AI_REQUEST_STREAM]?.value).toBe(true); + expect(chatSpan!.attributes[GEN_AI_REQUEST_STREAM]?.value).toBe(false); + + const errorSpan = container.items.find(s => s.attributes[GEN_AI_REQUEST_MODEL]?.value === 'error-model'); + expect(errorSpan).toBeDefined(); + // `bindTracingChannelToSpan` derives the status message from the thrown error. + expect(errorSpan!.status).toBe('error'); + }, + }) + .start() + .completed(); + }); + }); + + createEsmTests(__dirname, 'scenario-chat.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { + test('records chat inputs and outputs with PII enabled', async () => { + await createRunner() + .expect({ + span: container => { + const chatSpan = container.items.find(s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123'); + expect(chatSpan).toBeDefined(); + // The system message is split out into gen_ai.system_instructions. + expect(chatSpan!.attributes[GEN_AI_SYSTEM_INSTRUCTIONS]?.value).toContain('You are a helpful assistant.'); + expect(chatSpan!.attributes[GEN_AI_INPUT_MESSAGES]?.value).toBe( + '[{"role":"user","content":"What is the capital of France?"}]', + ); + expect(chatSpan!.attributes[GEN_AI_RESPONSE_TEXT]?.value).toBe('["Hello from Mistral mock!"]'); + expect(chatSpan!.attributes[GEN_AI_OUTPUT_MESSAGES]?.value).toBe( + '[{"role":"assistant","parts":[{"type":"text","content":"Hello from Mistral mock!"}],"finish_reason":"stop"}]', + ); + }, + }) + .start() + .completed(); + }); + }); + + createEsmTests(__dirname, 'scenario-chat.mjs', 'instrument-with-options.mjs', (createRunner, test) => { + test('records chat inputs and outputs with explicit integration options', async () => { + await createRunner() + .expect({ + span: container => { + const chatSpan = container.items.find(s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123'); + expect(chatSpan).toBeDefined(); + expect(chatSpan!.attributes[GEN_AI_INPUT_MESSAGES]?.value).toContain('What is the capital of France?'); + expect(chatSpan!.attributes[GEN_AI_RESPONSE_TEXT]?.value).toBe('["Hello from Mistral mock!"]'); + expect(chatSpan!.attributes[GEN_AI_OUTPUT_MESSAGES]?.value).toBe( + '[{"role":"assistant","parts":[{"type":"text","content":"Hello from Mistral mock!"}],"finish_reason":"stop"}]', + ); + }, + }) + .start() + .completed(); + }); + }); + + createEsmTests(__dirname, 'scenario-embeddings.mjs', 'instrument.mjs', (createRunner, test) => { + test('creates embeddings spans', async () => { + await createRunner() + .expect({ + span: container => { + const embeddingsSpan = container.items.find( + s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'embd-mock123', + ); + expect(embeddingsSpan).toBeDefined(); + expect(embeddingsSpan!.name).toBe('embeddings mistral-embed'); + expect(embeddingsSpan!.status).toBe('ok'); + expect(embeddingsSpan!.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('embeddings'); + expect(embeddingsSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]?.value).toBe('gen_ai.embeddings'); + expect(embeddingsSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]?.value).toBe(ORIGIN); + expect(embeddingsSpan!.attributes[GEN_AI_PROVIDER_NAME]?.value).toBe(PROVIDER); + expect(embeddingsSpan!.attributes[GEN_AI_REQUEST_MODEL]?.value).toBe('mistral-embed'); + expect(embeddingsSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value).toBe(8); + expect(embeddingsSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]?.value).toBe(8); + expect(embeddingsSpan!.attributes[GEN_AI_EMBEDDINGS_INPUT]).toBeUndefined(); + }, + }) + .start() + .completed(); + }); + }); + + createEsmTests(__dirname, 'scenario-embeddings.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { + test('records embeddings input with PII enabled', async () => { + await createRunner() + .expect({ + span: container => { + const embeddingsSpan = container.items.find( + s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'embd-mock123', + ); + expect(embeddingsSpan).toBeDefined(); + expect(embeddingsSpan!.attributes[GEN_AI_EMBEDDINGS_INPUT]?.value).toContain('Embedding test!'); + }, + }) + .start() + .completed(); + }); + }); + + createEsmTests(__dirname, 'scenario-agents.mjs', 'instrument.mjs', (createRunner, test) => { + test('creates invoke_agent spans', async () => { + await createRunner() + .expect({ + span: container => { + const agentSpan = container.items.find( + s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'agentcmpl-mock123', + ); + expect(agentSpan).toBeDefined(); + expect(agentSpan!.name).toBe('invoke_agent'); + expect(agentSpan!.status).toBe('ok'); + expect(agentSpan!.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('invoke_agent'); + expect(agentSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]?.value).toBe('gen_ai.invoke_agent'); + expect(agentSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]?.value).toBe(ORIGIN); + expect(agentSpan!.attributes[GEN_AI_PROVIDER_NAME]?.value).toBe(PROVIDER); + expect(agentSpan!.attributes[GEN_AI_AGENT_NAME]?.value).toBe('ag-mock-123'); + expect(agentSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value).toBe(10); + expect(agentSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]?.value).toBe(25); + + const agentStreamSpan = container.items.find( + s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'agentcmpl-stream-123', + ); + expect(agentStreamSpan).toBeDefined(); + expect(agentStreamSpan!.attributes[GEN_AI_RESPONSE_STREAMING]?.value).toBe(true); + }, + }) + .start() + .completed(); + }); + }); + + createEsmTests(__dirname, 'scenario-agents.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { + test('records agent inputs and outputs with PII enabled', async () => { + await createRunner() + .expect({ + span: container => { + const agentSpan = container.items.find( + s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'agentcmpl-mock123', + ); + expect(agentSpan).toBeDefined(); + expect(agentSpan!.attributes[GEN_AI_INPUT_MESSAGES]?.value).toContain('Who is the best French painter?'); + expect(agentSpan!.attributes[GEN_AI_RESPONSE_TEXT]?.value).toBe('["Hello from Mistral agent!"]'); + }, + }) + .start() + .completed(); + }); + }); + + createEsmTests(__dirname, 'scenario-tools.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { + test('records tool definitions and tool calls (streaming + non-streaming)', async () => { + await createRunner() + .expect({ + span: container => { + const toolSpan = container.items.find( + s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-tools-123', + ); + expect(toolSpan).toBeDefined(); + expect(toolSpan!.attributes[GEN_AI_TOOL_DEFINITIONS]?.value).toContain('get_weather'); + expect(toolSpan!.attributes[GEN_AI_RESPONSE_TOOL_CALLS]?.value).toContain('get_weather'); + expect(toolSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]?.value).toBe('["tool_calls"]'); + + const streamSpan = container.items.find( + s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-tools-stream-123', + ); + expect(streamSpan).toBeDefined(); + expect(streamSpan!.attributes[GEN_AI_TOOL_DEFINITIONS]?.value).toContain('get_weather'); + // The fragmented argument string ('{"city":' + '"Paris"}') is accumulated across chunks — + // both fragments present proves the join (quotes are backslash-escaped in the JSON string). + const streamedToolCalls = streamSpan!.attributes[GEN_AI_RESPONSE_TOOL_CALLS]?.value; + expect(streamedToolCalls).toContain('get_weather'); + expect(streamedToolCalls).toContain('city'); + expect(streamedToolCalls).toContain('Paris'); + }, + }) + .start() + .completed(); + }); + }); + + createEsmTests(__dirname, 'scenario-manual.mjs', 'instrument-manual.mjs', (createRunner, test) => { + test('instruments a client wrapped with instrumentMistralAiClient', async () => { + await createRunner() + .expect({ + span: container => { + const chatSpan = container.items.find( + s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-manual-123', + ); + expect(chatSpan).toBeDefined(); + expect(chatSpan!.name).toBe('chat mistral-small-latest'); + expect(chatSpan!.status).toBe('ok'); + expect(chatSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]?.value).toBe(ORIGIN); + expect(chatSpan!.attributes[GEN_AI_PROVIDER_NAME]?.value).toBe(PROVIDER); + expect(chatSpan!.attributes[GEN_AI_RESPONSE_TEXT]?.value).toBe('["Hello from the manual client!"]'); + + // The scenario drains the stream with `getReader()`, which only works if the instrumented + // result is still the SDK's `EventStream` and only ends the span if the reader is wrapped. + const streamSpan = container.items.find( + s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-manual-stream-123', + ); + expect(streamSpan).toBeDefined(); + expect(streamSpan!.name).toBe('chat mistral-large-latest'); + expect(streamSpan!.attributes[GEN_AI_RESPONSE_STREAMING]?.value).toBe(true); + expect(streamSpan!.attributes[GEN_AI_REQUEST_STREAM]?.value).toBe(true); + expect(streamSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]?.value).toBe(12); + expect(streamSpan!.attributes[GEN_AI_RESPONSE_TEXT]?.value).toBe('Manual streaming!'); + }, + }) + .start() + .completed(); + }); + }); + createEsmTests(__dirname, 'scenario-parse.mjs', 'instrument.mjs', (createRunner, test) => { + test('creates chat spans for the structured-output entry points', async () => { + await createRunner() + .expect({ + span: container => { + // `parse` bypasses `chat.complete`, so this span can only come from its own + // orchestrion entry — and there must be exactly one, not one per underlying call. + const parseSpans = container.items.filter( + s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-parse-123', + ); + expect(parseSpans).toHaveLength(1); + expect(parseSpans[0]!.name).toBe('chat mistral-small-latest'); + expect(parseSpans[0]!.status).toBe('ok'); + expect(parseSpans[0]!.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('chat'); + expect(parseSpans[0]!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]?.value).toBe(ORIGIN); + expect(parseSpans[0]!.attributes[GEN_AI_REQUEST_STREAM]?.value).toBe(false); + expect(parseSpans[0]!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]?.value).toBe(12); + + const parseStreamSpan = container.items.find( + s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-parse-stream-123', + ); + expect(parseStreamSpan).toBeDefined(); + expect(parseStreamSpan!.attributes[GEN_AI_REQUEST_STREAM]?.value).toBe(true); + expect(parseStreamSpan!.attributes[GEN_AI_RESPONSE_STREAMING]?.value).toBe(true); + }, + }) + .start() + .completed(); + }); + }); +}); diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 03150cf4619f..66faa541489e 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -91,6 +91,7 @@ export { nodeContextIntegration, onUncaughtExceptionIntegration, onUnhandledRejectionIntegration, + mistralAIIntegration, openAIIntegration, langChainIntegration, langGraphIntegration, @@ -158,6 +159,7 @@ export { withScope, supabaseIntegration, instrumentSupabaseClient, + instrumentMistralAiClient, instrumentOpenAiClient, instrumentAnthropicAiClient, instrumentGoogleGenAIClient, diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index 06794f7a380f..d231a660a9f9 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -59,6 +59,7 @@ export { nativeNodeFetchIntegration, onUncaughtExceptionIntegration, onUnhandledRejectionIntegration, + mistralAIIntegration, openAIIntegration, langChainIntegration, langGraphIntegration, @@ -141,6 +142,7 @@ export { updateSpanName, supabaseIntegration, instrumentSupabaseClient, + instrumentMistralAiClient, instrumentOpenAiClient, instrumentAnthropicAiClient, instrumentGoogleGenAIClient, diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index b98e4b5eb2f1..c9f7981a3122 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -81,6 +81,7 @@ export { httpServerSpansIntegration, onUncaughtExceptionIntegration, onUnhandledRejectionIntegration, + mistralAIIntegration, openAIIntegration, langChainIntegration, langGraphIntegration, @@ -158,6 +159,7 @@ export { updateSpanName, supabaseIntegration, instrumentSupabaseClient, + instrumentMistralAiClient, instrumentOpenAiClient, instrumentAnthropicAiClient, instrumentGoogleGenAIClient, diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 5fb8ed0863e3..8645514540d1 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -125,6 +125,7 @@ export { openTelemetryIntegration, getOtlpTracesEndpoint, prismaIntegration, + instrumentMistralAiClient, instrumentOpenAiClient, instrumentAnthropicAiClient, instrumentGoogleGenAIClient, diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index f99ad0f7c85c..02292fa57d81 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -143,6 +143,7 @@ export { mongooseIntegration, mysqlIntegration, mysql2Integration, + mistralAIIntegration, openAIIntegration, postgresIntegration, postgresJsIntegration, diff --git a/packages/elysia/src/index.ts b/packages/elysia/src/index.ts index 9cab170cce2b..28e6fc1ee2f3 100644 --- a/packages/elysia/src/index.ts +++ b/packages/elysia/src/index.ts @@ -60,6 +60,7 @@ export { fetchIntegration, onUncaughtExceptionIntegration, onUnhandledRejectionIntegration, + mistralAIIntegration, openAIIntegration, langChainIntegration, langGraphIntegration, @@ -135,6 +136,7 @@ export { updateSpanName, supabaseIntegration, instrumentSupabaseClient, + instrumentMistralAiClient, instrumentOpenAiClient, instrumentAnthropicAiClient, instrumentGoogleGenAIClient, diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index c328ec171666..9f36e06e9ed8 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -59,6 +59,7 @@ export { nativeNodeFetchIntegration, onUncaughtExceptionIntegration, onUnhandledRejectionIntegration, + mistralAIIntegration, openAIIntegration, langChainIntegration, langGraphIntegration, @@ -138,6 +139,7 @@ export { supabaseIntegration, systemErrorIntegration, instrumentSupabaseClient, + instrumentMistralAiClient, instrumentOpenAiClient, instrumentAnthropicAiClient, instrumentGoogleGenAIClient, diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index fde46f43ed33..3754f8408f86 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -26,6 +26,7 @@ export { mongoIntegration, mongooseIntegration, mysqlIntegration, + mistralAIIntegration, mysql2Integration, openAIIntegration, postgresIntegration, @@ -41,6 +42,7 @@ export { instrumentOpenAiClient, instrumentAnthropicAiClient, instrumentGoogleGenAIClient, + instrumentMistralAiClient, createLangChainCallbackHandler, instrumentLangChainEmbeddings, instrumentStateGraph, diff --git a/packages/server-utils/src/ai/core/utils.ts b/packages/server-utils/src/ai/core/utils.ts index f0bd9b7ce051..2c2fba4e88c8 100644 --- a/packages/server-utils/src/ai/core/utils.ts +++ b/packages/server-utils/src/ai/core/utils.ts @@ -2,9 +2,10 @@ /** * Shared utils for AI integrations (OpenAI, Anthropic, Verce.AI, etc.) */ -import { getClient, isThenable } from '@sentry/core'; +import { getClient, isThenable, stringify } from '@sentry/core'; import type { Span } from '@sentry/core'; import { + GEN_AI_OUTPUT_MESSAGES, GEN_AI_RESPONSE_FINISH_REASONS, GEN_AI_RESPONSE_ID, GEN_AI_RESPONSE_MODEL, @@ -128,6 +129,79 @@ export function setTokenUsageAttributes( } } +/** One assistant turn for {@link setOutputMessagesAttribute}. */ +export interface GenAiOutputMessage { + /** The message's text content, already flattened out of any content-part array. */ + responseText?: string; + /** Tool calls in either the OpenAI-compatible (`function.name`) or flat (`name`) shape. */ + toolCalls?: unknown[]; + /** Recorded as `finish_reason` on the message, per the `gen_ai.output.messages` schema. */ + finishReason?: string; +} + +/** + * Build the `gen_ai.output.messages` value (assistant messages with text and/or tool-call parts). + * + * We set this in addition to the deprecated `gen_ai.response.text` / `gen_ai.response.tool_calls` + * attributes because Sentry's product reads the model output from `gen_ai.output.messages` first. + * Relay migrates `gen_ai.response.text` into `gen_ai.output.messages`, but the tool-calls half of + * that migration is lossy — so tool-call turns would otherwise render an empty Output. + * + * Pass an array for providers that can return more than one choice per response; a single object is + * the common case of one assistant turn. + */ +export function setOutputMessagesAttribute(span: Span, messages: GenAiOutputMessage | GenAiOutputMessage[]): void { + const serialized = (Array.isArray(messages) ? messages : [messages]) + .map(buildOutputMessage) + .filter((message): message is Record => !!message); + + if (serialized.length > 0) { + span.setAttribute(GEN_AI_OUTPUT_MESSAGES, JSON.stringify(serialized)); + } +} + +function buildOutputMessage({ + responseText, + toolCalls, + finishReason, +}: GenAiOutputMessage): Record | undefined { + const parts: Array> = []; + + if (typeof responseText === 'string' && responseText.length > 0) { + parts.push({ type: 'text', content: responseText }); + } + + if (Array.isArray(toolCalls)) { + for (const toolCall of toolCalls) { + if (!toolCall || typeof toolCall !== 'object') { + continue; + } + const call = toolCall as { + id?: unknown; + function?: { name?: unknown; arguments?: unknown }; + name?: unknown; + arguments?: unknown; + }; + // Normalize both the OpenAI-compatible shape (name/arguments nested under `function`) + // and the flat shape some providers use. + const name = call.function?.name ?? call.name; + const args = call.function?.arguments ?? call.arguments; + parts.push({ + type: 'tool_call', + id: call.id, + name, + arguments: stringify(args ?? {}, String), + }); + } + } + + if (parts.length === 0) { + return undefined; + } + + return finishReason ? { role: 'assistant', parts, finish_reason: finishReason } : { role: 'assistant', parts }; +} + export interface StreamResponseState { responseId?: string; responseModel?: string; diff --git a/packages/server-utils/src/ai/index.ts b/packages/server-utils/src/ai/index.ts index f082646cd454..d45ece7e300f 100644 --- a/packages/server-utils/src/ai/index.ts +++ b/packages/server-utils/src/ai/index.ts @@ -7,6 +7,7 @@ export { instrumentOpenAiClient } from './openai'; export { instrumentAnthropicAiClient } from './anthropic-ai'; export { instrumentGoogleGenAIClient } from './google-genai'; +export { instrumentMistralAiClient } from './mistral'; export { instrumentWorkersAiClient } from './workers-ai'; export { createLangChainCallbackHandler, instrumentLangChainEmbeddings } from './langchain'; export { instrumentStateGraph, instrumentStateGraphCompile, instrumentCreateReactAgent } from './langgraph'; diff --git a/packages/server-utils/src/ai/mistral/constants.ts b/packages/server-utils/src/ai/mistral/constants.ts new file mode 100644 index 000000000000..b569d4716ebe --- /dev/null +++ b/packages/server-utils/src/ai/mistral/constants.ts @@ -0,0 +1,24 @@ +import type { InstrumentedMethodRegistry } from '../core/utils'; + +export const MISTRAL_INTEGRATION_NAME = 'Mistral' as const; + +// Matches the value `inferSystemFromInstance` reports for `@langchain/mistralai`, so a call recorded +// through LangChain and a call recorded here carry the same provider. +export const MISTRAL_PROVIDER_NAME = 'mistralai' as const; + +export const MISTRAL_ORIGIN = 'auto.ai.mistralai' as const; + +// https://docs.mistral.ai/api/ +// `*.stream` methods are intrinsically streaming (no `stream: true` param), so they are flagged here. +// `parse`/`parseStream` are the structured-output entry points; they call the underlying request +// functions directly rather than `this.complete`/`this.stream`, so they need their own entries and +// cannot produce a duplicate span. +export const MISTRAL_METHOD_REGISTRY = { + 'chat.complete': { operation: 'chat' }, + 'chat.stream': { operation: 'chat', streaming: true }, + 'chat.parse': { operation: 'chat' }, + 'chat.parseStream': { operation: 'chat', streaming: true }, + 'embeddings.create': { operation: 'embeddings' }, + 'agents.complete': { operation: 'invoke_agent' }, + 'agents.stream': { operation: 'invoke_agent', streaming: true }, +} as const satisfies InstrumentedMethodRegistry; diff --git a/packages/server-utils/src/ai/mistral/index.ts b/packages/server-utils/src/ai/mistral/index.ts new file mode 100644 index 000000000000..96a335d0ebf2 --- /dev/null +++ b/packages/server-utils/src/ai/mistral/index.ts @@ -0,0 +1,211 @@ +import { + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SPAN_STATUS_ERROR, + startSpan, + startSpanManual, + stringify, +} from '@sentry/core'; +import type { Span, SpanAttributeValue } from '@sentry/core'; +import { + GEN_AI_AGENT_NAME, + GEN_AI_EMBEDDINGS_INPUT, + GEN_AI_INPUT_MESSAGES, + GEN_AI_OPERATION_NAME, + GEN_AI_PROVIDER_NAME, + GEN_AI_SYSTEM_INSTRUCTIONS, + GEN_AI_TOOL_DEFINITIONS, +} from '@sentry/conventions/attributes'; +import type { InstrumentedMethodEntry } from '../core/utils'; +import { + buildMethodPath, + extractSystemInstructions, + getGenAiSpanOp, + resolveAIRecordingOptions, + wrapPromiseWithMethods, +} from '../core/utils'; +import { GEN_AI_REQUEST_STREAM_ATTRIBUTE } from '../core/gen-ai-attributes'; +import { MISTRAL_METHOD_REGISTRY, MISTRAL_ORIGIN, MISTRAL_PROVIDER_NAME } from './constants'; +import { instrumentEventStream } from './streaming'; +import type { MistralOptions } from './types'; +import { addResponseAttributes, extractRequestParameters, getSpanName } from './utils'; + +/** + * Serialize tool definitions from request parameters, if present. + */ +function extractToolDefinitions(params: Record): string | undefined { + if (!Array.isArray(params.tools) || params.tools.length === 0) { + return undefined; + } + return stringify(params.tools); +} + +/** + * Extract request attributes from method arguments. + * + * `streaming` comes from the called method, not the request: v2 streams only through the dedicated + * `*.stream` methods, and their `stream` request field is optional, so a params-derived value would + * be missing on most streaming calls. + */ +export function extractRequestAttributes( + args: unknown[], + operationName: string, + recordInputs: boolean, + streaming: boolean, +): Record { + const attributes: Record = { + [GEN_AI_PROVIDER_NAME]: MISTRAL_PROVIDER_NAME, + [GEN_AI_OPERATION_NAME]: operationName, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: MISTRAL_ORIGIN, + [GEN_AI_REQUEST_STREAM_ATTRIBUTE]: streaming, + }; + + if (args.length > 0 && typeof args[0] === 'object' && args[0] !== null) { + const params = args[0] as Record; + + if (operationName === 'invoke_agent' && typeof params.agentId === 'string') { + attributes[GEN_AI_AGENT_NAME] = params.agentId; + } + + const tools = recordInputs ? extractToolDefinitions(params) : undefined; + if (tools) { + attributes[GEN_AI_TOOL_DEFINITIONS] = tools; + } + + Object.assign(attributes, extractRequestParameters(params)); + } + + return attributes; +} + +/** + * Record AI request inputs on the span, if recording is enabled. + */ +export function addRequestAttributes(span: Span, params: Record, operationName: string): void { + if (operationName === 'embeddings') { + const input = params.inputs; + if (input == null || (typeof input === 'string' && input.length === 0) || (Array.isArray(input) && !input.length)) { + return; + } + span.setAttribute(GEN_AI_EMBEDDINGS_INPUT, stringify(input, String)); + return; + } + + const src = 'messages' in params ? params.messages : undefined; + if (!src || (Array.isArray(src) && src.length === 0)) { + return; + } + + const { systemInstructions, filteredMessages } = extractSystemInstructions(src); + if (systemInstructions) { + span.setAttribute(GEN_AI_SYSTEM_INSTRUCTIONS, systemInstructions); + } + span.setAttribute(GEN_AI_INPUT_MESSAGES, stringify(filteredMessages)); +} + +/** + * Instrument a single Mistral SDK method with a gen_ai span. + * @see https://docs.sentry.io/platforms/javascript/guides/node/tracing/instrumentation/ai-agents-module/#manual-instrumentation + */ +function instrumentMethod( + originalMethod: (...args: T) => Promise, + instrumentedMethod: InstrumentedMethodEntry, + context: unknown, + options: MistralOptions, +): (...args: T) => Promise { + return function instrumentedCall(...args: T): Promise { + const operationName = instrumentedMethod.operation || 'unknown'; + const isStreamRequested = !!instrumentedMethod.streaming; + const requestAttributes = extractRequestAttributes(args, operationName, !!options.recordInputs, isStreamRequested); + + const params = args[0] as Record | undefined; + + const spanConfig = { + name: getSpanName(operationName, requestAttributes), + op: getGenAiSpanOp(operationName), + attributes: requestAttributes as Record, + }; + + if (isStreamRequested) { + let originalResult!: Promise; + + const instrumentedPromise = startSpanManual(spanConfig, (span: Span) => { + originalResult = originalMethod.apply(context, args); + + if (options.recordInputs && params) { + addRequestAttributes(span, params, operationName); + } + + return originalResult.then( + result => { + // Patched in place so the caller keeps the real `EventStream`; `instrumentEventStream` + // takes over ending the span once the stream is drained. + if (!instrumentEventStream(result, span, options.recordOutputs ?? false)) { + span.end(); + } + return result; + }, + error => { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + span.end(); + throw error; + }, + ); + }); + + return wrapPromiseWithMethods(originalResult, instrumentedPromise); + } + + let originalResult!: Promise; + + const instrumentedPromise = startSpan(spanConfig, (span: Span) => { + originalResult = originalMethod.apply(context, args); + + if (options.recordInputs && params) { + addRequestAttributes(span, params, operationName); + } + + return originalResult.then(result => { + addResponseAttributes(span, result, options.recordOutputs); + return result; + }); + }); + + return wrapPromiseWithMethods(originalResult, instrumentedPromise); + }; +} + +/** + * Create a deep proxy for Mistral client instrumentation. + */ +function createDeepProxy(target: T, currentPath = '', options: MistralOptions): T { + return new Proxy(target, { + get(obj: object, prop: string): unknown { + const value = (obj as Record)[prop]; + const methodPath = buildMethodPath(currentPath, String(prop)); + + const instrumentedMethod = MISTRAL_METHOD_REGISTRY[methodPath as keyof typeof MISTRAL_METHOD_REGISTRY]; + if (typeof value === 'function' && instrumentedMethod) { + return instrumentMethod(value as (...args: unknown[]) => Promise, instrumentedMethod, obj, options); + } + + if (typeof value === 'function') { + // Preserve the original `this` for uninstrumented methods (private class fields). + return value.bind(obj); + } + + if (value && typeof value === 'object') { + return createDeepProxy(value, methodPath, options); + } + + return value; + }, + }) as T; +} + +/** + * Instrument a Mistral client with Sentry tracing. + * Can be used across Node.js, Cloudflare Workers, and Vercel Edge. + */ +export function instrumentMistralAiClient(client: T, options?: MistralOptions): T { + return createDeepProxy(client, '', resolveAIRecordingOptions(options)); +} diff --git a/packages/server-utils/src/ai/mistral/streaming.ts b/packages/server-utils/src/ai/mistral/streaming.ts new file mode 100644 index 000000000000..74acc6e43b70 --- /dev/null +++ b/packages/server-utils/src/ai/mistral/streaming.ts @@ -0,0 +1,329 @@ +import type { Span } from '@sentry/core'; +import { SPAN_STATUS_ERROR } from '@sentry/core'; +import { endStreamSpan, setOutputMessagesAttribute } from '../core/utils'; +import type { MistralCompletionChunk, MistralToolCall } from './types'; +import { contentToString } from './utils'; + +/** + * State accumulated while consuming a Mistral event stream. + */ +interface StreamingState { + responseTexts: string[]; + finishReasons: string[]; + responseId: string; + responseModel: string; + promptTokens: number | undefined; + completionTokens: number | undefined; + totalTokens: number | undefined; + /** Tool calls accumulated by their delta `index`; `function.arguments` arrives fragmented. */ + toolCalls: Record; +} + +/** Which drain path owns accumulation. See {@link instrumentEventStream}. */ +type StreamConsumer = 'iterator' | 'reader'; + +type AsyncIterableStream = { [Symbol.asyncIterator]: () => AsyncIterator }; + +interface StreamReaderLike { + read: () => Promise<{ done: boolean; value?: unknown }>; + cancel?: (reason?: unknown) => Promise; +} + +interface ReadableStreamLike { + getReader?: (...args: unknown[]) => StreamReaderLike; + cancel?: (reason?: unknown) => Promise; + tee?: () => unknown; + pipeTo?: (destination: unknown, options?: unknown) => Promise; + pipeThrough?: (transform: unknown, options?: unknown) => unknown; +} + +/** Whether a value can be drained with `for await`. */ +export function isAsyncIterable(value: unknown): value is AsyncIterableStream { + return !!value && typeof (value as AsyncIterableStream)[Symbol.asyncIterator] === 'function'; +} + +function createStreamingState(): StreamingState { + return { + responseTexts: [], + finishReasons: [], + responseId: '', + responseModel: '', + promptTokens: undefined, + completionTokens: undefined, + totalTokens: undefined, + toolCalls: {}, + }; +} + +function processToolCalls(toolCalls: MistralToolCall[], state: StreamingState): void { + for (const toolCall of toolCalls) { + const index = toolCall.index; + if (index === undefined || !toolCall.function) { + continue; + } + + const existing = state.toolCalls[index]; + if (!existing) { + state.toolCalls[index] = { + ...toolCall, + function: { name: toolCall.function.name, arguments: toolCall.function.arguments ?? '' }, + }; + } else if (toolCall.function.arguments && existing.function) { + existing.function.arguments = `${existing.function.arguments}${toolCall.function.arguments}`; + } + } +} + +function processChunk(chunk: MistralCompletionChunk, state: StreamingState, recordOutputs: boolean): void { + state.responseId = chunk.id ?? state.responseId; + state.responseModel = chunk.model ?? state.responseModel; + + if (chunk.usage) { + // Input tokens stay constant across the stream; output tokens are only finalized in the last + // event, so we overwrite on every event that carries usage to guarantee the totals are set. + state.promptTokens = chunk.usage.promptTokens; + state.completionTokens = chunk.usage.completionTokens; + state.totalTokens = chunk.usage.totalTokens; + } + + for (const choice of chunk.choices ?? []) { + if (recordOutputs) { + // Deltas carry either a plain string or the same content-chunk array the non-streaming + // responses use, so both go through `contentToString`. + const content = contentToString(choice.delta?.content); + if (content) { + state.responseTexts.push(content); + } + if (choice.delta?.toolCalls) { + processToolCalls(choice.delta.toolCalls, state); + } + } + if (choice.finishReason) { + state.finishReasons.push(choice.finishReason); + } + } +} + +/** Mistral yields `CompletionEvent` objects that wrap the chunk under `data`. */ +function processEvent(event: unknown, state: StreamingState, recordOutputs: boolean): void { + const chunk = (event as { data?: MistralCompletionChunk } | undefined)?.data; + if (chunk && typeof chunk === 'object') { + processChunk(chunk, state, recordOutputs); + } +} + +async function* instrumentIterator( + iterate: () => AsyncIterator, + state: StreamingState, + recordOutputs: boolean, + claim: (consumer: StreamConsumer) => boolean, + settle: (error?: unknown) => void, +): AsyncGenerator { + try { + for await (const event of { [Symbol.asyncIterator]: iterate }) { + if (claim('iterator')) { + processEvent(event, state, recordOutputs); + } + yield event; + } + } catch (error) { + settle(error); + throw error; + } finally { + settle(); + } +} + +function wrapReader( + reader: StreamReaderLike, + state: StreamingState, + recordOutputs: boolean, + claim: (consumer: StreamConsumer) => boolean, + settle: (error?: unknown) => void, + markCancelled: () => void, +): StreamReaderLike { + // Captured before the proxy exists so the wrappers below call the real reader, not themselves. + const originalRead = reader.read; + const originalCancel = reader.cancel; + const read = (): Promise<{ done: boolean; value?: unknown }> => originalRead.call(reader); + const cancel = + typeof originalCancel === 'function' + ? (reason?: unknown): Promise => originalCancel.call(reader, reason) + : undefined; + + return new Proxy(reader, { + get(target: StreamReaderLike, prop: string | symbol): unknown { + if (prop === 'read') { + return async (): Promise<{ done: boolean; value?: unknown }> => { + try { + const result = await read(); + if (result.done) { + settle(); + } else if (claim('reader')) { + processEvent(result.value, state, recordOutputs); + } + return result; + } catch (error) { + settle(error); + throw error; + } + }; + } + + if (prop === 'cancel' && cancel) { + return async (reason?: unknown): Promise => { + markCancelled(); + try { + return await cancel(reason); + } finally { + settle(); + } + }; + } + + const value = Reflect.get(target, prop, target) as unknown; + return typeof value === 'function' ? value.bind(target) : value; + }, + }); +} + +/** + * A stream that pulls through `readable`'s instrumented reader. + * + * `tee`, `pipeTo` and `pipeThrough` acquire their reader through internal slots rather than by + * calling the public `getReader`, so patching that method alone leaves them uninstrumented: the + * chunks bypass accumulation and the span is never ended. Handing them this stream instead routes + * them back through the wrapped reader, so there is still exactly one accumulating consumer. + * + * Same idea as `monitorStream` in `@sentry/deno`, with two deliberate differences: chunks are pulled + * on demand rather than drained in a `start` loop, so backpressure still reaches the source, and + * `cancel` is forwarded (the hole #24054 fixed there) so the source stops producing when the + * consumer disconnects. + */ +function instrumentedSource(readable: ReadableStreamLike): ReadableStream { + // The patched `getReader`, so reads are accumulated and the span is ended by the shared logic. + const reader = readable.getReader!(); + + return new ReadableStream({ + async pull(controller) { + const { done, value } = await reader.read(); + if (done) { + controller.close(); + return; + } + controller.enqueue(value); + }, + async cancel(reason) { + await reader.cancel?.(reason); + }, + }); +} + +/** + * Instrument a Mistral event stream in place: accumulate response attributes as it is drained and + * end `span` when it finishes. + * + * The stream is patched rather than replaced because `EventStream` extends `ReadableStream`, so + * handing back a bare async generator would drop `getReader`, `tee`, `pipeTo` and the rest of the + * `ReadableStream` API the caller is entitled to. + * + * Both `for await` and `getReader()` are valid ways to drain a `ReadableStream`, and the SDK's + * iterator polyfill reads through `getReader()`, so both are wrapped. The first path to see a chunk + * claims accumulation and the other stays a pass-through, which keeps a chunk from being counted + * twice when one path drives the other. + * + * Returns `false` for a value that is not a stream, leaving it untouched. + */ +export function instrumentEventStream(stream: unknown, span: Span, recordOutputs: boolean): boolean { + if (!isAsyncIterable(stream)) { + return false; + } + + const state = createStreamingState(); + let consumer: StreamConsumer | undefined; + let settled = false; + let cancelled = false; + + const claim = (candidate: StreamConsumer): boolean => { + consumer ??= candidate; + return consumer === candidate; + }; + + // Set synchronously when the caller asks to cancel, before the underlying cancel is awaited. + // Cancelling can reject an in-flight `read()` (it aborts the HTTP body the stream reads from), and + // that rejection would otherwise reach `settle` first and record a deliberate abort as a failure. + const markCancelled = (): void => { + cancelled = true; + }; + + const settle = (error?: unknown): void => { + if (settled) { + return; + } + settled = true; + if (error !== undefined && !cancelled) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + } + + const toolCalls = Object.values(state.toolCalls); + + if (recordOutputs) { + // Set the authoritative `gen_ai.output.messages` alongside the deprecated response attributes + // `endStreamSpan` writes, so tool calls survive Relay's lossy migration. A stream is a single + // assistant turn, so the accumulated fragments make up one message. + setOutputMessagesAttribute(span, { + responseText: state.responseTexts.join(''), + toolCalls, + finishReason: state.finishReasons[0], + }); + } + + endStreamSpan(span, { ...state, toolCalls }, recordOutputs); + }; + + const iterate = stream[Symbol.asyncIterator].bind(stream); + const instrumented = instrumentIterator(iterate, state, recordOutputs, claim, settle); + stream[Symbol.asyncIterator] = () => instrumented; + + const readable = stream as ReadableStreamLike; + + if (typeof readable.getReader === 'function') { + const getReader = readable.getReader.bind(readable); + readable.getReader = (...args: unknown[]) => + wrapReader(getReader(...args), state, recordOutputs, claim, settle, markCancelled); + } + + if (typeof readable.cancel === 'function') { + const cancel = readable.cancel.bind(readable); + readable.cancel = async (reason?: unknown): Promise => { + markCancelled(); + try { + return await cancel(reason); + } finally { + settle(); + } + }; + } + + // Only patched when `getReader` is present, since that is what `instrumentedSource` pulls through. + if (typeof readable.getReader === 'function') { + if (typeof readable.tee === 'function') { + readable.tee = () => instrumentedSource(readable).tee(); + } + + if (typeof readable.pipeTo === 'function') { + readable.pipeTo = (destination: unknown, options?: unknown) => + instrumentedSource(readable).pipeTo(destination as WritableStream, options as StreamPipeOptions); + } + + if (typeof readable.pipeThrough === 'function') { + readable.pipeThrough = (transform: unknown, options?: unknown) => + instrumentedSource(readable).pipeThrough( + transform as ReadableWritablePair, + options as StreamPipeOptions, + ); + } + } + + return true; +} diff --git a/packages/server-utils/src/ai/mistral/types.ts b/packages/server-utils/src/ai/mistral/types.ts new file mode 100644 index 000000000000..840830b8e120 --- /dev/null +++ b/packages/server-utils/src/ai/mistral/types.ts @@ -0,0 +1,38 @@ +import type { GenAiOptions } from '../core/utils'; + +/** Options for the Mistral integration. */ +export type MistralOptions = GenAiOptions; + +/** + * A tool call as it appears in a response/stream delta. During streaming the `function.arguments` + * string arrives fragmented and is accumulated by `index`. + */ +export interface MistralToolCall { + id?: string; + type?: string; + index?: number; + function?: { + name?: string; + arguments?: string; + }; +} + +/** + * A single streaming chunk. Field names are camelCase because the SDK deserializes the snake_case + * wire payload into typed objects before instrumentation sees them. Streaming APIs actually yield + * `CompletionEvent` objects that wrap this under `data`. + * @see https://docs.mistral.ai/api/#tag/chat/operation/stream_chat + */ +export interface MistralCompletionChunk { + id: string; + model: string; + choices?: Array<{ + delta?: { content?: string | Array | null; toolCalls?: MistralToolCall[] | null }; + finishReason?: string | null; + }>; + usage?: { + promptTokens?: number; + completionTokens?: number; + totalTokens?: number; + }; +} diff --git a/packages/server-utils/src/ai/mistral/utils.ts b/packages/server-utils/src/ai/mistral/utils.ts new file mode 100644 index 000000000000..e1786582a2ef --- /dev/null +++ b/packages/server-utils/src/ai/mistral/utils.ts @@ -0,0 +1,139 @@ +/* eslint-disable typescript-eslint/no-deprecated */ +import type { Span, SpanAttributeValue } from '@sentry/core'; +import type { GenAiOutputMessage } from '../core/utils'; +import { setOutputMessagesAttribute } from '../core/utils'; +import { + GEN_AI_REQUEST_FREQUENCY_PENALTY, + GEN_AI_REQUEST_MAX_TOKENS, + GEN_AI_REQUEST_MODEL, + GEN_AI_REQUEST_PRESENCE_PENALTY, + GEN_AI_REQUEST_SEED, + GEN_AI_REQUEST_TEMPERATURE, + GEN_AI_REQUEST_TOP_P, + GEN_AI_RESPONSE_FINISH_REASONS, + GEN_AI_RESPONSE_ID, + GEN_AI_RESPONSE_MODEL, + GEN_AI_RESPONSE_TEXT, + GEN_AI_RESPONSE_TOOL_CALLS, + GEN_AI_USAGE_INPUT_TOKENS, + GEN_AI_USAGE_OUTPUT_TOKENS, + GEN_AI_USAGE_TOTAL_TOKENS, +} from '@sentry/conventions/attributes'; + +/** + * Turn a Mistral message content (string or content-chunk array) into a plain string. + */ +export function contentToString(content: unknown): string { + if (typeof content === 'string') { + return content; + } + if (Array.isArray(content)) { + return content + .map(part => + part && typeof part === 'object' && typeof (part as { text?: unknown }).text === 'string' + ? (part as { text: string }).text + : '', + ) + .join(''); + } + return ''; +} + +/** + * Build the span name for an instrumented Mistral call. Agent ids and a missing model are left out + * to keep the name low cardinality. + */ +export function getSpanName(operationName: string, attributes: Record): string { + const model = attributes[GEN_AI_REQUEST_MODEL]; + + if (operationName === 'invoke_agent' || typeof model !== 'string') { + return operationName; + } + + return `${operationName} ${model}`; +} + +/** + * Extract request parameters. Mistral request fields are camelCase. + */ +export function extractRequestParameters(params: Record): Record { + const attributes: Record = {}; + + if (params.model != null) attributes[GEN_AI_REQUEST_MODEL] = params.model; + if ('temperature' in params) attributes[GEN_AI_REQUEST_TEMPERATURE] = params.temperature; + if ('topP' in params) attributes[GEN_AI_REQUEST_TOP_P] = params.topP; + if ('maxTokens' in params) attributes[GEN_AI_REQUEST_MAX_TOKENS] = params.maxTokens; + if ('frequencyPenalty' in params) attributes[GEN_AI_REQUEST_FREQUENCY_PENALTY] = params.frequencyPenalty; + if ('presencePenalty' in params) attributes[GEN_AI_REQUEST_PRESENCE_PENALTY] = params.presencePenalty; + if ('randomSeed' in params) attributes[GEN_AI_REQUEST_SEED] = params.randomSeed; + + return attributes; +} + +/** + * Add response attributes to a span using duck-typing. Mistral responses are camelCase + * (`choices[].message`, `usage.promptTokens`), matching the SDK's deserialized objects. + */ +export function addResponseAttributes(span: Span, result: unknown, recordOutputs?: boolean): void { + if (!result || typeof result !== 'object') return; + + const response = result as Record; + const attrs: Record = {}; + + if (typeof response.id === 'string') { + attrs[GEN_AI_RESPONSE_ID] = response.id; + } + + if (typeof response.model === 'string') { + attrs[GEN_AI_RESPONSE_MODEL] = response.model; + } + + if (response.usage && typeof response.usage === 'object') { + const usage = response.usage as Record; + if (typeof usage.promptTokens === 'number') attrs[GEN_AI_USAGE_INPUT_TOKENS] = usage.promptTokens; + if (typeof usage.completionTokens === 'number') attrs[GEN_AI_USAGE_OUTPUT_TOKENS] = usage.completionTokens; + if (typeof usage.totalTokens === 'number') attrs[GEN_AI_USAGE_TOTAL_TOKENS] = usage.totalTokens; + } + + let outputMessages: GenAiOutputMessage[] = []; + + if (Array.isArray(response.choices)) { + const choices = response.choices as Array>; + + const finishReasons = choices + .map(choice => choice.finishReason) + .filter((reason): reason is string => typeof reason === 'string'); + if (finishReasons.length > 0) { + attrs[GEN_AI_RESPONSE_FINISH_REASONS] = JSON.stringify(finishReasons); + } + + if (recordOutputs) { + // One entry per choice: Mistral can return several when `n` > 1, and both attributes are + // specified as arrays of messages rather than one merged blob. + outputMessages = choices.map(choice => { + const message = choice.message as Record | undefined; + return { + responseText: contentToString(message?.content), + toolCalls: Array.isArray(message?.toolCalls) ? message.toolCalls : undefined, + finishReason: typeof choice.finishReason === 'string' ? choice.finishReason : undefined, + }; + }); + + const responseTexts = outputMessages.map(message => message.responseText).filter(Boolean); + if (responseTexts.length > 0) { + attrs[GEN_AI_RESPONSE_TEXT] = JSON.stringify(responseTexts); + } + + const toolCalls = outputMessages.flatMap(message => message.toolCalls ?? []); + if (toolCalls.length > 0) { + attrs[GEN_AI_RESPONSE_TOOL_CALLS] = JSON.stringify(toolCalls); + } + } + } + + span.setAttributes(attrs); + + if (recordOutputs) { + setOutputMessagesAttribute(span, outputMessages); + } +} diff --git a/packages/server-utils/src/ai/workers-ai/utils.ts b/packages/server-utils/src/ai/workers-ai/utils.ts index b174e9361161..33908290be72 100644 --- a/packages/server-utils/src/ai/workers-ai/utils.ts +++ b/packages/server-utils/src/ai/workers-ai/utils.ts @@ -3,7 +3,6 @@ import { GEN_AI_EMBEDDINGS_INPUT, GEN_AI_INPUT_MESSAGES, GEN_AI_OPERATION_NAME, - GEN_AI_OUTPUT_MESSAGES, GEN_AI_PROVIDER_NAME, GEN_AI_REQUEST_FREQUENCY_PENALTY, GEN_AI_REQUEST_MAX_TOKENS, @@ -20,7 +19,9 @@ import { GEN_AI_CHAT, GEN_AI_EMBEDDINGS } from '@sentry/conventions/op'; import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, stringify } from '@sentry/core'; import type { Span, SpanAttributeValue } from '@sentry/core'; import { GEN_AI_REQUEST_STREAM_ATTRIBUTE } from '../core/gen-ai-attributes'; -import { extractSystemInstructions, setTokenUsageAttributes } from '../core/utils'; +import { extractSystemInstructions, setOutputMessagesAttribute, setTokenUsageAttributes } from '../core/utils'; +// Re-exported so `workers-ai/streaming.ts` keeps importing it from this module. +export { setOutputMessagesAttribute }; import { WORKERS_AI_ORIGIN, WORKERS_AI_PROVIDER_NAME } from './constants'; import type { WorkersAiInput, WorkersAiOutput } from './types'; @@ -128,55 +129,6 @@ export function addRequestAttributes(span: Span, inputs: unknown, operationName: span.setAttribute(GEN_AI_INPUT_MESSAGES, stringify(filteredMessages)); } -/** - * Build the `gen_ai.output.messages` value (a single assistant message with text and/or - * tool-call parts) from the response text and tool calls. - * - * We set this in addition to the deprecated `gen_ai.response.text` / `gen_ai.response.tool_calls` - * attributes because Sentry's product reads the model output from `gen_ai.output.messages` first. - * Relay migrates `gen_ai.response.text` into `gen_ai.output.messages`, but the tool-calls half of - * that migration is lossy — so tool-call turns would otherwise render an empty Output. Emitting the - * normalized message here (mirroring the Vercel AI integration) keeps tool calls visible. - */ -export function setOutputMessagesAttribute( - span: Span, - { responseText, toolCalls }: { responseText?: string; toolCalls?: unknown[] }, -): void { - const parts: Array> = []; - - if (typeof responseText === 'string' && responseText.length > 0) { - parts.push({ type: 'text', content: responseText }); - } - - if (Array.isArray(toolCalls)) { - for (const toolCall of toolCalls) { - if (!toolCall || typeof toolCall !== 'object') { - continue; - } - const call = toolCall as { - id?: unknown; - function?: { name?: unknown; arguments?: unknown }; - name?: unknown; - arguments?: unknown; - }; - // Normalize both the OpenAI-compatible shape (name/arguments nested under `function`) - // and the native Workers AI shape (name/arguments at the top level). - const name = call.function?.name ?? call.name; - const args = call.function?.arguments ?? call.arguments; - parts.push({ - type: 'tool_call', - id: call.id, - name, - arguments: stringify(args ?? {}, String), - }); - } - } - - if (parts.length > 0) { - span.setAttribute(GEN_AI_OUTPUT_MESSAGES, JSON.stringify([{ role: 'assistant', parts }])); - } -} - /** * Record the response attributes (token usage, response text, tool calls) on the span. */ diff --git a/packages/server-utils/src/index.ts b/packages/server-utils/src/index.ts index a2369110ba68..57fa3f913cda 100644 --- a/packages/server-utils/src/index.ts +++ b/packages/server-utils/src/index.ts @@ -49,6 +49,7 @@ export { SentryMastraExporter } from './ai/mastra'; export { lruMemoizerIntegration } from './integrations/lru-memoizer'; export { mongoIntegration } from './integrations/mongodb'; export { mongooseIntegration } from './integrations/mongoose'; +export { mistralAIIntegration } from './integrations/mistral'; export { mysqlIntegration } from './integrations/mysql'; export { mysql2Integration } from './integrations/mysql2'; export { openAIIntegration } from './integrations/openai'; diff --git a/packages/server-utils/src/integrations/index.ts b/packages/server-utils/src/integrations/index.ts index 89386654397d..6e319bfc5e2b 100644 --- a/packages/server-utils/src/integrations/index.ts +++ b/packages/server-utils/src/integrations/index.ts @@ -20,6 +20,7 @@ import { vercelAIIntegration } from './vercel-ai'; import { openAIIntegration } from './openai'; import { anthropicAIIntegration } from './anthropic'; import { googleGenAIIntegration } from './google-genai'; +import { mistralAIIntegration } from './mistral'; import { postgresJsIntegration } from './postgres-js'; import { firebaseIntegration } from './firebase'; import { expressIntegration } from './express'; @@ -57,6 +58,7 @@ export function getTracingIntegrations(): Integration[] { openAIIntegration(), anthropicAIIntegration(), googleGenAIIntegration(), + mistralAIIntegration(), postgresJsIntegration(), firebaseIntegration(), ]; diff --git a/packages/server-utils/src/integrations/langchain.ts b/packages/server-utils/src/integrations/langchain.ts index b6a43579de0f..52fb4a438a2c 100644 --- a/packages/server-utils/src/integrations/langchain.ts +++ b/packages/server-utils/src/integrations/langchain.ts @@ -8,6 +8,7 @@ import { LANGCHAIN_INTEGRATION_NAME } from '../ai/langchain/constants'; import { _INTERNAL_getLangChainEmbeddingsSpanOptions } from '../ai/langchain/embeddings'; import type { LangChainOptions } from '../ai/langchain/types'; import { _INTERNAL_mergeLangChainCallbackHandler } from '../ai/langchain/utils'; +import { MISTRAL_INTEGRATION_NAME } from '../ai/mistral/constants'; import { OPENAI_INTEGRATION_NAME } from '../ai/openai/constants'; import { CHANNELS } from '../orchestrion/channels'; import { langchainEmbeddingsChannels } from '../orchestrion/config/langchain'; @@ -21,7 +22,12 @@ const INTEGRATION_NAME = LANGCHAIN_INTEGRATION_NAME; // LangChain drives the underlying AI provider SDKs itself, so while it's active those providers must // not also instrument, or every call would produce two spans (mirrors the OTel path's skip list). -const SKIPPED_PROVIDERS = [OPENAI_INTEGRATION_NAME, ANTHROPIC_AI_INTEGRATION_NAME, GOOGLE_GENAI_INTEGRATION_NAME]; +const SKIPPED_PROVIDERS = [ + OPENAI_INTEGRATION_NAME, + ANTHROPIC_AI_INTEGRATION_NAME, + GOOGLE_GENAI_INTEGRATION_NAME, + MISTRAL_INTEGRATION_NAME, +]; // The chat-model channels carry the live args array of `invoke(input, options)` / `_streamIterator(input, options)`. interface RunnableChannelContext { diff --git a/packages/server-utils/src/integrations/mistral.ts b/packages/server-utils/src/integrations/mistral.ts new file mode 100644 index 000000000000..7d53c2047cf8 --- /dev/null +++ b/packages/server-utils/src/integrations/mistral.ts @@ -0,0 +1,107 @@ +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import type { IntegrationFn, Span, SpanAttributeValue } from '@sentry/core'; +import { + _INTERNAL_shouldSkipAiProviderWrapping, + defineIntegration, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + startInactiveSpan, +} from '@sentry/core'; +import { getGenAiSpanOp, resolveAIRecordingOptions } from '../ai/core/utils'; +import { addRequestAttributes, extractRequestAttributes } from '../ai/mistral'; +import { MISTRAL_INTEGRATION_NAME, MISTRAL_ORIGIN } from '../ai/mistral/constants'; +import { instrumentEventStream } from '../ai/mistral/streaming'; +import type { MistralOptions } from '../ai/mistral/types'; +import { addResponseAttributes, getSpanName } from '../ai/mistral/utils'; +import { CHANNELS } from '../orchestrion/channels'; +import { mistralModuleNames } from '../orchestrion/config/mistral'; +import { invokeOrchestrionInstrumentation } from '../orchestrion/instrumentation'; +import { bindTracingChannelToSpan } from '../tracing-channel'; + +const INTEGRATION_NAME = MISTRAL_INTEGRATION_NAME; + +// Each instrumented channel maps to the gen_ai operation its span reports. Streaming methods publish +// on their own channel, so the span knows it is a stream before the result exists. +const INSTRUMENTED_CHANNELS = [ + { channel: CHANNELS.MISTRAL_CHAT, operation: 'chat', streaming: false }, + { channel: CHANNELS.MISTRAL_CHAT_STREAM, operation: 'chat', streaming: true }, + { channel: CHANNELS.MISTRAL_EMBEDDINGS, operation: 'embeddings', streaming: false }, + { channel: CHANNELS.MISTRAL_AGENTS, operation: 'invoke_agent', streaming: false }, + { channel: CHANNELS.MISTRAL_AGENTS_STREAM, operation: 'invoke_agent', streaming: true }, +] as const; + +/** + * The context orchestrion shares across the tracing-channel lifecycle hooks: `arguments` is the live + * args array passed to the SDK method, and Node's `tracingChannel` attaches `result` when it settles. + */ +interface MistralChannelContext { + arguments: unknown[]; + result?: unknown; +} + +const _mistralAIIntegration = ((options: MistralOptions = {}) => { + return { + name: INTEGRATION_NAME, + setup(client) { + invokeOrchestrionInstrumentation(client, mistralModuleNames, instrumentMistral, [options]); + }, + }; +}) satisfies IntegrationFn; + +function instrumentMistral(options: MistralOptions): void { + for (const { channel, operation, streaming } of INSTRUMENTED_CHANNELS) { + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(channel), + data => createGenAiSpan(data, operation, streaming, options), + { + beforeSpanEnd: (span, data) => { + addResponseAttributes(span, data.result, resolveAIRecordingOptions(options).recordOutputs); + }, + // Streaming: the result is an `EventStream` consumed later, so instrument it and let it end the span. + deferSpanEnd: ({ span, data }) => + streaming && instrumentEventStream(data.result, span, resolveAIRecordingOptions(options).recordOutputs), + }, + ); + } +} + +/** + * Build the span for an instrumented Mistral call. + * Returning `undefined` opts the payload out so no span is opened. + */ +function createGenAiSpan( + data: MistralChannelContext, + operation: string, + streaming: boolean, + options: MistralOptions, +): Span | undefined { + // When another provider (e.g. LangChain) is driving the SDK, it records the spans itself and marks + // this provider as skipped; skip here to avoid double spans. + if (_INTERNAL_shouldSkipAiProviderWrapping(INTEGRATION_NAME)) { + return undefined; + } + + const args = data.arguments ?? []; + const params = args[0] as Record | undefined; + + const { recordInputs } = resolveAIRecordingOptions(options); + + const attributes = extractRequestAttributes(args, operation, recordInputs, streaming); + attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] = MISTRAL_ORIGIN; + + const span = startInactiveSpan({ + name: getSpanName(operation, attributes), + op: getGenAiSpanOp(operation), + attributes: attributes as Record, + }); + + if (recordInputs && params) { + addRequestAttributes(span, params, operation); + } + + return span; +} + +/** + * An integration to instrument @mistralai/mistrailai. + */ +export const mistralAIIntegration = defineIntegration(_mistralAIIntegration); diff --git a/packages/server-utils/src/orchestrion/channels.ts b/packages/server-utils/src/orchestrion/channels.ts index bc6d1b2524b9..65ab3e56899e 100644 --- a/packages/server-utils/src/orchestrion/channels.ts +++ b/packages/server-utils/src/orchestrion/channels.ts @@ -16,6 +16,7 @@ import { langchainChannels } from './config/langchain'; import { langgraphChannels } from './config/langgraph'; import { lruMemoizerChannels } from './config/lru-memoizer'; import { mastraChannels } from './config/mastra'; +import { mistralChannels } from './config/mistral'; import { mongodbChannels } from './config/mongodb'; import { mongooseChannels } from './config/mongoose'; import { mysql2Channels } from './config/mysql2'; @@ -64,6 +65,7 @@ export const CHANNELS = { ...langgraphChannels, ...lruMemoizerChannels, ...mastraChannels, + ...mistralChannels, ...mongodbChannels, ...mongooseChannels, ...mysql2Channels, diff --git a/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts b/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts index d293b9d22baf..73d7c164cfe7 100644 --- a/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts +++ b/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts @@ -29,6 +29,7 @@ export const CHANNEL_INTEGRATION_DEFINITIONS = [ { exportName: 'openAIIntegration', modules: ['openai'] }, { exportName: 'anthropicAIIntegration', modules: ['@anthropic-ai/sdk'] }, { exportName: 'googleGenAIIntegration', modules: ['@google/genai'] }, + { exportName: 'mistralAIIntegration', modules: ['@mistralai/mistralai'] }, { exportName: 'vercelAIIntegration', modules: ['ai'] }, { exportName: 'langChainIntegration', diff --git a/packages/server-utils/src/orchestrion/config/index.ts b/packages/server-utils/src/orchestrion/config/index.ts index 1dd2b6863418..723c5857b8f1 100644 --- a/packages/server-utils/src/orchestrion/config/index.ts +++ b/packages/server-utils/src/orchestrion/config/index.ts @@ -20,6 +20,7 @@ import { langchainConfig } from './langchain'; import { langgraphConfig } from './langgraph'; import { lruMemoizerConfig } from './lru-memoizer'; import { mastraConfig } from './mastra'; +import { mistralConfig } from './mistral'; import { mongodbConfig } from './mongodb'; import { mongooseConfig } from './mongoose'; import { mysql2Config } from './mysql2'; @@ -67,6 +68,7 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ...langgraphConfig, ...lruMemoizerConfig, ...mastraConfig, + ...mistralConfig, ...mongodbConfig, ...mongooseConfig, ...mysql2Config, diff --git a/packages/server-utils/src/orchestrion/config/mistral.ts b/packages/server-utils/src/orchestrion/config/mistral.ts new file mode 100644 index 000000000000..01dd2b7320ff --- /dev/null +++ b/packages/server-utils/src/orchestrion/config/mistral.ts @@ -0,0 +1,61 @@ +import type { InstrumentationConfig } from '../apmTypes'; + +import { getModuleNames } from './module-names'; + +// `@mistralai/mistralai` v2 is ESM-only, so there is a single built file per resource (no dual CJS/ESM +// variants). Each SDK resource class exposes async methods that return a thenable, so `kind: 'Auto'` +// resolves to `wrapPromise`; the `.stream` methods resolve to an async-iterable `EventStream`. +const MODULE = { name: '@mistralai/mistralai', versionRange: '>=2.0.0 <3' } as const; + +const CHAT_FILE = { ...MODULE, filePath: 'esm/sdk/chat.js' } as const; +const AGENTS_FILE = { ...MODULE, filePath: 'esm/sdk/agents.js' } as const; + +// Streaming methods get their own channel so both the span attributes and the stream handling can be +// driven by the method that fired, instead of duck-typing the resolved value. +export const mistralConfig = [ + { + channelName: 'chat', + module: CHAT_FILE, + functionQuery: { className: 'Chat', methodName: 'complete', kind: 'Auto' as const }, + }, + { + channelName: 'chat', + module: CHAT_FILE, + functionQuery: { className: 'Chat', methodName: 'parse', kind: 'Auto' as const }, + }, + { + channelName: 'chat-stream', + module: CHAT_FILE, + functionQuery: { className: 'Chat', methodName: 'stream', kind: 'Auto' as const }, + }, + { + channelName: 'chat-stream', + module: CHAT_FILE, + functionQuery: { className: 'Chat', methodName: 'parseStream', kind: 'Auto' as const }, + }, + { + channelName: 'embeddings', + module: { ...MODULE, filePath: 'esm/sdk/embeddings.js' }, + functionQuery: { className: 'Embeddings', methodName: 'create', kind: 'Auto' as const }, + }, + { + channelName: 'agents', + module: AGENTS_FILE, + functionQuery: { className: 'Agents', methodName: 'complete', kind: 'Auto' as const }, + }, + { + channelName: 'agents-stream', + module: AGENTS_FILE, + functionQuery: { className: 'Agents', methodName: 'stream', kind: 'Auto' as const }, + }, +] satisfies InstrumentationConfig[]; + +export const mistralModuleNames = getModuleNames(mistralConfig); + +export const mistralChannels = { + MISTRAL_CHAT: 'orchestrion:@mistralai/mistralai:chat', + MISTRAL_CHAT_STREAM: 'orchestrion:@mistralai/mistralai:chat-stream', + MISTRAL_EMBEDDINGS: 'orchestrion:@mistralai/mistralai:embeddings', + MISTRAL_AGENTS: 'orchestrion:@mistralai/mistralai:agents', + MISTRAL_AGENTS_STREAM: 'orchestrion:@mistralai/mistralai:agents-stream', +} as const; diff --git a/packages/server-utils/test/ai/lib/tracing/mistral.test.ts b/packages/server-utils/test/ai/lib/tracing/mistral.test.ts new file mode 100644 index 000000000000..89ee08ea34cc --- /dev/null +++ b/packages/server-utils/test/ai/lib/tracing/mistral.test.ts @@ -0,0 +1,639 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getMainCarrier, setCurrentClient, spanToStaticSpanJSON } from '@sentry/core'; +import type { Span } from '@sentry/core'; +import { + GEN_AI_AGENT_NAME, + GEN_AI_OPERATION_NAME, + GEN_AI_PROVIDER_NAME, + GEN_AI_RESPONSE_STREAMING, + GEN_AI_OUTPUT_MESSAGES, + GEN_AI_RESPONSE_TEXT, + GEN_AI_RESPONSE_TOOL_CALLS, + GEN_AI_USAGE_TOTAL_TOKENS, +} from '@sentry/conventions/attributes'; +import { instrumentMistralAiClient } from '../../../../src/ai/mistral'; +import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client'; + +const GEN_AI_REQUEST_STREAM = 'gen_ai.request.stream'; + +/** One `CompletionEvent`, as the SDK yields it: the chunk lives under `data`. */ +function completionEvent(data: Record): { data: Record } { + return { data }; +} + +const STREAM_EVENTS = [ + completionEvent({ + id: 'chatcmpl-stream', + model: 'mistral-large-latest', + choices: [{ delta: { content: 'Hello ' }, finishReason: null }], + }), + completionEvent({ + id: 'chatcmpl-stream', + model: 'mistral-large-latest', + // Structured content arrives as an array of chunks, same as the non-streaming shape. + choices: [ + { + delta: { + content: [ + { type: 'text', text: 'from ' }, + { type: 'text', text: 'Mistral' }, + ], + }, + }, + ], + }), + completionEvent({ + id: 'chatcmpl-stream', + model: 'mistral-large-latest', + choices: [{ delta: {}, finishReason: 'stop' }], + usage: { promptTokens: 12, completionTokens: 18, totalTokens: 30 }, + }), +]; + +/** + * Stand-in for the SDK's `EventStream`, which extends `ReadableStream`. The tests need both drain + * paths (`for await` and `getReader()`) to behave like the real thing. + */ +function eventStream(events: unknown[] = STREAM_EVENTS): ReadableStream { + return new ReadableStream({ + start(controller) { + for (const event of events) { + controller.enqueue(event); + } + controller.close(); + }, + }); +} + +describe('instrumentMistralAiClient', () => { + beforeEach(() => { + getMainCarrier().__SENTRY__ = undefined; + }); + + afterEach(() => { + getMainCarrier().__SENTRY__ = undefined; + }); + + function setupClient(traceLifecycle: 'static' | 'stream'): Span[] { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, + traceLifecycle, + }), + ); + setCurrentClient(client); + client.init(); + + const endedSpans: Span[] = []; + client.on('spanEnd', span => endedSpans.push(span)); + return endedSpans; + } + + function fakeClient(overrides: Record = {}): any { + return { + chat: { + complete: vi.fn().mockResolvedValue({ + id: 'chatcmpl-mock', + model: 'mistral-small-latest', + choices: [{ message: { content: 'Hello from Mistral mock!' }, finishReason: 'stop' }], + usage: { promptTokens: 10, completionTokens: 15, totalTokens: 25 }, + }), + parse: vi.fn().mockResolvedValue({ + id: 'chatcmpl-parsed', + model: 'mistral-small-latest', + choices: [{ message: { content: '{"city":"Paris"}' }, finishReason: 'stop' }], + }), + stream: vi.fn().mockResolvedValue(eventStream()), + parseStream: vi.fn().mockResolvedValue(eventStream()), + }, + embeddings: { + create: vi.fn().mockResolvedValue({ id: 'embd-mock', usage: { promptTokens: 8, totalTokens: 8 } }), + }, + agents: { + complete: vi.fn().mockResolvedValue({ id: 'agentcmpl-mock', choices: [] }), + stream: vi.fn().mockResolvedValue(eventStream()), + }, + ...overrides, + }; + } + + describe('span names', () => { + it('names a chat span `{operation} {model}`', async () => { + const endedSpans = setupClient('stream'); + const client = instrumentMistralAiClient(fakeClient()); + + await client.chat.complete({ model: 'mistral-small-latest', messages: [] }); + + expect(spanToStaticSpanJSON(endedSpans[0]!).description).toBe('chat mistral-small-latest'); + }); + + it('names a chat span with a missing model `{operation}`', async () => { + const endedSpans = setupClient('static'); + const client = instrumentMistralAiClient(fakeClient()); + + await client.chat.complete({ messages: [] }); + + expect(spanToStaticSpanJSON(endedSpans[0]!).description).toBe('chat'); + }); + + it('leaves the agent id out of the span name', async () => { + const endedSpans = setupClient('static'); + const client = instrumentMistralAiClient(fakeClient()); + + await client.agents.complete({ agentId: 'ag_01abcdef', messages: [] }); + + const span = spanToStaticSpanJSON(endedSpans[0]!); + expect(span.description).toBe('invoke_agent'); + expect(span.data[GEN_AI_AGENT_NAME]).toBe('ag_01abcdef'); + }); + }); + + describe('gen_ai.request.stream', () => { + it('is false for `complete`, even when the request carries `stream: true`', async () => { + const endedSpans = setupClient('stream'); + const client = instrumentMistralAiClient(fakeClient()); + + await client.chat.complete({ model: 'mistral-small-latest', messages: [], stream: true }); + + expect(spanToStaticSpanJSON(endedSpans[0]!).data[GEN_AI_REQUEST_STREAM]).toBe(false); + }); + + it('is true for `stream`, which takes no `stream` request field', async () => { + const endedSpans = setupClient('stream'); + const client = instrumentMistralAiClient(fakeClient()); + + const stream = await client.chat.stream({ model: 'mistral-large-latest', messages: [] }); + for await (const _ of stream) { + void _; + } + + expect(spanToStaticSpanJSON(endedSpans[0]!).data[GEN_AI_REQUEST_STREAM]).toBe(true); + }); + }); + + describe('streaming', () => { + it('hands back the original stream object rather than a bare generator', async () => { + setupClient('stream'); + const client = instrumentMistralAiClient(fakeClient()); + + const stream = await client.chat.stream({ model: 'mistral-large-latest', messages: [] }); + + expect(stream).toBeInstanceOf(ReadableStream); + expect(typeof stream.getReader).toBe('function'); + expect(typeof stream.tee).toBe('function'); + }); + + it('accumulates streamed attributes when drained with `for await`', async () => { + const endedSpans = setupClient('stream'); + const client = instrumentMistralAiClient(fakeClient(), { recordOutputs: true }); + + const stream = await client.chat.stream({ model: 'mistral-large-latest', messages: [] }); + const seen: unknown[] = []; + for await (const event of stream) { + seen.push(event); + } + + expect(seen).toHaveLength(3); + const span = spanToStaticSpanJSON(endedSpans[0]!); + expect(span.data[GEN_AI_RESPONSE_STREAMING]).toBe(true); + expect(span.data[GEN_AI_USAGE_TOTAL_TOKENS]).toBe(30); + // The array-shaped delta contributes its text instead of being dropped. + expect(span.data[GEN_AI_RESPONSE_TEXT]).toBe('Hello from Mistral'); + }); + + it('ends the span when the stream is drained with `getReader()`', async () => { + const endedSpans = setupClient('stream'); + const client = instrumentMistralAiClient(fakeClient(), { recordOutputs: true }); + + const stream = await client.chat.stream({ model: 'mistral-large-latest', messages: [] }); + const reader = stream.getReader(); + for (;;) { + const { done } = await reader.read(); + if (done) break; + } + + expect(endedSpans).toHaveLength(1); + const span = spanToStaticSpanJSON(endedSpans[0]!); + expect(span.data[GEN_AI_RESPONSE_STREAMING]).toBe(true); + expect(span.data[GEN_AI_USAGE_TOTAL_TOKENS]).toBe(30); + expect(span.data[GEN_AI_RESPONSE_TEXT]).toBe('Hello from Mistral'); + }); + + it('ends the span when the stream is cancelled instead of drained', async () => { + const endedSpans = setupClient('stream'); + const client = instrumentMistralAiClient(fakeClient()); + + const stream = await client.chat.stream({ model: 'mistral-large-latest', messages: [] }); + await stream.cancel(); + + expect(endedSpans).toHaveLength(1); + }); + + it('keeps the span ok when cancelling rejects an in-flight read', async () => { + const endedSpans = setupClient('stream'); + + // Mirrors a real HTTP body: cancelling aborts the underlying request, which rejects the read + // that was already in flight. A spec `ReadableStream` resolves that read with `done: true` + // instead, so the failure mode only shows up on a stream backed by a live connection. + let rejectPendingRead: ((error: Error) => void) | undefined; + const abortingStream = { + [Symbol.asyncIterator]: () => ({ next: () => new Promise(() => {}) }), + getReader: () => ({ + read: () => new Promise((_, reject) => (rejectPendingRead = reject)), + cancel: async () => { + rejectPendingRead?.(new Error('The operation was aborted')); + // Let the rejected read settle before the cancel resolves, which is the race. + await Promise.resolve(); + }, + }), + }; + const client = instrumentMistralAiClient( + fakeClient({ chat: { stream: vi.fn().mockResolvedValue(abortingStream) } }), + ); + + const stream = await client.chat.stream({ model: 'mistral-large-latest', messages: [] }); + const reader = stream.getReader(); + const pendingRead = reader.read().catch(() => undefined); + await reader.cancel(); + await pendingRead; + + expect(endedSpans).toHaveLength(1); + expect(spanToStaticSpanJSON(endedSpans[0]!).status).not.toBe('internal_error'); + }); + + it('still marks the span errored when the stream fails without a cancel', async () => { + const endedSpans = setupClient('stream'); + const failing = new ReadableStream({ + start(controller) { + controller.error(new Error('connection reset')); + }, + }); + const client = instrumentMistralAiClient(fakeClient({ chat: { stream: vi.fn().mockResolvedValue(failing) } })); + + const stream = await client.chat.stream({ model: 'mistral-large-latest', messages: [] }); + const reader = stream.getReader(); + await expect(reader.read()).rejects.toThrow('connection reset'); + + expect(spanToStaticSpanJSON(endedSpans[0]!).status).toBe('internal_error'); + }); + + it('keeps the span ok when a pending read is cancelled on a real ReadableStream', async () => { + const endedSpans = setupClient('stream'); + const neverResolving = new ReadableStream({ start() {} }); + const client = instrumentMistralAiClient( + fakeClient({ chat: { stream: vi.fn().mockResolvedValue(neverResolving) } }), + ); + + const stream = await client.chat.stream({ model: 'mistral-large-latest', messages: [] }); + const reader = stream.getReader(); + const pendingRead = reader.read(); + await reader.cancel('user cancel'); + + // Spec behaviour: the in-flight read resolves as done rather than rejecting. + await expect(pendingRead).resolves.toEqual({ done: true, value: undefined }); + expect(endedSpans).toHaveLength(1); + expect(spanToStaticSpanJSON(endedSpans[0]!).status).not.toBe('internal_error'); + }); + + it('counts each chunk once when the iterator reads through `getReader()`', async () => { + const endedSpans = setupClient('stream'); + const client = instrumentMistralAiClient(fakeClient(), { recordOutputs: true }); + + const stream = await client.chat.stream({ model: 'mistral-large-latest', messages: [] }); + // Mirrors the SDK's iterator polyfill, which drains through the public `getReader()`. + const reader = stream.getReader(); + for (;;) { + const { done } = await reader.read(); + if (done) break; + } + + expect(spanToStaticSpanJSON(endedSpans[0]!).data[GEN_AI_RESPONSE_TEXT]).toBe('Hello from Mistral'); + }); + + it('accumulates and ends the span when the stream is drained with pipeTo()', async () => { + const endedSpans = setupClient('stream'); + const client = instrumentMistralAiClient(fakeClient(), { recordOutputs: true }); + + const stream = await client.chat.stream({ model: 'mistral-large-latest', messages: [] }); + const received: unknown[] = []; + await stream.pipeTo( + new WritableStream({ + write(chunk) { + received.push(chunk); + }, + }), + ); + + expect(received).toHaveLength(3); + expect(endedSpans).toHaveLength(1); + const span = spanToStaticSpanJSON(endedSpans[0]!); + expect(span.data[GEN_AI_RESPONSE_STREAMING]).toBe(true); + expect(span.data[GEN_AI_RESPONSE_TEXT]).toBe('Hello from Mistral'); + expect(span.data[GEN_AI_USAGE_TOTAL_TOKENS]).toBe(30); + }); + + it('accumulates and ends the span when the stream is drained with pipeThrough()', async () => { + const endedSpans = setupClient('stream'); + const client = instrumentMistralAiClient(fakeClient(), { recordOutputs: true }); + + const stream = await client.chat.stream({ model: 'mistral-large-latest', messages: [] }); + const passThrough = stream.pipeThrough(new TransformStream()); + + const received: unknown[] = []; + for await (const chunk of passThrough) { + received.push(chunk); + } + + expect(received).toHaveLength(3); + expect(endedSpans).toHaveLength(1); + expect(spanToStaticSpanJSON(endedSpans[0]!).data[GEN_AI_RESPONSE_TEXT]).toBe('Hello from Mistral'); + }); + + it('counts chunks once when the stream is teed, and feeds both branches', async () => { + const endedSpans = setupClient('stream'); + const client = instrumentMistralAiClient(fakeClient(), { recordOutputs: true }); + + const stream = await client.chat.stream({ model: 'mistral-large-latest', messages: [] }); + const [left, right] = stream.tee(); + + const drain = async (branch: ReadableStream): Promise => { + const chunks: unknown[] = []; + for await (const chunk of branch) { + chunks.push(chunk); + } + return chunks; + }; + const [leftChunks, rightChunks] = await Promise.all([drain(left), drain(right)]); + + // Both consumers see the full stream. + expect(leftChunks).toHaveLength(3); + expect(rightChunks).toHaveLength(3); + + // ...but the span records the response once, not twice. + expect(endedSpans).toHaveLength(1); + expect(spanToStaticSpanJSON(endedSpans[0]!).data[GEN_AI_RESPONSE_TEXT]).toBe('Hello from Mistral'); + }); + + it('leaves the teed stream locked, as an untouched ReadableStream would be', async () => { + setupClient('stream'); + const client = instrumentMistralAiClient(fakeClient()); + + const stream = await client.chat.stream({ model: 'mistral-large-latest', messages: [] }); + stream.tee(); + + expect(stream.locked).toBe(true); + expect(() => stream.getReader()).toThrow(TypeError); + }); + + it('applies backpressure instead of draining the source eagerly', async () => { + setupClient('stream'); + let pulled = 0; + const counting = new ReadableStream({ + pull(controller) { + pulled++; + controller.enqueue(STREAM_EVENTS[0]); + }, + }); + const client = instrumentMistralAiClient(fakeClient({ chat: { stream: vi.fn().mockResolvedValue(counting) } })); + + const stream = await client.chat.stream({ model: 'mistral-large-latest', messages: [] }); + const reader = stream.pipeThrough(new TransformStream()).getReader(); + await reader.read(); + const afterOneRead = pulled; + await reader.cancel(); + + // An infinite source must not be drained just because it was piped. The exact count depends on + // queue sizes; what matters is that it stays bounded rather than running away. + expect(afterOneRead).toBeLessThan(10); + }); + + it('marks the span errored when the stream throws', async () => { + const endedSpans = setupClient('stream'); + const failing = new ReadableStream({ + start(controller) { + controller.enqueue(STREAM_EVENTS[0]); + controller.error(new Error('stream blew up')); + }, + }); + const client = instrumentMistralAiClient(fakeClient({ chat: { stream: vi.fn().mockResolvedValue(failing) } })); + + const stream = await client.chat.stream({ model: 'mistral-large-latest', messages: [] }); + await expect( + (async () => { + for await (const _ of stream) { + void _; + } + })(), + ).rejects.toThrow('stream blew up'); + + expect(spanToStaticSpanJSON(endedSpans[0]!).status).toBe('internal_error'); + }); + + it('accumulates streamed tool-call arguments across chunks', async () => { + const endedSpans = setupClient('stream'); + const toolStream = eventStream([ + completionEvent({ + id: 'chatcmpl-tools', + model: 'mistral-large-latest', + choices: [ + { + delta: { + toolCalls: [{ index: 0, id: 'call_1', function: { name: 'get_weather', arguments: '{"city":' } }], + }, + }, + ], + }), + completionEvent({ + id: 'chatcmpl-tools', + model: 'mistral-large-latest', + choices: [ + { delta: { toolCalls: [{ index: 0, function: { arguments: '"Paris"}' } }] }, finishReason: 'tool_calls' }, + ], + }), + ]); + const client = instrumentMistralAiClient( + fakeClient({ chat: { stream: vi.fn().mockResolvedValue(toolStream) } }), + { + recordOutputs: true, + }, + ); + + const stream = await client.chat.stream({ model: 'mistral-large-latest', messages: [] }); + for await (const _ of stream) { + void _; + } + + const toolCalls = spanToStaticSpanJSON(endedSpans[0]!).data[GEN_AI_RESPONSE_TOOL_CALLS] as string; + expect(JSON.parse(toolCalls)).toEqual([ + { index: 0, id: 'call_1', function: { name: 'get_weather', arguments: '{"city":"Paris"}' } }, + ]); + }); + }); + + describe('output attributes', () => { + it('writes `gen_ai.response.text` as a stringified array of messages', async () => { + const endedSpans = setupClient('stream'); + const client = instrumentMistralAiClient(fakeClient(), { recordOutputs: true }); + + await client.chat.complete({ model: 'mistral-small-latest', messages: [] }); + + const responseText = spanToStaticSpanJSON(endedSpans[0]!).data[GEN_AI_RESPONSE_TEXT] as string; + expect(JSON.parse(responseText)).toEqual(['Hello from Mistral mock!']); + }); + + it('keeps one entry per choice instead of merging them', async () => { + const endedSpans = setupClient('stream'); + const multiChoice = vi.fn().mockResolvedValue({ + id: 'chatcmpl-multi', + model: 'mistral-small-latest', + choices: [ + { message: { content: 'First answer' }, finishReason: 'stop' }, + { message: { content: 'Second answer' }, finishReason: 'stop' }, + ], + }); + const client = instrumentMistralAiClient(fakeClient({ chat: { complete: multiChoice } }), { + recordOutputs: true, + }); + + await client.chat.complete({ model: 'mistral-small-latest', messages: [] }); + + const responseText = spanToStaticSpanJSON(endedSpans[0]!).data[GEN_AI_RESPONSE_TEXT] as string; + expect(JSON.parse(responseText)).toEqual(['First answer', 'Second answer']); + }); + + it('writes `gen_ai.output.messages` in the documented shape', async () => { + const endedSpans = setupClient('stream'); + const client = instrumentMistralAiClient(fakeClient(), { recordOutputs: true }); + + await client.chat.complete({ model: 'mistral-small-latest', messages: [] }); + + const outputMessages = spanToStaticSpanJSON(endedSpans[0]!).data[GEN_AI_OUTPUT_MESSAGES] as string; + expect(JSON.parse(outputMessages)).toEqual([ + { + role: 'assistant', + parts: [{ type: 'text', content: 'Hello from Mistral mock!' }], + finish_reason: 'stop', + }, + ]); + }); + + it('carries tool calls into `gen_ai.output.messages` as tool_call parts', async () => { + const endedSpans = setupClient('stream'); + const withToolCall = vi.fn().mockResolvedValue({ + id: 'chatcmpl-tools', + model: 'mistral-large-latest', + choices: [ + { + message: { + content: '', + toolCalls: [{ id: 'call_1', function: { name: 'get_weather', arguments: '{"city":"Paris"}' } }], + }, + finishReason: 'tool_calls', + }, + ], + }); + const client = instrumentMistralAiClient(fakeClient({ chat: { complete: withToolCall } }), { + recordOutputs: true, + }); + + await client.chat.complete({ model: 'mistral-large-latest', messages: [] }); + + const outputMessages = spanToStaticSpanJSON(endedSpans[0]!).data[GEN_AI_OUTPUT_MESSAGES] as string; + expect(JSON.parse(outputMessages)).toEqual([ + { + role: 'assistant', + parts: [{ type: 'tool_call', id: 'call_1', name: 'get_weather', arguments: '{"city":"Paris"}' }], + finish_reason: 'tool_calls', + }, + ]); + }); + + it('writes `gen_ai.output.messages` for a streamed response', async () => { + const endedSpans = setupClient('stream'); + const client = instrumentMistralAiClient(fakeClient(), { recordOutputs: true }); + + const stream = await client.chat.stream({ model: 'mistral-large-latest', messages: [] }); + for await (const _ of stream) { + void _; + } + + const outputMessages = spanToStaticSpanJSON(endedSpans[0]!).data[GEN_AI_OUTPUT_MESSAGES] as string; + expect(JSON.parse(outputMessages)).toEqual([ + { + role: 'assistant', + parts: [{ type: 'text', content: 'Hello from Mistral' }], + finish_reason: 'stop', + }, + ]); + }); + + it('records no output attributes when output recording is off', async () => { + const endedSpans = setupClient('stream'); + const client = instrumentMistralAiClient(fakeClient(), { recordOutputs: false }); + + await client.chat.complete({ model: 'mistral-small-latest', messages: [] }); + + const span = spanToStaticSpanJSON(endedSpans[0]!); + expect(span.data[GEN_AI_RESPONSE_TEXT]).toBeUndefined(); + expect(span.data[GEN_AI_OUTPUT_MESSAGES]).toBeUndefined(); + }); + }); + + describe('structured outputs', () => { + it('instruments `chat.parse`, which does not route through `chat.complete`', async () => { + const endedSpans = setupClient('stream'); + const raw = fakeClient(); + const client = instrumentMistralAiClient(raw); + + await client.chat.parse({ model: 'mistral-small-latest', messages: [] }); + + expect(raw.chat.complete).not.toHaveBeenCalled(); + const span = spanToStaticSpanJSON(endedSpans[0]!); + expect(span.description).toBe('chat mistral-small-latest'); + expect(span.data[GEN_AI_OPERATION_NAME]).toBe('chat'); + expect(span.data[GEN_AI_PROVIDER_NAME]).toBe('mistralai'); + }); + + it('instruments `chat.parseStream` as a streaming call', async () => { + const endedSpans = setupClient('stream'); + const client = instrumentMistralAiClient(fakeClient()); + + const stream = await client.chat.parseStream({ model: 'mistral-large-latest', messages: [] }); + for await (const _ of stream) { + void _; + } + + const span = spanToStaticSpanJSON(endedSpans[0]!); + expect(span.data[GEN_AI_REQUEST_STREAM]).toBe(true); + expect(span.data[GEN_AI_RESPONSE_STREAMING]).toBe(true); + }); + }); + + describe('errors', () => { + it('marks the span errored when a non-streaming call rejects', async () => { + const endedSpans = setupClient('stream'); + const client = instrumentMistralAiClient( + fakeClient({ chat: { complete: vi.fn().mockRejectedValue(new Error('404 Model not found')) } }), + ); + + await expect(client.chat.complete({ model: 'error-model', messages: [] })).rejects.toThrow('404 Model not found'); + + expect(spanToStaticSpanJSON(endedSpans[0]!).status).toBe('internal_error'); + }); + + it('marks the span errored when a streaming call rejects before the stream exists', async () => { + const endedSpans = setupClient('stream'); + const client = instrumentMistralAiClient( + fakeClient({ chat: { stream: vi.fn().mockRejectedValue(new Error('429 Too many requests')) } }), + ); + + await expect(client.chat.stream({ model: 'mistral-large-latest', messages: [] })).rejects.toThrow( + '429 Too many requests', + ); + + expect(endedSpans).toHaveLength(1); + expect(spanToStaticSpanJSON(endedSpans[0]!).status).toBe('internal_error'); + }); + }); +}); diff --git a/packages/vercel-edge/src/index.ts b/packages/vercel-edge/src/index.ts index bc9be3e2aeef..983df5a23b7f 100644 --- a/packages/vercel-edge/src/index.ts +++ b/packages/vercel-edge/src/index.ts @@ -105,6 +105,7 @@ export { trpcMiddleware, wrapMcpServerWithSentry } from '@sentry/core/server'; export { openTelemetryIntegration, getOtlpTracesEndpoint, + instrumentMistralAiClient, instrumentOpenAiClient, instrumentAnthropicAiClient, instrumentGoogleGenAIClient, diff --git a/yarn.lock b/yarn.lock index 554ed736076e..2c1b930b29dd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5730,6 +5730,16 @@ semver "^7.5.3" tar "^7.4.0" +"@mistralai/mistralai@2.6.4": + version "2.6.4" + resolved "https://sfw.security.sentry.io/npm/@mistralai/mistralai/-/mistralai-2.6.4.tgz#dbc733788e5d39cd4c45913c40db6c2a1fb1ba5f" + integrity sha512-PPt4GyJqs2hEsWrYCJZK5f0ORmT+L2MSm75LVGD7kBLf6ZKsoDpld/FRBQXr8xG6iFCBOFJFYzvGYhUb+UCkbw== + dependencies: + "@opentelemetry/semantic-conventions" "^1.40.0" + ws "^8.18.0" + zod "^3.25.0 || ^4.0.0" + zod-to-json-schema "^3.25.0" + "@mjackson/node-fetch-server@^0.2.0": version "0.2.0" resolved "https://registry.yarnpkg.com/@mjackson/node-fetch-server/-/node-fetch-server-0.2.0.tgz#577c0c25d8aae9f69a97738b7b0d03d1471cdc49" @@ -6586,7 +6596,7 @@ import-in-the-middle "^3.0.0" require-in-the-middle "^8.0.0" -"@opentelemetry/semantic-conventions@^1.29.0": +"@opentelemetry/semantic-conventions@^1.29.0", "@opentelemetry/semantic-conventions@^1.40.0": version "1.43.0" resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz#f3f467e36c27332f0e735ec86cdcd78dd6f27865" integrity sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg== @@ -28703,7 +28713,7 @@ zip-stream@^6.0.1: compress-commons "^6.0.2" readable-stream "^4.0.0" -zod-to-json-schema@^3.22.3, zod-to-json-schema@^3.23.5, zod-to-json-schema@^3.24.1: +zod-to-json-schema@^3.22.3, zod-to-json-schema@^3.23.5, zod-to-json-schema@^3.24.1, zod-to-json-schema@^3.25.0: version "3.25.2" resolved "https://registry.yarnpkg.com/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz#3fa799a7badd554541472fb65843fdc460b2e5aa" integrity sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA== @@ -28718,7 +28728,7 @@ zod@^3.23.8, zod@^3.24.1, zod@^3.25.32: resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34" integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ== -zod@^4.0.0, zod@^4.2.0: +"zod@^3.25.0 || ^4.0.0", zod@^4.0.0, zod@^4.2.0: version "4.5.4" resolved "https://sfw.security.sentry.io/npm/zod/-/zod-4.5.4.tgz#e215c62420c528dd7951e31fb52c5438f1fd184a" integrity sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==