From f3e776293b5cf3c12f2ebd2fa2e2cab4a19b5e63 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Wed, 9 Sep 2026 14:09:30 +0200 Subject: [PATCH 01/24] test(node): Add failing integration tests for Mistral AI SDK Add span-streaming (`traceLifecycle: 'stream'`) node integration tests for a planned `@mistralai/mistralai` gen_ai integration, mirroring the OpenAI suite. Covers chat, embeddings, agents (invoke_agent) and fim (text_completion), across PII-off, PII-on and explicit-integration-option variants. These tests are expected to fail until the `mistralAIIntegration` / `instrumentMistralClient` instrumentation is implemented (TDD step 1). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../node-integration-tests/package.json | 1 + .../mistral/instrument-with-options.mjs | 16 ++ .../tracing/mistral/instrument-with-pii.mjs | 11 + .../suites/tracing/mistral/instrument.mjs | 11 + .../tracing/mistral/scenario-agents.mjs | 124 ++++++++++ .../suites/tracing/mistral/scenario-chat.mjs | 137 +++++++++++ .../tracing/mistral/scenario-embeddings.mjs | 66 ++++++ .../suites/tracing/mistral/scenario-fim.mjs | 125 ++++++++++ .../suites/tracing/mistral/test.ts | 224 ++++++++++++++++++ yarn.lock | 16 +- 10 files changed, 728 insertions(+), 3 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/mistral/instrument-with-options.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/mistral/instrument-with-pii.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/mistral/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/mistral/scenario-agents.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/mistral/scenario-chat.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/mistral/scenario-embeddings.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/mistral/scenario-fim.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/mistral/test.ts diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index eada078172a5..2ff2abfc3115 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-with-options.mjs b/dev-packages/node-integration-tests/suites/tracing/mistral/instrument-with-options.mjs new file mode 100644 index 000000000000..7d67ddb7df15 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mistral/instrument-with-options.mjs @@ -0,0 +1,16 @@ +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, + traceLifecycle: 'stream', + 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..657bed0a3a8c --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mistral/instrument-with-pii.mjs @@ -0,0 +1,11 @@ +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, + traceLifecycle: 'stream', +}); 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..5e0b6fb5592f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mistral/instrument.mjs @@ -0,0 +1,11 @@ +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, + traceLifecycle: 'stream', +}); 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..070cef3bcb3f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mistral/scenario-embeddings.mjs @@ -0,0 +1,66 @@ +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 } = req.body; + + if (model === 'error-model') { + res.status(404).set('x-request-id', 'mock-request-123').end('Model not found'); + return; + } + + res.send({ + id: '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-fim.mjs b/dev-packages/node-integration-tests/suites/tracing/mistral/scenario-fim.mjs new file mode 100644 index 000000000000..e88d6a33fbbc --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mistral/scenario-fim.mjs @@ -0,0 +1,125 @@ +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/fim/completions', (req, res) => { + const { model, stream } = req.body; + + 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: 'fimcmpl-stream-123', + object: 'chat.completion.chunk', + created: 1677652300, + model, + choices: [ + { + index: 0, + delta: { role: 'assistant', content: '' }, + finish_reason: null, + }, + ], + }, + { + id: 'fimcmpl-stream-123', + object: 'chat.completion.chunk', + created: 1677652300, + model, + choices: [ + { + index: 0, + delta: { content: 'def hello():' }, + finish_reason: null, + }, + ], + }, + { + id: 'fimcmpl-stream-123', + object: 'chat.completion.chunk', + created: 1677652300, + model, + choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], + usage: { prompt_tokens: 6, completion_tokens: 9, total_tokens: 15 }, + }, + ]; + + 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: 'fimcmpl-mock123', + object: 'chat.completion', + created: 1677652288, + model, + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: 'def hello():\n return "world"', + }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 5, completion_tokens: 10, total_tokens: 15 }, + }); + } + }); + + 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.fim.complete({ + model: 'codestral-latest', + prompt: 'def hello', + suffix: 'return res', + }); + + const stream = await client.fim.stream({ + model: 'codestral-latest', + prompt: 'def fib(n)', + }); + + 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..db7b573637de --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mistral/test.ts @@ -0,0 +1,224 @@ +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_RESPONSE_TEXT, + 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, createEsmAndCjsTests } from '../../../utils/runner'; + +const PROVIDER = 'mistral'; +const ORIGIN = 'auto.ai.mistral'; + +describe('Mistral integration', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests(__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); + }, + }) + .start() + .completed(); + }); + }); + + createEsmAndCjsTests(__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(); + expect(chatSpan!.attributes[GEN_AI_INPUT_MESSAGES]?.value).toBe( + '[{"role":"system","content":"You are a helpful assistant."},{"role":"user","content":"What is the capital of France?"}]', + ); + expect(chatSpan!.attributes[GEN_AI_RESPONSE_TEXT]?.value).toBe('Hello from Mistral mock!'); + }, + }) + .start() + .completed(); + }); + }); + + createEsmAndCjsTests(__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!'); + }, + }) + .start() + .completed(); + }); + }); + + createEsmAndCjsTests(__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(); + }); + }); + + createEsmAndCjsTests(__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(); + }); + }); + + createEsmAndCjsTests(__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 ag-mock-123'); + 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(); + }); + }); + + createEsmAndCjsTests(__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(); + }); + }); + + createEsmAndCjsTests(__dirname, 'scenario-fim.mjs', 'instrument.mjs', (createRunner, test) => { + test('creates fim text_completion spans', async () => { + await createRunner() + .expect({ + span: container => { + const fimSpan = container.items.find(s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'fimcmpl-mock123'); + expect(fimSpan).toBeDefined(); + expect(fimSpan!.name).toBe('text_completion codestral-latest'); + expect(fimSpan!.status).toBe('ok'); + expect(fimSpan!.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('text_completion'); + expect(fimSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]?.value).toBe('gen_ai.text_completion'); + expect(fimSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]?.value).toBe(ORIGIN); + expect(fimSpan!.attributes[GEN_AI_PROVIDER_NAME]?.value).toBe(PROVIDER); + expect(fimSpan!.attributes[GEN_AI_REQUEST_MODEL]?.value).toBe('codestral-latest'); + expect(fimSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]?.value).toBe(15); + + const fimStreamSpan = container.items.find( + s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'fimcmpl-stream-123', + ); + expect(fimStreamSpan).toBeDefined(); + expect(fimStreamSpan!.attributes[GEN_AI_RESPONSE_STREAMING]?.value).toBe(true); + }, + }) + .start() + .completed(); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 6ac40a9a354e..77a2b615325a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5574,6 +5574,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" @@ -6430,7 +6440,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== @@ -28519,7 +28529,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== @@ -28534,7 +28544,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== From bc0fbe42246dbee76b6c8f98b5b043f46a2bddf3 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Wed, 9 Sep 2026 14:45:03 +0200 Subject: [PATCH 02/24] feat(node): Add Mistral AI integration Instrument `@mistralai/mistralai` v2 with gen_ai spans, turning the previously-failing integration tests green. - Automatic `mistralAIIntegration()` via the orchestrion diagnostics channels (default-on in Node) - Manual `instrumentMistralClient()` proxy for edge runtimes - Covers chat, embeddings, agents (invoke_agent) and fim (text_completion), including streaming, with `recordInputs` / `recordOutputs` controls Mistral's typed responses/usage are camelCase, so the response/stream mapping reads `promptTokens`/`completionTokens`/`totalTokens` and `choices[].finishReason` directly. `@mistralai/mistralai` v2 is ESM-only, so the CJS test variants are marked `failsOnCjs`. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../suites/tracing/mistral/test.ts | 427 ++++++++++-------- packages/node/src/index.ts | 2 + packages/server-utils/src/ai/index.ts | 1 + .../server-utils/src/ai/mistral/constants.ts | 15 + packages/server-utils/src/ai/mistral/index.ts | 200 ++++++++ .../server-utils/src/ai/mistral/streaming.ts | 73 +++ packages/server-utils/src/ai/mistral/types.ts | 24 + packages/server-utils/src/ai/mistral/utils.ts | 124 +++++ packages/server-utils/src/index.ts | 1 + .../server-utils/src/integrations/index.ts | 2 + .../server-utils/src/integrations/mistral.ts | 135 ++++++ .../server-utils/src/orchestrion/channels.ts | 2 + .../config/channel-integration-definitions.ts | 1 + .../src/orchestrion/config/index.ts | 2 + .../src/orchestrion/config/mistral.ts | 55 +++ 15 files changed, 881 insertions(+), 183 deletions(-) create mode 100644 packages/server-utils/src/ai/mistral/constants.ts create mode 100644 packages/server-utils/src/ai/mistral/index.ts create mode 100644 packages/server-utils/src/ai/mistral/streaming.ts create mode 100644 packages/server-utils/src/ai/mistral/types.ts create mode 100644 packages/server-utils/src/ai/mistral/utils.ts create mode 100644 packages/server-utils/src/integrations/mistral.ts create mode 100644 packages/server-utils/src/orchestrion/config/mistral.ts diff --git a/dev-packages/node-integration-tests/suites/tracing/mistral/test.ts b/dev-packages/node-integration-tests/suites/tracing/mistral/test.ts index db7b573637de..cbec4f7b2abc 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mistral/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mistral/test.ts @@ -13,6 +13,7 @@ import { GEN_AI_RESPONSE_MODEL, GEN_AI_RESPONSE_STREAMING, GEN_AI_RESPONSE_TEXT, + GEN_AI_SYSTEM_INSTRUCTIONS, GEN_AI_USAGE_INPUT_TOKENS, GEN_AI_USAGE_OUTPUT_TOKENS, GEN_AI_USAGE_TOTAL_TOKENS, @@ -23,202 +24,262 @@ import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runn const PROVIDER = 'mistral'; const ORIGIN = 'auto.ai.mistral'; +// `@mistralai/mistralai` v2 is ESM-only. Under CJS the SDK loads via `require(esm)`, which the +// orchestrion transform does not reach, so no gen_ai spans are produced there — the integration only +// auto-instruments under ESM for this package, hence `{ failsOnCjs: true }` on every case below. + describe('Mistral integration', () => { afterAll(() => { cleanupChildProcesses(); }); - createEsmAndCjsTests(__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(); + createEsmAndCjsTests( + __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); - }, - }) - .start() - .completed(); - }); - }); + 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); + }, + }) + .start() + .completed(); + }); + }, + { failsOnCjs: true }, + ); - createEsmAndCjsTests(__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(); - expect(chatSpan!.attributes[GEN_AI_INPUT_MESSAGES]?.value).toBe( - '[{"role":"system","content":"You are a helpful assistant."},{"role":"user","content":"What is the capital of France?"}]', - ); - expect(chatSpan!.attributes[GEN_AI_RESPONSE_TEXT]?.value).toBe('Hello from Mistral mock!'); - }, - }) - .start() - .completed(); - }); - }); + createEsmAndCjsTests( + __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!'); + }, + }) + .start() + .completed(); + }); + }, + { failsOnCjs: true }, + ); - createEsmAndCjsTests(__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!'); - }, - }) - .start() - .completed(); - }); - }); + createEsmAndCjsTests( + __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!'); + }, + }) + .start() + .completed(); + }); + }, + { failsOnCjs: true }, + ); - createEsmAndCjsTests(__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(); - }); - }); + createEsmAndCjsTests( + __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(); + }); + }, + { failsOnCjs: true }, + ); - createEsmAndCjsTests(__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(); - }); - }); + createEsmAndCjsTests( + __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(); + }); + }, + { failsOnCjs: true }, + ); - createEsmAndCjsTests(__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 ag-mock-123'); - 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); + createEsmAndCjsTests( + __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 ag-mock-123'); + 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(); - }); - }); + 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(); + }); + }, + { failsOnCjs: true }, + ); - createEsmAndCjsTests(__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(); - }); - }); + createEsmAndCjsTests( + __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(); + }); + }, + { failsOnCjs: true }, + ); - createEsmAndCjsTests(__dirname, 'scenario-fim.mjs', 'instrument.mjs', (createRunner, test) => { - test('creates fim text_completion spans', async () => { - await createRunner() - .expect({ - span: container => { - const fimSpan = container.items.find(s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'fimcmpl-mock123'); - expect(fimSpan).toBeDefined(); - expect(fimSpan!.name).toBe('text_completion codestral-latest'); - expect(fimSpan!.status).toBe('ok'); - expect(fimSpan!.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('text_completion'); - expect(fimSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]?.value).toBe('gen_ai.text_completion'); - expect(fimSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]?.value).toBe(ORIGIN); - expect(fimSpan!.attributes[GEN_AI_PROVIDER_NAME]?.value).toBe(PROVIDER); - expect(fimSpan!.attributes[GEN_AI_REQUEST_MODEL]?.value).toBe('codestral-latest'); - expect(fimSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]?.value).toBe(15); + createEsmAndCjsTests( + __dirname, + 'scenario-fim.mjs', + 'instrument.mjs', + (createRunner, test) => { + test('creates fim text_completion spans', async () => { + await createRunner() + .expect({ + span: container => { + const fimSpan = container.items.find(s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'fimcmpl-mock123'); + expect(fimSpan).toBeDefined(); + expect(fimSpan!.name).toBe('text_completion codestral-latest'); + expect(fimSpan!.status).toBe('ok'); + expect(fimSpan!.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('text_completion'); + expect(fimSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]?.value).toBe('gen_ai.text_completion'); + expect(fimSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]?.value).toBe(ORIGIN); + expect(fimSpan!.attributes[GEN_AI_PROVIDER_NAME]?.value).toBe(PROVIDER); + expect(fimSpan!.attributes[GEN_AI_REQUEST_MODEL]?.value).toBe('codestral-latest'); + expect(fimSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]?.value).toBe(15); - const fimStreamSpan = container.items.find( - s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'fimcmpl-stream-123', - ); - expect(fimStreamSpan).toBeDefined(); - expect(fimStreamSpan!.attributes[GEN_AI_RESPONSE_STREAMING]?.value).toBe(true); - }, - }) - .start() - .completed(); - }); - }); + const fimStreamSpan = container.items.find( + s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'fimcmpl-stream-123', + ); + expect(fimStreamSpan).toBeDefined(); + expect(fimStreamSpan!.attributes[GEN_AI_RESPONSE_STREAMING]?.value).toBe(true); + }, + }) + .start() + .completed(); + }); + }, + { failsOnCjs: true }, + ); }); diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 03532223a506..8c894dba9cf3 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -25,6 +25,7 @@ export { mongoIntegration, mongooseIntegration, mysqlIntegration, + mistralAIIntegration, mysql2Integration, openAIIntegration, postgresIntegration, @@ -40,6 +41,7 @@ export { instrumentOpenAiClient, instrumentAnthropicAiClient, instrumentGoogleGenAIClient, + instrumentMistralClient, createLangChainCallbackHandler, instrumentLangChainEmbeddings, instrumentStateGraph, diff --git a/packages/server-utils/src/ai/index.ts b/packages/server-utils/src/ai/index.ts index 9fd995466027..6c1174287ac7 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 { instrumentMistralClient } 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..8bdf92009997 --- /dev/null +++ b/packages/server-utils/src/ai/mistral/constants.ts @@ -0,0 +1,15 @@ +import type { InstrumentedMethodRegistry } from '../core/utils'; + +export const MISTRAL_INTEGRATION_NAME = 'Mistral' as const; + +// https://docs.mistral.ai/api/ +// `*.stream` methods are intrinsically streaming (no `stream: true` param), so they are flagged here. +export const MISTRAL_METHOD_REGISTRY = { + 'chat.complete': { operation: 'chat' }, + 'chat.stream': { operation: 'chat', streaming: true }, + 'embeddings.create': { operation: 'embeddings' }, + 'agents.complete': { operation: 'invoke_agent' }, + 'agents.stream': { operation: 'invoke_agent', streaming: true }, + 'fim.complete': { operation: 'text_completion' }, + 'fim.stream': { operation: 'text_completion', 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..912c18cb9f0f --- /dev/null +++ b/packages/server-utils/src/ai/mistral/index.ts @@ -0,0 +1,200 @@ +import { + getClient, + hasSpanStreamingEnabled, + 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, +} from '@sentry/conventions/attributes'; +import type { InstrumentedMethodEntry } from '../core/utils'; +import { + buildMethodPath, + extractSystemInstructions, + getGenAiSpanOp, + resolveAIRecordingOptions, + wrapPromiseWithMethods, +} from '../core/utils'; +import { MISTRAL_METHOD_REGISTRY } from './constants'; +import { instrumentStream } from './streaming'; +import type { MistralOptions } from './types'; +import { addResponseAttributes, extractRequestParameters, getModelForSpanName } from './utils'; + +/** + * Extract request attributes from method arguments. + */ +export function extractRequestAttributes(args: unknown[], operationName: string): Record { + const attributes: Record = { + [GEN_AI_PROVIDER_NAME]: 'mistral', + [GEN_AI_OPERATION_NAME]: operationName, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ai.mistral', + }; + + 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; + } + + 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; + } + + // FIM completions carry a raw `prompt` string rather than a messages array. + if (operationName === 'text_completion') { + if (typeof params.prompt === 'string' && params.prompt.length > 0) { + span.setAttribute(GEN_AI_INPUT_MESSAGES, stringify(params.prompt)); + } + 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 requestAttributes = extractRequestAttributes(args, operationName); + + const params = args[0] as Record | undefined; + const model = getModelForSpanName(params, operationName); + // `*.stream` methods are always streaming; `complete` methods stream only with `stream: true`. + const isStreamRequested = !!instrumentedMethod.streaming || params?.stream === true; + const client = getClient(); + + const spanConfig = { + // With span streaming, omit the `'unknown'` model sentinel so the name stays low-cardinality. + name: + model !== 'unknown' || !(client && hasSpanStreamingEnabled(client)) + ? `${operationName} ${model}` + : operationName, + 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 (async () => { + try { + const result = await originalResult; + return instrumentStream( + result as AsyncIterable, + span, + options.recordOutputs ?? false, + ) as unknown as R; + } catch (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 instrumentMistralClient(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..356584cceb3d --- /dev/null +++ b/packages/server-utils/src/ai/mistral/streaming.ts @@ -0,0 +1,73 @@ +import type { Span } from '@sentry/core'; +import { endStreamSpan } from '../core/utils'; +import type { MistralCompletionChunk } from './types'; + +/** + * 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; +} + +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 && typeof choice.delta?.content === 'string' && choice.delta.content) { + state.responseTexts.push(choice.delta.content); + } + if (choice.finishReason) { + state.finishReasons.push(choice.finishReason); + } + } +} + +/** + * Instrument a Mistral event stream, accumulating response attributes and ending the span when + * iteration finishes. Mistral yields `CompletionEvent` objects that wrap the chunk under `data`. + */ +export async function* instrumentStream( + stream: AsyncIterable, + span: Span, + recordOutputs: boolean, +): AsyncGenerator { + const state: StreamingState = { + responseTexts: [], + finishReasons: [], + responseId: '', + responseModel: '', + promptTokens: undefined, + completionTokens: undefined, + totalTokens: undefined, + }; + + try { + for await (const event of stream) { + const chunk = + event && typeof event === 'object' && 'data' in event + ? (event as { data: MistralCompletionChunk }).data + : (event as unknown as MistralCompletionChunk); + if (chunk && typeof chunk === 'object') { + processChunk(chunk, state, recordOutputs); + } + yield event; + } + } finally { + endStreamSpan(span, { ...state, toolCalls: [] }, recordOutputs); + } +} 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..c2c4ffafa320 --- /dev/null +++ b/packages/server-utils/src/ai/mistral/types.ts @@ -0,0 +1,24 @@ +import type { GenAiOptions } from '../core/utils'; + +/** Options for the Mistral integration. */ +export type MistralOptions = GenAiOptions; + +/** + * 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 }; + 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..3a477e960321 --- /dev/null +++ b/packages/server-utils/src/ai/mistral/utils.ts @@ -0,0 +1,124 @@ +/* eslint-disable typescript-eslint/no-deprecated */ +import type { Span, SpanAttributeValue } from '@sentry/core'; +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'; +import { GEN_AI_REQUEST_STREAM_ATTRIBUTE } from '../core/gen-ai-attributes'; + +/** + * The token that follows the operation in a span name. Agents have no `model` at request time, + * so their span is named after the invoked agent id instead. + */ +export function getModelForSpanName(params: Record | undefined, operationName: string): string { + if (operationName === 'invoke_agent') { + return (params?.agentId as string) || 'unknown'; + } + return (params?.model as string) || 'unknown'; +} + +/** + * Turn a Mistral message content (string or content-chunk array) into a plain string. + */ +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 ''; +} + +/** + * 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; + if ('stream' in params) attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE] = params.stream; + + 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; + } + + 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) { + const responseText = choices + .map(choice => contentToString((choice.message as Record | undefined)?.content)) + .join(''); + if (responseText) { + attrs[GEN_AI_RESPONSE_TEXT] = responseText; + } + + const toolCalls = choices + .map(choice => (choice.message as Record | undefined)?.toolCalls) + .filter(calls => Array.isArray(calls) && calls.length > 0) + .flat(); + if (toolCalls.length > 0) { + attrs[GEN_AI_RESPONSE_TOOL_CALLS] = JSON.stringify(toolCalls); + } + } + } + + span.setAttributes(attrs); +} diff --git a/packages/server-utils/src/index.ts b/packages/server-utils/src/index.ts index 2f918e2d5422..9de4b6a82c10 100644 --- a/packages/server-utils/src/index.ts +++ b/packages/server-utils/src/index.ts @@ -45,6 +45,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 849a873cb1e3..77d8358a2cc2 100644 --- a/packages/server-utils/src/integrations/index.ts +++ b/packages/server-utils/src/integrations/index.ts @@ -18,6 +18,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'; @@ -53,6 +54,7 @@ export function getTracingIntegrations(): Integration[] { openAIIntegration(), anthropicAIIntegration(), googleGenAIIntegration(), + mistralAIIntegration(), postgresJsIntegration(), firebaseIntegration(), ]; diff --git a/packages/server-utils/src/integrations/mistral.ts b/packages/server-utils/src/integrations/mistral.ts new file mode 100644 index 000000000000..9a85fad02d2e --- /dev/null +++ b/packages/server-utils/src/integrations/mistral.ts @@ -0,0 +1,135 @@ +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import type { IntegrationFn, Span, SpanAttributeValue } from '@sentry/core'; +import { + _INTERNAL_shouldSkipAiProviderWrapping, + defineIntegration, + getClient, + hasSpanStreamingEnabled, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + startInactiveSpan, +} from '@sentry/core'; +import { getGenAiSpanOp, resolveAIRecordingOptions } from '../ai/core/utils'; +import { addRequestAttributes, extractRequestAttributes } from '../ai/mistral'; +import { instrumentStream } from '../ai/mistral/streaming'; +import type { MistralOptions } from '../ai/mistral/types'; +import { addResponseAttributes, getModelForSpanName } 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' as const; + +const ORIGIN = 'auto.ai.mistral'; + +// Each instrumented channel maps to the gen_ai operation its span reports. +const INSTRUMENTED_CHANNELS = [ + { channel: CHANNELS.MISTRAL_CHAT, operation: 'chat' }, + { channel: CHANNELS.MISTRAL_EMBEDDINGS, operation: 'embeddings' }, + { channel: CHANNELS.MISTRAL_AGENTS, operation: 'invoke_agent' }, + { channel: CHANNELS.MISTRAL_FIM, operation: 'text_completion' }, +] 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 } of INSTRUMENTED_CHANNELS) { + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(channel), + data => createGenAiSpan(data, operation, options), + { + beforeSpanEnd: (span, data) => { + addResponseAttributes(span, data.result, resolveAIRecordingOptions(options).recordOutputs); + }, + // Streaming: the result is an async-iterable consumed later, so instrument it and let it end the span. + deferSpanEnd: ({ span, data }) => wrapStreamResult(span, data, options), + }, + ); + } +} + +/** + * 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, 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); + attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] = ORIGIN; + const model = getModelForSpanName(params, operation); + const client = getClient(); + + const span = startInactiveSpan({ + // With span streaming, omit the `'unknown'` model sentinel so the name stays low-cardinality. + name: model !== 'unknown' || !(client && hasSpanStreamingEnabled(client)) ? `${operation} ${model}` : operation, + op: getGenAiSpanOp(operation), + attributes: attributes as Record, + }); + + if (recordInputs && params) { + addRequestAttributes(span, params, operation); + } + + return span; +} + +type AsyncIterableStream = { [Symbol.asyncIterator]: () => AsyncIterator }; + +function isAsyncIterable(value: unknown): value is AsyncIterableStream { + return !!value && typeof (value as AsyncIterableStream)[Symbol.asyncIterator] === 'function'; +} + +/** + * For a streaming call the result is an `EventStream` the caller consumes later. We can't swap what the + * method returns, but the stream in `data.result` is the same instance the caller holds and `asyncEnd` + * fires before iteration — so we patch its async iterator in place to run through `instrumentStream`, + * which accumulates streamed attributes and ends the span when iteration finishes. Only a streaming call + * resolves to an async-iterable, so that check alone distinguishes it. Returns `true` to hand + * span-ending ownership to `instrumentStream`; `false` for non-streaming/errored results. + */ +function wrapStreamResult(span: Span, data: MistralChannelContext, options: MistralOptions): boolean { + const result = data.result; + if (!isAsyncIterable(result)) { + return false; + } + + const { recordOutputs } = resolveAIRecordingOptions(options); + const iterate = result[Symbol.asyncIterator].bind(result); + const instrumented = instrumentStream({ [Symbol.asyncIterator]: iterate }, span, recordOutputs ?? false); + result[Symbol.asyncIterator] = () => instrumented; + + return true; +} + +/** + * Diagnostics-channel-based Mistral integration. Subscribes to the `orchestrion:@mistralai/mistralai:*` + * diagnostics_channels injected into the SDK's chat, embeddings, agents and fim methods, so it requires + * the Sentry runtime hook or bundler plugin. + */ +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 1fec4fb2c5ad..e41c124f0c61 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..76e34b2d1986 --- /dev/null +++ b/packages/server-utils/src/orchestrion/config/mistral.ts @@ -0,0 +1,55 @@ +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; + +export const mistralConfig = [ + { + channelName: 'chat', + module: { ...MODULE, filePath: 'esm/sdk/chat.js' }, + functionQuery: { className: 'Chat', methodName: 'complete', kind: 'Auto' as const }, + }, + { + channelName: 'chat', + module: { ...MODULE, filePath: 'esm/sdk/chat.js' }, + functionQuery: { className: 'Chat', methodName: 'stream', 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: { ...MODULE, filePath: 'esm/sdk/agents.js' }, + functionQuery: { className: 'Agents', methodName: 'complete', kind: 'Auto' as const }, + }, + { + channelName: 'agents', + module: { ...MODULE, filePath: 'esm/sdk/agents.js' }, + functionQuery: { className: 'Agents', methodName: 'stream', kind: 'Auto' as const }, + }, + { + channelName: 'fim', + module: { ...MODULE, filePath: 'esm/sdk/fim.js' }, + functionQuery: { className: 'Fim', methodName: 'complete', kind: 'Auto' as const }, + }, + { + channelName: 'fim', + module: { ...MODULE, filePath: 'esm/sdk/fim.js' }, + functionQuery: { className: 'Fim', methodName: 'stream', kind: 'Auto' as const }, + }, +] satisfies InstrumentationConfig[]; + +export const mistralModuleNames = getModuleNames(mistralConfig); + +export const mistralChannels = { + MISTRAL_CHAT: 'orchestrion:@mistralai/mistralai:chat', + MISTRAL_EMBEDDINGS: 'orchestrion:@mistralai/mistralai:embeddings', + MISTRAL_AGENTS: 'orchestrion:@mistralai/mistralai:agents', + MISTRAL_FIM: 'orchestrion:@mistralai/mistralai:fim', +} as const; From cd4eb98d3c3fd55d092fa2dd6e7736359af5e0c7 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Wed, 9 Sep 2026 15:16:44 +0200 Subject: [PATCH 03/24] =?UTF-8?q?fix(mistral):=20Fix=20CI=20=E2=80=94=20re?= =?UTF-8?q?-exports,=20deno=20snapshot,=20and=20CJS=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove `failsOnCjs` from the Mistral suite: CJS auto-instrumentation works on a full build (it only failed under a partial local rebuild), so the tests pass in both ESM and CJS. - Re-export `mistralAIIntegration` / `instrumentMistralClient` from the dependent SDK packages (aws-serverless, bun, elysia, deno, google-cloud-serverless, astro, cloudflare, vercel-edge) so the node-exports consistency check passes. - Add `Mistral` to the Deno default-integrations snapshot. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../suites/tracing/mistral/test.ts | 428 ++++++++---------- packages/astro/src/index.server.ts | 2 + packages/aws-serverless/src/index.ts | 2 + packages/bun/src/index.ts | 2 + packages/cloudflare/src/index.ts | 1 + packages/deno/src/index.ts | 1 + .../deno/test/__snapshots__/mod.test.ts.snap | 3 + packages/elysia/src/index.ts | 2 + packages/google-cloud-serverless/src/index.ts | 2 + packages/vercel-edge/src/index.ts | 1 + 10 files changed, 201 insertions(+), 243 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/mistral/test.ts b/dev-packages/node-integration-tests/suites/tracing/mistral/test.ts index cbec4f7b2abc..f02b9b7ba504 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mistral/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mistral/test.ts @@ -24,262 +24,204 @@ import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runn const PROVIDER = 'mistral'; const ORIGIN = 'auto.ai.mistral'; -// `@mistralai/mistralai` v2 is ESM-only. Under CJS the SDK loads via `require(esm)`, which the -// orchestrion transform does not reach, so no gen_ai spans are produced there — the integration only -// auto-instruments under ESM for this package, hence `{ failsOnCjs: true }` on every case below. - describe('Mistral integration', () => { afterAll(() => { cleanupChildProcesses(); }); - createEsmAndCjsTests( - __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(); + createEsmAndCjsTests(__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); - }, - }) - .start() - .completed(); - }); - }, - { failsOnCjs: true }, - ); + 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); + }, + }) + .start() + .completed(); + }); + }); - createEsmAndCjsTests( - __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!'); - }, - }) - .start() - .completed(); - }); - }, - { failsOnCjs: true }, - ); + createEsmAndCjsTests(__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!'); + }, + }) + .start() + .completed(); + }); + }); - createEsmAndCjsTests( - __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!'); - }, - }) - .start() - .completed(); - }); - }, - { failsOnCjs: true }, - ); + createEsmAndCjsTests(__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!'); + }, + }) + .start() + .completed(); + }); + }); - createEsmAndCjsTests( - __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(); - }); - }, - { failsOnCjs: true }, - ); + createEsmAndCjsTests(__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(); + }); + }); - createEsmAndCjsTests( - __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(); - }); - }, - { failsOnCjs: true }, - ); + createEsmAndCjsTests(__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(); + }); + }); - createEsmAndCjsTests( - __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 ag-mock-123'); - 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); + createEsmAndCjsTests(__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 ag-mock-123'); + 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(); - }); - }, - { failsOnCjs: true }, - ); + 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(); + }); + }); - createEsmAndCjsTests( - __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(); - }); - }, - { failsOnCjs: true }, - ); + createEsmAndCjsTests(__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(); + }); + }); - createEsmAndCjsTests( - __dirname, - 'scenario-fim.mjs', - 'instrument.mjs', - (createRunner, test) => { - test('creates fim text_completion spans', async () => { - await createRunner() - .expect({ - span: container => { - const fimSpan = container.items.find(s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'fimcmpl-mock123'); - expect(fimSpan).toBeDefined(); - expect(fimSpan!.name).toBe('text_completion codestral-latest'); - expect(fimSpan!.status).toBe('ok'); - expect(fimSpan!.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('text_completion'); - expect(fimSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]?.value).toBe('gen_ai.text_completion'); - expect(fimSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]?.value).toBe(ORIGIN); - expect(fimSpan!.attributes[GEN_AI_PROVIDER_NAME]?.value).toBe(PROVIDER); - expect(fimSpan!.attributes[GEN_AI_REQUEST_MODEL]?.value).toBe('codestral-latest'); - expect(fimSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]?.value).toBe(15); + createEsmAndCjsTests(__dirname, 'scenario-fim.mjs', 'instrument.mjs', (createRunner, test) => { + test('creates fim text_completion spans', async () => { + await createRunner() + .expect({ + span: container => { + const fimSpan = container.items.find(s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'fimcmpl-mock123'); + expect(fimSpan).toBeDefined(); + expect(fimSpan!.name).toBe('text_completion codestral-latest'); + expect(fimSpan!.status).toBe('ok'); + expect(fimSpan!.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('text_completion'); + expect(fimSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]?.value).toBe('gen_ai.text_completion'); + expect(fimSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]?.value).toBe(ORIGIN); + expect(fimSpan!.attributes[GEN_AI_PROVIDER_NAME]?.value).toBe(PROVIDER); + expect(fimSpan!.attributes[GEN_AI_REQUEST_MODEL]?.value).toBe('codestral-latest'); + expect(fimSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]?.value).toBe(15); - const fimStreamSpan = container.items.find( - s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'fimcmpl-stream-123', - ); - expect(fimStreamSpan).toBeDefined(); - expect(fimStreamSpan!.attributes[GEN_AI_RESPONSE_STREAMING]?.value).toBe(true); - }, - }) - .start() - .completed(); - }); - }, - { failsOnCjs: true }, - ); + const fimStreamSpan = container.items.find( + s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'fimcmpl-stream-123', + ); + expect(fimStreamSpan).toBeDefined(); + expect(fimStreamSpan!.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 030d150878d7..baf8f5d2d585 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, @@ -157,6 +158,7 @@ export { withScope, supabaseIntegration, instrumentSupabaseClient, + instrumentMistralClient, instrumentOpenAiClient, instrumentAnthropicAiClient, instrumentGoogleGenAIClient, diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index aa5aaaf33cb2..abea92048e26 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, @@ -140,6 +141,7 @@ export { updateSpanName, supabaseIntegration, instrumentSupabaseClient, + instrumentMistralClient, instrumentOpenAiClient, instrumentAnthropicAiClient, instrumentGoogleGenAIClient, diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index fe427aa97424..6bfc4c0fbd00 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, @@ -157,6 +158,7 @@ export { updateSpanName, supabaseIntegration, instrumentSupabaseClient, + instrumentMistralClient, instrumentOpenAiClient, instrumentAnthropicAiClient, instrumentGoogleGenAIClient, diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index e347cbc9fdab..ea4250f8ed60 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -125,6 +125,7 @@ export { openTelemetryIntegration, getOtlpTracesEndpoint, prismaIntegration, + instrumentMistralClient, instrumentOpenAiClient, instrumentAnthropicAiClient, instrumentGoogleGenAIClient, diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index 56840b35f654..e7d4bbaff9dd 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -140,6 +140,7 @@ export { mongooseIntegration, mysqlIntegration, mysql2Integration, + mistralAIIntegration, openAIIntegration, postgresIntegration, postgresJsIntegration, diff --git a/packages/deno/test/__snapshots__/mod.test.ts.snap b/packages/deno/test/__snapshots__/mod.test.ts.snap index 9c0b54cd65da..9b5db51bdc01 100644 --- a/packages/deno/test/__snapshots__/mod.test.ts.snap +++ b/packages/deno/test/__snapshots__/mod.test.ts.snap @@ -84,6 +84,7 @@ snapshot[`captureMessage 1`] = ` "OpenAI", "Anthropic_AI", "Google_GenAI", + "Mistral", "PostgresJs", "Firebase", ], @@ -192,6 +193,7 @@ snapshot[`captureMessage twice 1`] = ` "OpenAI", "Anthropic_AI", "Google_GenAI", + "Mistral", "PostgresJs", "Firebase", ], @@ -307,6 +309,7 @@ snapshot[`captureMessage twice 2`] = ` "OpenAI", "Anthropic_AI", "Google_GenAI", + "Mistral", "PostgresJs", "Firebase", ], diff --git a/packages/elysia/src/index.ts b/packages/elysia/src/index.ts index 904b707b1a37..244e1c61a8d1 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, @@ -134,6 +135,7 @@ export { updateSpanName, supabaseIntegration, instrumentSupabaseClient, + instrumentMistralClient, instrumentOpenAiClient, instrumentAnthropicAiClient, instrumentGoogleGenAIClient, diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index 0540b2e6879b..dc6b7b877af4 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, @@ -137,6 +138,7 @@ export { supabaseIntegration, systemErrorIntegration, instrumentSupabaseClient, + instrumentMistralClient, instrumentOpenAiClient, instrumentAnthropicAiClient, instrumentGoogleGenAIClient, diff --git a/packages/vercel-edge/src/index.ts b/packages/vercel-edge/src/index.ts index bc9be3e2aeef..b4b959cf88a6 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, + instrumentMistralClient, instrumentOpenAiClient, instrumentAnthropicAiClient, instrumentGoogleGenAIClient, From 962495de010c0fa27188755f0894ee09d5aa9d84 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Wed, 9 Sep 2026 15:31:51 +0200 Subject: [PATCH 04/24] feat(mistral): Drop fim (text_completion) from initial scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exclude the fim endpoints for now — `text_completion` is not yet used by any other AI integration, so defer it to a follow-up. Remaining scope: chat, embeddings, and agents. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../suites/tracing/mistral/scenario-fim.mjs | 125 ------------------ .../suites/tracing/mistral/test.ts | 28 ---- .../server-utils/src/ai/mistral/constants.ts | 2 - packages/server-utils/src/ai/mistral/index.ts | 8 -- .../server-utils/src/integrations/mistral.ts | 3 +- .../src/orchestrion/config/mistral.ts | 11 -- 6 files changed, 1 insertion(+), 176 deletions(-) delete mode 100644 dev-packages/node-integration-tests/suites/tracing/mistral/scenario-fim.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/mistral/scenario-fim.mjs b/dev-packages/node-integration-tests/suites/tracing/mistral/scenario-fim.mjs deleted file mode 100644 index e88d6a33fbbc..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/mistral/scenario-fim.mjs +++ /dev/null @@ -1,125 +0,0 @@ -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/fim/completions', (req, res) => { - const { model, stream } = req.body; - - 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: 'fimcmpl-stream-123', - object: 'chat.completion.chunk', - created: 1677652300, - model, - choices: [ - { - index: 0, - delta: { role: 'assistant', content: '' }, - finish_reason: null, - }, - ], - }, - { - id: 'fimcmpl-stream-123', - object: 'chat.completion.chunk', - created: 1677652300, - model, - choices: [ - { - index: 0, - delta: { content: 'def hello():' }, - finish_reason: null, - }, - ], - }, - { - id: 'fimcmpl-stream-123', - object: 'chat.completion.chunk', - created: 1677652300, - model, - choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], - usage: { prompt_tokens: 6, completion_tokens: 9, total_tokens: 15 }, - }, - ]; - - 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: 'fimcmpl-mock123', - object: 'chat.completion', - created: 1677652288, - model, - choices: [ - { - index: 0, - message: { - role: 'assistant', - content: 'def hello():\n return "world"', - }, - finish_reason: 'stop', - }, - ], - usage: { prompt_tokens: 5, completion_tokens: 10, total_tokens: 15 }, - }); - } - }); - - 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.fim.complete({ - model: 'codestral-latest', - prompt: 'def hello', - suffix: 'return res', - }); - - const stream = await client.fim.stream({ - model: 'codestral-latest', - prompt: 'def fib(n)', - }); - - 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 index f02b9b7ba504..aecb7b66414e 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mistral/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mistral/test.ts @@ -196,32 +196,4 @@ describe('Mistral integration', () => { .completed(); }); }); - - createEsmAndCjsTests(__dirname, 'scenario-fim.mjs', 'instrument.mjs', (createRunner, test) => { - test('creates fim text_completion spans', async () => { - await createRunner() - .expect({ - span: container => { - const fimSpan = container.items.find(s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'fimcmpl-mock123'); - expect(fimSpan).toBeDefined(); - expect(fimSpan!.name).toBe('text_completion codestral-latest'); - expect(fimSpan!.status).toBe('ok'); - expect(fimSpan!.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('text_completion'); - expect(fimSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]?.value).toBe('gen_ai.text_completion'); - expect(fimSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]?.value).toBe(ORIGIN); - expect(fimSpan!.attributes[GEN_AI_PROVIDER_NAME]?.value).toBe(PROVIDER); - expect(fimSpan!.attributes[GEN_AI_REQUEST_MODEL]?.value).toBe('codestral-latest'); - expect(fimSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]?.value).toBe(15); - - const fimStreamSpan = container.items.find( - s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'fimcmpl-stream-123', - ); - expect(fimStreamSpan).toBeDefined(); - expect(fimStreamSpan!.attributes[GEN_AI_RESPONSE_STREAMING]?.value).toBe(true); - }, - }) - .start() - .completed(); - }); - }); }); diff --git a/packages/server-utils/src/ai/mistral/constants.ts b/packages/server-utils/src/ai/mistral/constants.ts index 8bdf92009997..10bfcfa9bf18 100644 --- a/packages/server-utils/src/ai/mistral/constants.ts +++ b/packages/server-utils/src/ai/mistral/constants.ts @@ -10,6 +10,4 @@ export const MISTRAL_METHOD_REGISTRY = { 'embeddings.create': { operation: 'embeddings' }, 'agents.complete': { operation: 'invoke_agent' }, 'agents.stream': { operation: 'invoke_agent', streaming: true }, - 'fim.complete': { operation: 'text_completion' }, - 'fim.stream': { operation: 'text_completion', 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 index 912c18cb9f0f..45d15f8fa160 100644 --- a/packages/server-utils/src/ai/mistral/index.ts +++ b/packages/server-utils/src/ai/mistral/index.ts @@ -65,14 +65,6 @@ export function addRequestAttributes(span: Span, params: Record return; } - // FIM completions carry a raw `prompt` string rather than a messages array. - if (operationName === 'text_completion') { - if (typeof params.prompt === 'string' && params.prompt.length > 0) { - span.setAttribute(GEN_AI_INPUT_MESSAGES, stringify(params.prompt)); - } - return; - } - const src = 'messages' in params ? params.messages : undefined; if (!src || (Array.isArray(src) && src.length === 0)) { return; diff --git a/packages/server-utils/src/integrations/mistral.ts b/packages/server-utils/src/integrations/mistral.ts index 9a85fad02d2e..013cdd3ab0be 100644 --- a/packages/server-utils/src/integrations/mistral.ts +++ b/packages/server-utils/src/integrations/mistral.ts @@ -27,7 +27,6 @@ const INSTRUMENTED_CHANNELS = [ { channel: CHANNELS.MISTRAL_CHAT, operation: 'chat' }, { channel: CHANNELS.MISTRAL_EMBEDDINGS, operation: 'embeddings' }, { channel: CHANNELS.MISTRAL_AGENTS, operation: 'invoke_agent' }, - { channel: CHANNELS.MISTRAL_FIM, operation: 'text_completion' }, ] as const; /** @@ -129,7 +128,7 @@ function wrapStreamResult(span: Span, data: MistralChannelContext, options: Mist /** * Diagnostics-channel-based Mistral integration. Subscribes to the `orchestrion:@mistralai/mistralai:*` - * diagnostics_channels injected into the SDK's chat, embeddings, agents and fim methods, so it requires + * diagnostics_channels injected into the SDK's chat, embeddings and agents methods, so it requires * the Sentry runtime hook or bundler plugin. */ export const mistralAIIntegration = defineIntegration(_mistralAIIntegration); diff --git a/packages/server-utils/src/orchestrion/config/mistral.ts b/packages/server-utils/src/orchestrion/config/mistral.ts index 76e34b2d1986..a81000ebe389 100644 --- a/packages/server-utils/src/orchestrion/config/mistral.ts +++ b/packages/server-utils/src/orchestrion/config/mistral.ts @@ -33,16 +33,6 @@ export const mistralConfig = [ module: { ...MODULE, filePath: 'esm/sdk/agents.js' }, functionQuery: { className: 'Agents', methodName: 'stream', kind: 'Auto' as const }, }, - { - channelName: 'fim', - module: { ...MODULE, filePath: 'esm/sdk/fim.js' }, - functionQuery: { className: 'Fim', methodName: 'complete', kind: 'Auto' as const }, - }, - { - channelName: 'fim', - module: { ...MODULE, filePath: 'esm/sdk/fim.js' }, - functionQuery: { className: 'Fim', methodName: 'stream', kind: 'Auto' as const }, - }, ] satisfies InstrumentationConfig[]; export const mistralModuleNames = getModuleNames(mistralConfig); @@ -51,5 +41,4 @@ export const mistralChannels = { MISTRAL_CHAT: 'orchestrion:@mistralai/mistralai:chat', MISTRAL_EMBEDDINGS: 'orchestrion:@mistralai/mistralai:embeddings', MISTRAL_AGENTS: 'orchestrion:@mistralai/mistralai:agents', - MISTRAL_FIM: 'orchestrion:@mistralai/mistralai:fim', } as const; From befee31c3014b933dbd4e987717e11820e51ba4b Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Wed, 9 Sep 2026 15:39:25 +0200 Subject: [PATCH 05/24] test(mistral): Run the suite 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 (works on 24/26, fails on 22). The SDK's native mode is ESM, so use `createEsmTests` and cover it there only. Also give the embeddings mock a distinct id per call shape so the single-input span is targeted unambiguously. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../tracing/mistral/scenario-embeddings.mjs | 5 +++-- .../suites/tracing/mistral/test.ts | 19 +++++++++++-------- 2 files changed, 14 insertions(+), 10 deletions(-) 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 index 070cef3bcb3f..f05ac044c411 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mistral/scenario-embeddings.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/mistral/scenario-embeddings.mjs @@ -7,15 +7,16 @@ function startMockServer() { app.use(express.json()); app.post('/v1/embeddings', (req, res) => { - const { model } = req.body; + 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: 'embd-mock123', + id: Array.isArray(inputs) ? 'embd-mock-multi' : 'embd-mock123', object: 'list', model, data: [{ object: 'embedding', embedding: [0.1, 0.2, 0.3], index: 0 }], diff --git a/dev-packages/node-integration-tests/suites/tracing/mistral/test.ts b/dev-packages/node-integration-tests/suites/tracing/mistral/test.ts index aecb7b66414e..3961e8b6d9f7 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mistral/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mistral/test.ts @@ -19,17 +19,20 @@ import { GEN_AI_USAGE_TOTAL_TOKENS, } from '@sentry/conventions/attributes'; import { afterAll, describe, expect } from 'vitest'; -import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; +import { cleanupChildProcesses, createEsmTests } from '../../../utils/runner'; const PROVIDER = 'mistral'; const ORIGIN = 'auto.ai.mistral'; +// 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(); }); - createEsmAndCjsTests(__dirname, 'scenario-chat.mjs', 'instrument.mjs', (createRunner, test) => { + createEsmTests(__dirname, 'scenario-chat.mjs', 'instrument.mjs', (createRunner, test) => { test('creates chat spans with genAI recording disabled', async () => { await createRunner() .expect({ @@ -69,7 +72,7 @@ describe('Mistral integration', () => { }); }); - createEsmAndCjsTests(__dirname, 'scenario-chat.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { + createEsmTests(__dirname, 'scenario-chat.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { test('records chat inputs and outputs with PII enabled', async () => { await createRunner() .expect({ @@ -89,7 +92,7 @@ describe('Mistral integration', () => { }); }); - createEsmAndCjsTests(__dirname, 'scenario-chat.mjs', 'instrument-with-options.mjs', (createRunner, test) => { + 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({ @@ -105,7 +108,7 @@ describe('Mistral integration', () => { }); }); - createEsmAndCjsTests(__dirname, 'scenario-embeddings.mjs', 'instrument.mjs', (createRunner, test) => { + createEsmTests(__dirname, 'scenario-embeddings.mjs', 'instrument.mjs', (createRunner, test) => { test('creates embeddings spans', async () => { await createRunner() .expect({ @@ -131,7 +134,7 @@ describe('Mistral integration', () => { }); }); - createEsmAndCjsTests(__dirname, 'scenario-embeddings.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { + createEsmTests(__dirname, 'scenario-embeddings.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { test('records embeddings input with PII enabled', async () => { await createRunner() .expect({ @@ -148,7 +151,7 @@ describe('Mistral integration', () => { }); }); - createEsmAndCjsTests(__dirname, 'scenario-agents.mjs', 'instrument.mjs', (createRunner, test) => { + createEsmTests(__dirname, 'scenario-agents.mjs', 'instrument.mjs', (createRunner, test) => { test('creates invoke_agent spans', async () => { await createRunner() .expect({ @@ -179,7 +182,7 @@ describe('Mistral integration', () => { }); }); - createEsmAndCjsTests(__dirname, 'scenario-agents.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { + createEsmTests(__dirname, 'scenario-agents.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { test('records agent inputs and outputs with PII enabled', async () => { await createRunner() .expect({ From c2109c7d44785c3472eca8b79b6047dc127c6240 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Wed, 9 Sep 2026 16:38:05 +0200 Subject: [PATCH 06/24] fix(mistral): Skip Mistral provider wrapping when LangChain is active `@langchain/mistralai` drives the `@mistralai/mistralai` SDK under the hood, so with the LangChain integration active both it and `mistralAIIntegration` would instrument the same call, producing two spans. Add `Mistral` to LangChain's `SKIPPED_PROVIDERS`, matching the existing OpenAI/Anthropic/Google handling. This also puts the previously unused `MISTRAL_INTEGRATION_NAME` constant to use. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/server-utils/src/integrations/langchain.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 { From 224d3e8c21ab772178101e62ab7138d3c7f0be28 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 11 Sep 2026 11:08:51 +0200 Subject: [PATCH 07/24] feat(mistral): Capture tool definitions and tool calls (OpenAI parity) - Record request tool definitions on `gen_ai.tool.definitions` (gated by recordInputs), restoring the `recordInputs` arg to `extractRequestAttributes`. - Accumulate streamed tool calls by index (concatenating fragmented `function.arguments`) and emit them on `gen_ai.response.tool_calls`; non-streaming tool calls were already captured. - Add `scenario-tools.mjs` + a test asserting tool definitions and tool calls for both streaming and non-streaming chat. Brings Mistral to parity with the OpenAI integration for function tools. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../suites/tracing/mistral/scenario-tools.mjs | 136 ++++++++++++++++++ .../suites/tracing/mistral/test.ts | 33 +++++ packages/server-utils/src/ai/mistral/index.ts | 24 +++- .../server-utils/src/ai/mistral/streaming.ts | 29 +++- packages/server-utils/src/ai/mistral/types.ts | 16 ++- .../server-utils/src/integrations/mistral.ts | 2 +- 6 files changed, 234 insertions(+), 6 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/mistral/scenario-tools.mjs 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 index 3961e8b6d9f7..19f9099ec27f 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mistral/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mistral/test.ts @@ -13,7 +13,9 @@ import { GEN_AI_RESPONSE_MODEL, GEN_AI_RESPONSE_STREAMING, 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, @@ -199,4 +201,35 @@ describe('Mistral integration', () => { .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(); + }); + }); }); diff --git a/packages/server-utils/src/ai/mistral/index.ts b/packages/server-utils/src/ai/mistral/index.ts index 45d15f8fa160..a7d756628a01 100644 --- a/packages/server-utils/src/ai/mistral/index.ts +++ b/packages/server-utils/src/ai/mistral/index.ts @@ -15,6 +15,7 @@ import { 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 { @@ -29,10 +30,24 @@ import { instrumentStream } from './streaming'; import type { MistralOptions } from './types'; import { addResponseAttributes, extractRequestParameters, getModelForSpanName } 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. */ -export function extractRequestAttributes(args: unknown[], operationName: string): Record { +export function extractRequestAttributes( + args: unknown[], + operationName: string, + recordInputs: boolean, +): Record { const attributes: Record = { [GEN_AI_PROVIDER_NAME]: 'mistral', [GEN_AI_OPERATION_NAME]: operationName, @@ -46,6 +61,11 @@ export function extractRequestAttributes(args: unknown[], operationName: 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)); } @@ -89,7 +109,7 @@ function instrumentMethod( ): (...args: T) => Promise { return function instrumentedCall(...args: T): Promise { const operationName = instrumentedMethod.operation || 'unknown'; - const requestAttributes = extractRequestAttributes(args, operationName); + const requestAttributes = extractRequestAttributes(args, operationName, !!options.recordInputs); const params = args[0] as Record | undefined; const model = getModelForSpanName(params, operationName); diff --git a/packages/server-utils/src/ai/mistral/streaming.ts b/packages/server-utils/src/ai/mistral/streaming.ts index 356584cceb3d..d2f20a2d36e3 100644 --- a/packages/server-utils/src/ai/mistral/streaming.ts +++ b/packages/server-utils/src/ai/mistral/streaming.ts @@ -1,6 +1,6 @@ import type { Span } from '@sentry/core'; import { endStreamSpan } from '../core/utils'; -import type { MistralCompletionChunk } from './types'; +import type { MistralCompletionChunk, MistralToolCall } from './types'; /** * State accumulated while consuming a Mistral event stream. @@ -13,6 +13,27 @@ interface StreamingState { promptTokens: number | undefined; completionTokens: number | undefined; totalTokens: number | undefined; + /** Tool calls accumulated by their delta `index`; `function.arguments` arrives fragmented. */ + toolCalls: Record; +} + +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 { @@ -31,6 +52,9 @@ function processChunk(chunk: MistralCompletionChunk, state: StreamingState, reco if (recordOutputs && typeof choice.delta?.content === 'string' && choice.delta.content) { state.responseTexts.push(choice.delta.content); } + if (recordOutputs && choice.delta?.toolCalls) { + processToolCalls(choice.delta.toolCalls, state); + } if (choice.finishReason) { state.finishReasons.push(choice.finishReason); } @@ -54,6 +78,7 @@ export async function* instrumentStream( promptTokens: undefined, completionTokens: undefined, totalTokens: undefined, + toolCalls: {}, }; try { @@ -68,6 +93,6 @@ export async function* instrumentStream( yield event; } } finally { - endStreamSpan(span, { ...state, toolCalls: [] }, recordOutputs); + endStreamSpan(span, { ...state, toolCalls: Object.values(state.toolCalls) }, recordOutputs); } } diff --git a/packages/server-utils/src/ai/mistral/types.ts b/packages/server-utils/src/ai/mistral/types.ts index c2c4ffafa320..840830b8e120 100644 --- a/packages/server-utils/src/ai/mistral/types.ts +++ b/packages/server-utils/src/ai/mistral/types.ts @@ -3,6 +3,20 @@ 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 @@ -13,7 +27,7 @@ export interface MistralCompletionChunk { id: string; model: string; choices?: Array<{ - delta?: { content?: string | Array | null }; + delta?: { content?: string | Array | null; toolCalls?: MistralToolCall[] | null }; finishReason?: string | null; }>; usage?: { diff --git a/packages/server-utils/src/integrations/mistral.ts b/packages/server-utils/src/integrations/mistral.ts index 013cdd3ab0be..741801db0743 100644 --- a/packages/server-utils/src/integrations/mistral.ts +++ b/packages/server-utils/src/integrations/mistral.ts @@ -79,7 +79,7 @@ function createGenAiSpan(data: MistralChannelContext, operation: string, options const { recordInputs } = resolveAIRecordingOptions(options); - const attributes = extractRequestAttributes(args, operation); + const attributes = extractRequestAttributes(args, operation, recordInputs); attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] = ORIGIN; const model = getModelForSpanName(params, operation); const client = getClient(); From 4d5db7832c0cb4750a3eef7244e08177151eaff5 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 11 Sep 2026 14:37:22 +0200 Subject: [PATCH 08/24] refactor(mistral): Source span-name model from extracted attributes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read the span-name token from the already-extracted request attributes (model, or the agent name for invoke_agent spans) instead of re-reading the raw params — mirroring the OpenAI integration. Removes the `getModelForSpanName` helper. No change to emitted attributes. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/server-utils/src/ai/mistral/index.ts | 8 ++++++-- packages/server-utils/src/ai/mistral/utils.ts | 11 ----------- packages/server-utils/src/integrations/mistral.ts | 7 +++++-- 3 files changed, 11 insertions(+), 15 deletions(-) diff --git a/packages/server-utils/src/ai/mistral/index.ts b/packages/server-utils/src/ai/mistral/index.ts index a7d756628a01..a64dee47b3d7 100644 --- a/packages/server-utils/src/ai/mistral/index.ts +++ b/packages/server-utils/src/ai/mistral/index.ts @@ -14,6 +14,7 @@ import { GEN_AI_INPUT_MESSAGES, GEN_AI_OPERATION_NAME, GEN_AI_PROVIDER_NAME, + GEN_AI_REQUEST_MODEL, GEN_AI_SYSTEM_INSTRUCTIONS, GEN_AI_TOOL_DEFINITIONS, } from '@sentry/conventions/attributes'; @@ -28,7 +29,7 @@ import { import { MISTRAL_METHOD_REGISTRY } from './constants'; import { instrumentStream } from './streaming'; import type { MistralOptions } from './types'; -import { addResponseAttributes, extractRequestParameters, getModelForSpanName } from './utils'; +import { addResponseAttributes, extractRequestParameters } from './utils'; /** * Serialize tool definitions from request parameters, if present. @@ -112,7 +113,10 @@ function instrumentMethod( const requestAttributes = extractRequestAttributes(args, operationName, !!options.recordInputs); const params = args[0] as Record | undefined; - const model = getModelForSpanName(params, operationName); + // Agent calls carry no model, so their span name uses the agent id (recorded as gen_ai.agent.name). + const model = + (requestAttributes[operationName === 'invoke_agent' ? GEN_AI_AGENT_NAME : GEN_AI_REQUEST_MODEL] as string) || + 'unknown'; // `*.stream` methods are always streaming; `complete` methods stream only with `stream: true`. const isStreamRequested = !!instrumentedMethod.streaming || params?.stream === true; const client = getClient(); diff --git a/packages/server-utils/src/ai/mistral/utils.ts b/packages/server-utils/src/ai/mistral/utils.ts index 3a477e960321..74e2c81945fd 100644 --- a/packages/server-utils/src/ai/mistral/utils.ts +++ b/packages/server-utils/src/ai/mistral/utils.ts @@ -19,17 +19,6 @@ import { } from '@sentry/conventions/attributes'; import { GEN_AI_REQUEST_STREAM_ATTRIBUTE } from '../core/gen-ai-attributes'; -/** - * The token that follows the operation in a span name. Agents have no `model` at request time, - * so their span is named after the invoked agent id instead. - */ -export function getModelForSpanName(params: Record | undefined, operationName: string): string { - if (operationName === 'invoke_agent') { - return (params?.agentId as string) || 'unknown'; - } - return (params?.model as string) || 'unknown'; -} - /** * Turn a Mistral message content (string or content-chunk array) into a plain string. */ diff --git a/packages/server-utils/src/integrations/mistral.ts b/packages/server-utils/src/integrations/mistral.ts index 741801db0743..1270ced7e70f 100644 --- a/packages/server-utils/src/integrations/mistral.ts +++ b/packages/server-utils/src/integrations/mistral.ts @@ -8,11 +8,12 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startInactiveSpan, } from '@sentry/core'; +import { GEN_AI_AGENT_NAME, GEN_AI_REQUEST_MODEL } from '@sentry/conventions/attributes'; import { getGenAiSpanOp, resolveAIRecordingOptions } from '../ai/core/utils'; import { addRequestAttributes, extractRequestAttributes } from '../ai/mistral'; import { instrumentStream } from '../ai/mistral/streaming'; import type { MistralOptions } from '../ai/mistral/types'; -import { addResponseAttributes, getModelForSpanName } from '../ai/mistral/utils'; +import { addResponseAttributes } from '../ai/mistral/utils'; import { CHANNELS } from '../orchestrion/channels'; import { mistralModuleNames } from '../orchestrion/config/mistral'; import { invokeOrchestrionInstrumentation } from '../orchestrion/instrumentation'; @@ -81,7 +82,9 @@ function createGenAiSpan(data: MistralChannelContext, operation: string, options const attributes = extractRequestAttributes(args, operation, recordInputs); attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] = ORIGIN; - const model = getModelForSpanName(params, operation); + // Agent calls carry no model, so their span name uses the agent id (recorded as gen_ai.agent.name). + const model = + (attributes[operation === 'invoke_agent' ? GEN_AI_AGENT_NAME : GEN_AI_REQUEST_MODEL] as string) || 'unknown'; const client = getClient(); const span = startInactiveSpan({ From 1af6176d9ff46a4115ef9905e695cd85c4f494a1 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 11 Sep 2026 14:44:27 +0200 Subject: [PATCH 09/24] refactor(mistral): Simplify streaming to match OpenAI - Hoist the single `recordOutputs` check to wrap both content and tool calls (was checked twice), matching the OpenAI streaming path. - Drop the dead `?? ''` in tool-call argument accumulation (the first chunk already seeds it to ''). - Simplify the chunk unwrap to a single object guard. No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../server-utils/src/ai/mistral/streaming.ts | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/server-utils/src/ai/mistral/streaming.ts b/packages/server-utils/src/ai/mistral/streaming.ts index d2f20a2d36e3..16a12921fb0f 100644 --- a/packages/server-utils/src/ai/mistral/streaming.ts +++ b/packages/server-utils/src/ai/mistral/streaming.ts @@ -31,7 +31,7 @@ function processToolCalls(toolCalls: MistralToolCall[], state: StreamingState): 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}`; + existing.function.arguments = `${existing.function.arguments}${toolCall.function.arguments}`; } } } @@ -49,11 +49,13 @@ function processChunk(chunk: MistralCompletionChunk, state: StreamingState, reco } for (const choice of chunk.choices ?? []) { - if (recordOutputs && typeof choice.delta?.content === 'string' && choice.delta.content) { - state.responseTexts.push(choice.delta.content); - } - if (recordOutputs && choice.delta?.toolCalls) { - processToolCalls(choice.delta.toolCalls, state); + if (recordOutputs) { + if (typeof choice.delta?.content === 'string' && choice.delta.content) { + state.responseTexts.push(choice.delta.content); + } + if (choice.delta?.toolCalls) { + processToolCalls(choice.delta.toolCalls, state); + } } if (choice.finishReason) { state.finishReasons.push(choice.finishReason); @@ -83,10 +85,8 @@ export async function* instrumentStream( try { for await (const event of stream) { - const chunk = - event && typeof event === 'object' && 'data' in event - ? (event as { data: MistralCompletionChunk }).data - : (event as unknown as MistralCompletionChunk); + // Mistral streams `CompletionEvent` objects that wrap the chunk under `data`. + const chunk = (event as { data?: MistralCompletionChunk })?.data; if (chunk && typeof chunk === 'object') { processChunk(chunk, state, recordOutputs); } From ed44af224297a81b94fe0117fe5b8852723ca705 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 11 Sep 2026 14:58:47 +0200 Subject: [PATCH 10/24] fix(mistral): Use `mistralai` as the provider name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align `gen_ai.provider.name` (and the matching `sentry.origin`) with the package-scope slug `mistralai` — consistent with how `@google/genai` maps to `google_genai`, and with the existing LangChain path which already reports Mistral as `mistralai`. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../node-integration-tests/suites/tracing/mistral/test.ts | 4 ++-- packages/server-utils/src/ai/mistral/index.ts | 4 ++-- packages/server-utils/src/integrations/mistral.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/mistral/test.ts b/dev-packages/node-integration-tests/suites/tracing/mistral/test.ts index 19f9099ec27f..ea3feb05392d 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mistral/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mistral/test.ts @@ -23,8 +23,8 @@ import { import { afterAll, describe, expect } from 'vitest'; import { cleanupChildProcesses, createEsmTests } from '../../../utils/runner'; -const PROVIDER = 'mistral'; -const ORIGIN = 'auto.ai.mistral'; +const PROVIDER = 'mistralai'; +const ORIGIN = 'auto.ai.mistralai'; // 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 diff --git a/packages/server-utils/src/ai/mistral/index.ts b/packages/server-utils/src/ai/mistral/index.ts index a64dee47b3d7..2fdb97e3e72f 100644 --- a/packages/server-utils/src/ai/mistral/index.ts +++ b/packages/server-utils/src/ai/mistral/index.ts @@ -50,9 +50,9 @@ export function extractRequestAttributes( recordInputs: boolean, ): Record { const attributes: Record = { - [GEN_AI_PROVIDER_NAME]: 'mistral', + [GEN_AI_PROVIDER_NAME]: 'mistralai', [GEN_AI_OPERATION_NAME]: operationName, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ai.mistral', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ai.mistralai', }; if (args.length > 0 && typeof args[0] === 'object' && args[0] !== null) { diff --git a/packages/server-utils/src/integrations/mistral.ts b/packages/server-utils/src/integrations/mistral.ts index 1270ced7e70f..cdbd16e5b841 100644 --- a/packages/server-utils/src/integrations/mistral.ts +++ b/packages/server-utils/src/integrations/mistral.ts @@ -21,7 +21,7 @@ import { bindTracingChannelToSpan } from '../tracing-channel'; const INTEGRATION_NAME = 'Mistral' as const; -const ORIGIN = 'auto.ai.mistral'; +const ORIGIN = 'auto.ai.mistralai'; // Each instrumented channel maps to the gen_ai operation its span reports. const INSTRUMENTED_CHANNELS = [ From ddcd554d806e25c07ec4fd96a882e9eeb0fdaa13 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 11 Sep 2026 15:01:09 +0200 Subject: [PATCH 11/24] refactor(mistral): Rename instrumentMistralClient to instrumentMistralAiClient Align the manual instrumentation API with the other providers, which all use the `instrumentAiClient` form (`instrumentOpenAiClient`, `instrumentAnthropicAiClient`, `instrumentWorkersAiClient`). Renamed the export and all per-package re-exports. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/astro/src/index.server.ts | 2 +- packages/aws-serverless/src/index.ts | 2 +- packages/bun/src/index.ts | 2 +- packages/cloudflare/src/index.ts | 2 +- packages/elysia/src/index.ts | 2 +- packages/google-cloud-serverless/src/index.ts | 2 +- packages/node/src/index.ts | 2 +- packages/server-utils/src/ai/index.ts | 2 +- packages/server-utils/src/ai/mistral/index.ts | 2 +- packages/vercel-edge/src/index.ts | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index baf8f5d2d585..5a1d55436ed8 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -158,7 +158,7 @@ export { withScope, supabaseIntegration, instrumentSupabaseClient, - instrumentMistralClient, + instrumentMistralAiClient, instrumentOpenAiClient, instrumentAnthropicAiClient, instrumentGoogleGenAIClient, diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index abea92048e26..f08830178092 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -141,7 +141,7 @@ export { updateSpanName, supabaseIntegration, instrumentSupabaseClient, - instrumentMistralClient, + instrumentMistralAiClient, instrumentOpenAiClient, instrumentAnthropicAiClient, instrumentGoogleGenAIClient, diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index 6bfc4c0fbd00..55bcb9a2ee22 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -158,7 +158,7 @@ export { updateSpanName, supabaseIntegration, instrumentSupabaseClient, - instrumentMistralClient, + instrumentMistralAiClient, instrumentOpenAiClient, instrumentAnthropicAiClient, instrumentGoogleGenAIClient, diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index ea4250f8ed60..80501b3c1957 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -125,7 +125,7 @@ export { openTelemetryIntegration, getOtlpTracesEndpoint, prismaIntegration, - instrumentMistralClient, + instrumentMistralAiClient, instrumentOpenAiClient, instrumentAnthropicAiClient, instrumentGoogleGenAIClient, diff --git a/packages/elysia/src/index.ts b/packages/elysia/src/index.ts index 244e1c61a8d1..6af05fefe34e 100644 --- a/packages/elysia/src/index.ts +++ b/packages/elysia/src/index.ts @@ -135,7 +135,7 @@ export { updateSpanName, supabaseIntegration, instrumentSupabaseClient, - instrumentMistralClient, + instrumentMistralAiClient, instrumentOpenAiClient, instrumentAnthropicAiClient, instrumentGoogleGenAIClient, diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index dc6b7b877af4..00107c7cd597 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -138,7 +138,7 @@ export { supabaseIntegration, systemErrorIntegration, instrumentSupabaseClient, - instrumentMistralClient, + instrumentMistralAiClient, instrumentOpenAiClient, instrumentAnthropicAiClient, instrumentGoogleGenAIClient, diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 8c894dba9cf3..868b6ccef3d6 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -41,7 +41,7 @@ export { instrumentOpenAiClient, instrumentAnthropicAiClient, instrumentGoogleGenAIClient, - instrumentMistralClient, + instrumentMistralAiClient, createLangChainCallbackHandler, instrumentLangChainEmbeddings, instrumentStateGraph, diff --git a/packages/server-utils/src/ai/index.ts b/packages/server-utils/src/ai/index.ts index 6c1174287ac7..7e5d08d381d0 100644 --- a/packages/server-utils/src/ai/index.ts +++ b/packages/server-utils/src/ai/index.ts @@ -7,7 +7,7 @@ export { instrumentOpenAiClient } from './openai'; export { instrumentAnthropicAiClient } from './anthropic-ai'; export { instrumentGoogleGenAIClient } from './google-genai'; -export { instrumentMistralClient } from './mistral'; +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/index.ts b/packages/server-utils/src/ai/mistral/index.ts index 2fdb97e3e72f..ea2399d6b079 100644 --- a/packages/server-utils/src/ai/mistral/index.ts +++ b/packages/server-utils/src/ai/mistral/index.ts @@ -211,6 +211,6 @@ function createDeepProxy(target: T, currentPath = '', options: * Instrument a Mistral client with Sentry tracing. * Can be used across Node.js, Cloudflare Workers, and Vercel Edge. */ -export function instrumentMistralClient(client: T, options?: MistralOptions): T { +export function instrumentMistralAiClient(client: T, options?: MistralOptions): T { return createDeepProxy(client, '', resolveAIRecordingOptions(options)); } diff --git a/packages/vercel-edge/src/index.ts b/packages/vercel-edge/src/index.ts index b4b959cf88a6..983df5a23b7f 100644 --- a/packages/vercel-edge/src/index.ts +++ b/packages/vercel-edge/src/index.ts @@ -105,7 +105,7 @@ export { trpcMiddleware, wrapMcpServerWithSentry } from '@sentry/core/server'; export { openTelemetryIntegration, getOtlpTracesEndpoint, - instrumentMistralClient, + instrumentMistralAiClient, instrumentOpenAiClient, instrumentAnthropicAiClient, instrumentGoogleGenAIClient, From a06b6d96c080f185c0c50679e73a9c618a2b507d Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 11 Sep 2026 15:32:12 +0200 Subject: [PATCH 12/24] fix(node): Address Mistral stream review comments - Set error status on the span when a Mistral stream throws mid-iteration, so failed streams no longer end as successful gen_ai spans. - Decide streaming from the SDK method alone; `stream: true` on `complete` still returns a completion in v2, so it must not be wrapped as a stream. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/server-utils/src/ai/mistral/index.ts | 5 +++-- packages/server-utils/src/ai/mistral/streaming.ts | 4 ++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/server-utils/src/ai/mistral/index.ts b/packages/server-utils/src/ai/mistral/index.ts index ea2399d6b079..2aa2b1ade373 100644 --- a/packages/server-utils/src/ai/mistral/index.ts +++ b/packages/server-utils/src/ai/mistral/index.ts @@ -117,8 +117,9 @@ function instrumentMethod( const model = (requestAttributes[operationName === 'invoke_agent' ? GEN_AI_AGENT_NAME : GEN_AI_REQUEST_MODEL] as string) || 'unknown'; - // `*.stream` methods are always streaming; `complete` methods stream only with `stream: true`. - const isStreamRequested = !!instrumentedMethod.streaming || params?.stream === true; + // v2 streams only through dedicated `*.stream` methods; `stream: true` on `complete` still + // returns a completion, so streaming is decided by the method, not the request params. + const isStreamRequested = !!instrumentedMethod.streaming; const client = getClient(); const spanConfig = { diff --git a/packages/server-utils/src/ai/mistral/streaming.ts b/packages/server-utils/src/ai/mistral/streaming.ts index 16a12921fb0f..9f61d8cf44a7 100644 --- a/packages/server-utils/src/ai/mistral/streaming.ts +++ b/packages/server-utils/src/ai/mistral/streaming.ts @@ -1,4 +1,5 @@ import type { Span } from '@sentry/core'; +import { SPAN_STATUS_ERROR } from '@sentry/core'; import { endStreamSpan } from '../core/utils'; import type { MistralCompletionChunk, MistralToolCall } from './types'; @@ -92,6 +93,9 @@ export async function* instrumentStream( } yield event; } + } catch (error) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + throw error; } finally { endStreamSpan(span, { ...state, toolCalls: Object.values(state.toolCalls) }, recordOutputs); } From dafd2c6083e9c024582ca087fe5a970e3c4d2a9f Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 11 Sep 2026 15:49:23 +0200 Subject: [PATCH 13/24] chore(node): Bump @sentry/node size limits for Mistral integration The default-on Mistral AI integration grows the @sentry/node bundle past the current budgets (130.52 kB > 130, 109.42 kB > 109). Raise both limits. Co-Authored-By: Claude Opus 4.8 (1M context) --- .size-limit.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index 38540ed229bd..9f5330e9ce56 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -406,7 +406,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '130 KB', + limit: '131 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -452,7 +452,7 @@ module.exports = [ path: 'packages/node/build/esm/index.js', import: createImport('init'), gzip: true, - limit: '109 KB', + limit: '110 KB', disablePlugins: ['@size-limit/esbuild'], ignore: [...builtinModules, ...nodePrefixedBuiltinModules], modifyWebpackConfig: function (config) { From bf2477752a76b68d6d750d0a225545c972f4bf72 Mon Sep 17 00:00:00 2001 From: RulaKhaled Date: Mon, 14 Sep 2026 18:18:03 +0300 Subject: [PATCH 14/24] fix(node): Address Mistral integration review findings Follow-up fixes on top of the Mistral AI integration. - Patch streamed results in place instead of replacing them. `EventStream` extends `ReadableStream`, so the manual path was handing callers a bare async generator and dropping `getReader`, `tee`, `pipeTo` and `cancel`. - End the span when a stream is drained through `getReader()` or cancelled, not only through `for await`. A first-touch claim keeps chunks from being counted twice when one drain path drives the other. - Leave the agent id out of `invoke_agent` span names under span streaming. Ids are one value per agent, which is the cardinality the surrounding code was already trying to avoid. The id stays on `gen_ai.agent.name`. - Derive `gen_ai.request.stream` from the called method. v2 streams only through the dedicated `*.stream` methods and their `stream` request field is optional, so the params-derived value was missing on most streaming calls. Streaming methods now publish on their own channels. - Instrument `chat.parse` and `chat.parseStream`. They call the request functions directly rather than `this.complete`, so structured-output calls produced no span at all. - Flatten content-part arrays in streamed deltas through `contentToString`, so structured content is no longer dropped from the recorded response. - Write `gen_ai.response.text` as a stringified array of messages, one entry per choice, matching the attribute spec instead of one concatenated blob. - Set `gen_ai.output.messages` on both paths. It replaces the deprecated `gen_ai.response.text`, and Relay's migration of the tool-calls half is lossy. `setOutputMessagesAttribute` moves from `ai/workers-ai/utils` to `ai/core/utils` so both providers share it. - Single-source the integration name, provider and origin in `ai/mistral/constants`. Adds unit tests for the manual client (24 cases) and an integration scenario covering `instrumentMistralAiClient`, which had no coverage. Bumps the two `@sentry/node` size limits by 1 KB. Co-Authored-By: Claude Opus 5 --- .size-limit.js | 4 +- .../tracing/mistral/instrument-manual.mjs | 14 + .../tracing/mistral/scenario-manual.mjs | 99 ++++ .../suites/tracing/mistral/test.ts | 61 ++- packages/server-utils/src/ai/core/utils.ts | 76 ++- .../server-utils/src/ai/mistral/constants.ts | 11 + packages/server-utils/src/ai/mistral/index.ts | 66 +-- .../server-utils/src/ai/mistral/streaming.ts | 218 +++++++- packages/server-utils/src/ai/mistral/utils.ts | 58 +- .../server-utils/src/ai/workers-ai/utils.ts | 54 +- .../server-utils/src/integrations/mistral.ts | 78 +-- .../src/orchestrion/config/mistral.ts | 27 +- .../test/ai/lib/tracing/mistral.test.ts | 495 ++++++++++++++++++ 13 files changed, 1067 insertions(+), 194 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/mistral/instrument-manual.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/mistral/scenario-manual.mjs create mode 100644 packages/server-utils/test/ai/lib/tracing/mistral.test.ts diff --git a/.size-limit.js b/.size-limit.js index 9f5330e9ce56..838fe19c9b75 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -406,7 +406,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '131 KB', + limit: '132 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -452,7 +452,7 @@ module.exports = [ path: 'packages/node/build/esm/index.js', import: createImport('init'), gzip: true, - limit: '110 KB', + limit: '111 KB', disablePlugins: ['@size-limit/esbuild'], ignore: [...builtinModules, ...nodePrefixedBuiltinModules], modifyWebpackConfig: function (config) { 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..62a3870004b8 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mistral/instrument-manual.mjs @@ -0,0 +1,14 @@ +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, + traceLifecycle: 'stream', + // `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/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/test.ts b/dev-packages/node-integration-tests/suites/tracing/mistral/test.ts index ea3feb05392d..285608b57f3f 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mistral/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mistral/test.ts @@ -12,6 +12,7 @@ import { 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, @@ -25,6 +26,8 @@ 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 @@ -67,6 +70,15 @@ describe('Mistral integration', () => { 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() @@ -86,7 +98,10 @@ describe('Mistral integration', () => { 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_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() @@ -102,7 +117,10 @@ describe('Mistral integration', () => { 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_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() @@ -162,7 +180,9 @@ describe('Mistral integration', () => { s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'agentcmpl-mock123', ); expect(agentSpan).toBeDefined(); - expect(agentSpan!.name).toBe('invoke_agent ag-mock-123'); + // Agent ids are one value per agent, so the name stays at the bare operation under span + // streaming; the id is still recorded on `gen_ai.agent.name`. + 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'); @@ -194,7 +214,7 @@ describe('Mistral integration', () => { ); 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!'); + expect(agentSpan!.attributes[GEN_AI_RESPONSE_TEXT]?.value).toBe('["Hello from Mistral agent!"]'); }, }) .start() @@ -232,4 +252,37 @@ describe('Mistral integration', () => { .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(); + }); + }); }); 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/mistral/constants.ts b/packages/server-utils/src/ai/mistral/constants.ts index 10bfcfa9bf18..b569d4716ebe 100644 --- a/packages/server-utils/src/ai/mistral/constants.ts +++ b/packages/server-utils/src/ai/mistral/constants.ts @@ -2,11 +2,22 @@ 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 }, diff --git a/packages/server-utils/src/ai/mistral/index.ts b/packages/server-utils/src/ai/mistral/index.ts index 2aa2b1ade373..2d28b170b815 100644 --- a/packages/server-utils/src/ai/mistral/index.ts +++ b/packages/server-utils/src/ai/mistral/index.ts @@ -1,6 +1,4 @@ import { - getClient, - hasSpanStreamingEnabled, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, startSpan, @@ -14,7 +12,6 @@ import { GEN_AI_INPUT_MESSAGES, GEN_AI_OPERATION_NAME, GEN_AI_PROVIDER_NAME, - GEN_AI_REQUEST_MODEL, GEN_AI_SYSTEM_INSTRUCTIONS, GEN_AI_TOOL_DEFINITIONS, } from '@sentry/conventions/attributes'; @@ -26,10 +23,11 @@ import { resolveAIRecordingOptions, wrapPromiseWithMethods, } from '../core/utils'; -import { MISTRAL_METHOD_REGISTRY } from './constants'; -import { instrumentStream } from './streaming'; +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 } from './utils'; +import { addResponseAttributes, extractRequestParameters, getSpanName } from './utils'; /** * Serialize tool definitions from request parameters, if present. @@ -43,16 +41,22 @@ function extractToolDefinitions(params: Record): string | undef /** * 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]: 'mistralai', + [GEN_AI_PROVIDER_NAME]: MISTRAL_PROVIDER_NAME, [GEN_AI_OPERATION_NAME]: operationName, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ai.mistralai', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: MISTRAL_ORIGIN, + [GEN_AI_REQUEST_STREAM_ATTRIBUTE]: streaming, }; if (args.length > 0 && typeof args[0] === 'object' && args[0] !== null) { @@ -110,31 +114,20 @@ function instrumentMethod( ): (...args: T) => Promise { return function instrumentedCall(...args: T): Promise { const operationName = instrumentedMethod.operation || 'unknown'; - const requestAttributes = extractRequestAttributes(args, operationName, !!options.recordInputs); + const isStreamRequested = !!instrumentedMethod.streaming; + const requestAttributes = extractRequestAttributes(args, operationName, !!options.recordInputs, isStreamRequested); const params = args[0] as Record | undefined; - // Agent calls carry no model, so their span name uses the agent id (recorded as gen_ai.agent.name). - const model = - (requestAttributes[operationName === 'invoke_agent' ? GEN_AI_AGENT_NAME : GEN_AI_REQUEST_MODEL] as string) || - 'unknown'; - // v2 streams only through dedicated `*.stream` methods; `stream: true` on `complete` still - // returns a completion, so streaming is decided by the method, not the request params. - const isStreamRequested = !!instrumentedMethod.streaming; - const client = getClient(); const spanConfig = { - // With span streaming, omit the `'unknown'` model sentinel so the name stays low-cardinality. - name: - model !== 'unknown' || !(client && hasSpanStreamingEnabled(client)) - ? `${operationName} ${model}` - : operationName, + name: getSpanName(operationName, requestAttributes), op: getGenAiSpanOp(operationName), attributes: requestAttributes as Record, }; - if (isStreamRequested) { - let originalResult!: Promise; + let originalResult!: Promise; + if (isStreamRequested) { const instrumentedPromise = startSpanManual(spanConfig, (span: Span) => { originalResult = originalMethod.apply(context, args); @@ -142,27 +135,26 @@ function instrumentMethod( addRequestAttributes(span, params, operationName); } - return (async () => { - try { - const result = await originalResult; - return instrumentStream( - result as AsyncIterable, - span, - options.recordOutputs ?? false, - ) as unknown as R; - } catch (error) { + 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); diff --git a/packages/server-utils/src/ai/mistral/streaming.ts b/packages/server-utils/src/ai/mistral/streaming.ts index 9f61d8cf44a7..9947abd774fe 100644 --- a/packages/server-utils/src/ai/mistral/streaming.ts +++ b/packages/server-utils/src/ai/mistral/streaming.ts @@ -1,7 +1,8 @@ import type { Span } from '@sentry/core'; import { SPAN_STATUS_ERROR } from '@sentry/core'; -import { endStreamSpan } from '../core/utils'; +import { endStreamSpan, setOutputMessagesAttribute } from '../core/utils'; import type { MistralCompletionChunk, MistralToolCall } from './types'; +import { contentToString } from './utils'; /** * State accumulated while consuming a Mistral event stream. @@ -18,6 +19,39 @@ interface StreamingState { 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; +} + +/** 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; @@ -51,8 +85,11 @@ function processChunk(chunk: MistralCompletionChunk, state: StreamingState, reco for (const choice of chunk.choices ?? []) { if (recordOutputs) { - if (typeof choice.delta?.content === 'string' && choice.delta.content) { - state.responseTexts.push(choice.delta.content); + // 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); @@ -64,39 +101,162 @@ function processChunk(chunk: MistralCompletionChunk, state: StreamingState, reco } } -/** - * Instrument a Mistral event stream, accumulating response attributes and ending the span when - * iteration finishes. Mistral yields `CompletionEvent` objects that wrap the chunk under `data`. - */ -export async function* instrumentStream( - stream: AsyncIterable, - span: Span, - recordOutputs: boolean, -): AsyncGenerator { - const state: StreamingState = { - responseTexts: [], - finishReasons: [], - responseId: '', - responseModel: '', - promptTokens: undefined, - completionTokens: undefined, - totalTokens: undefined, - toolCalls: {}, - }; +/** 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 stream) { - // Mistral streams `CompletionEvent` objects that wrap the chunk under `data`. - const chunk = (event as { data?: MistralCompletionChunk })?.data; - if (chunk && typeof chunk === 'object') { - processChunk(chunk, state, recordOutputs); + for await (const event of { [Symbol.asyncIterator]: iterate }) { + if (claim('iterator')) { + processEvent(event, state, recordOutputs); } yield event; } } catch (error) { - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + settle(error); throw error; } finally { - endStreamSpan(span, { ...state, toolCalls: Object.values(state.toolCalls) }, recordOutputs); + settle(); } } + +function wrapReader( + reader: StreamReaderLike, + state: StreamingState, + recordOutputs: boolean, + claim: (consumer: StreamConsumer) => boolean, + settle: (error?: unknown) => 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 => { + try { + return await cancel(reason); + } finally { + settle(); + } + }; + } + + const value = Reflect.get(target, prop, target) as unknown; + return typeof value === 'function' ? value.bind(target) : value; + }, + }); +} + +/** + * 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; + + const claim = (candidate: StreamConsumer): boolean => { + consumer ??= candidate; + return consumer === candidate; + }; + + const settle = (error?: unknown): void => { + if (settled) { + return; + } + settled = true; + if (error !== undefined) { + 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); + } + + if (typeof readable.cancel === 'function') { + const cancel = readable.cancel.bind(readable); + readable.cancel = async (reason?: unknown): Promise => { + try { + return await cancel(reason); + } finally { + settle(); + } + }; + } + + return true; +} diff --git a/packages/server-utils/src/ai/mistral/utils.ts b/packages/server-utils/src/ai/mistral/utils.ts index 74e2c81945fd..800134e2cd03 100644 --- a/packages/server-utils/src/ai/mistral/utils.ts +++ b/packages/server-utils/src/ai/mistral/utils.ts @@ -1,6 +1,10 @@ /* eslint-disable typescript-eslint/no-deprecated */ import type { Span, SpanAttributeValue } from '@sentry/core'; +import { getClient, hasSpanStreamingEnabled } from '@sentry/core'; +import type { GenAiOutputMessage } from '../core/utils'; +import { setOutputMessagesAttribute } from '../core/utils'; import { + GEN_AI_AGENT_NAME, GEN_AI_REQUEST_FREQUENCY_PENALTY, GEN_AI_REQUEST_MAX_TOKENS, GEN_AI_REQUEST_MODEL, @@ -17,12 +21,11 @@ import { GEN_AI_USAGE_OUTPUT_TOKENS, GEN_AI_USAGE_TOTAL_TOKENS, } from '@sentry/conventions/attributes'; -import { GEN_AI_REQUEST_STREAM_ATTRIBUTE } from '../core/gen-ai-attributes'; /** * Turn a Mistral message content (string or content-chunk array) into a plain string. */ -function contentToString(content: unknown): string { +export function contentToString(content: unknown): string { if (typeof content === 'string') { return content; } @@ -38,6 +41,26 @@ function contentToString(content: unknown): string { return ''; } +/** + * Build the span name for an instrumented Mistral call. + * + * Agent calls carry no model, and their `agentId` is one value per agent, so it is left out of the + * name whenever span streaming asks for low-cardinality names. The `'unknown'` model sentinel is + * dropped there for the same reason. + */ +export function getSpanName(operationName: string, attributes: Record): string { + const client = getClient(); + const lowCardinalityNames = !!client && hasSpanStreamingEnabled(client); + + const detail = attributes[operationName === 'invoke_agent' ? GEN_AI_AGENT_NAME : GEN_AI_REQUEST_MODEL]; + + if (lowCardinalityNames && (operationName === 'invoke_agent' || typeof detail !== 'string')) { + return operationName; + } + + return `${operationName} ${typeof detail === 'string' ? detail : 'unknown'}`; +} + /** * Extract request parameters. Mistral request fields are camelCase. */ @@ -51,7 +74,6 @@ export function extractRequestParameters(params: Record): Recor 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; - if ('stream' in params) attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE] = params.stream; return attributes; } @@ -81,6 +103,8 @@ export function addResponseAttributes(span: Span, result: unknown, recordOutputs 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>; @@ -92,17 +116,23 @@ export function addResponseAttributes(span: Span, result: unknown, recordOutputs } if (recordOutputs) { - const responseText = choices - .map(choice => contentToString((choice.message as Record | undefined)?.content)) - .join(''); - if (responseText) { - attrs[GEN_AI_RESPONSE_TEXT] = responseText; + // 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 = choices - .map(choice => (choice.message as Record | undefined)?.toolCalls) - .filter(calls => Array.isArray(calls) && calls.length > 0) - .flat(); + const toolCalls = outputMessages.flatMap(message => message.toolCalls ?? []); if (toolCalls.length > 0) { attrs[GEN_AI_RESPONSE_TOOL_CALLS] = JSON.stringify(toolCalls); } @@ -110,4 +140,8 @@ export function addResponseAttributes(span: Span, result: unknown, recordOutputs } 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/integrations/mistral.ts b/packages/server-utils/src/integrations/mistral.ts index cdbd16e5b841..e6e0e8ef5944 100644 --- a/packages/server-utils/src/integrations/mistral.ts +++ b/packages/server-utils/src/integrations/mistral.ts @@ -3,31 +3,30 @@ import type { IntegrationFn, Span, SpanAttributeValue } from '@sentry/core'; import { _INTERNAL_shouldSkipAiProviderWrapping, defineIntegration, - getClient, - hasSpanStreamingEnabled, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startInactiveSpan, } from '@sentry/core'; -import { GEN_AI_AGENT_NAME, GEN_AI_REQUEST_MODEL } from '@sentry/conventions/attributes'; import { getGenAiSpanOp, resolveAIRecordingOptions } from '../ai/core/utils'; import { addRequestAttributes, extractRequestAttributes } from '../ai/mistral'; -import { instrumentStream } from '../ai/mistral/streaming'; +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 } from '../ai/mistral/utils'; +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' as const; +const INTEGRATION_NAME = MISTRAL_INTEGRATION_NAME; -const ORIGIN = 'auto.ai.mistralai'; - -// Each instrumented channel maps to the gen_ai operation its span reports. +// 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' }, - { channel: CHANNELS.MISTRAL_EMBEDDINGS, operation: 'embeddings' }, - { channel: CHANNELS.MISTRAL_AGENTS, operation: 'invoke_agent' }, + { 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; /** @@ -49,16 +48,17 @@ const _mistralAIIntegration = ((options: MistralOptions = {}) => { }) satisfies IntegrationFn; function instrumentMistral(options: MistralOptions): void { - for (const { channel, operation } of INSTRUMENTED_CHANNELS) { + for (const { channel, operation, streaming } of INSTRUMENTED_CHANNELS) { bindTracingChannelToSpan( diagnosticsChannel.tracingChannel(channel), - data => createGenAiSpan(data, operation, options), + data => createGenAiSpan(data, operation, streaming, options), { beforeSpanEnd: (span, data) => { addResponseAttributes(span, data.result, resolveAIRecordingOptions(options).recordOutputs); }, - // Streaming: the result is an async-iterable consumed later, so instrument it and let it end the span. - deferSpanEnd: ({ span, data }) => wrapStreamResult(span, data, options), + // 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), }, ); } @@ -68,7 +68,12 @@ function instrumentMistral(options: MistralOptions): void { * 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, options: MistralOptions): Span | undefined { +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)) { @@ -80,16 +85,11 @@ function createGenAiSpan(data: MistralChannelContext, operation: string, options const { recordInputs } = resolveAIRecordingOptions(options); - const attributes = extractRequestAttributes(args, operation, recordInputs); - attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] = ORIGIN; - // Agent calls carry no model, so their span name uses the agent id (recorded as gen_ai.agent.name). - const model = - (attributes[operation === 'invoke_agent' ? GEN_AI_AGENT_NAME : GEN_AI_REQUEST_MODEL] as string) || 'unknown'; - const client = getClient(); + const attributes = extractRequestAttributes(args, operation, recordInputs, streaming); + attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] = MISTRAL_ORIGIN; const span = startInactiveSpan({ - // With span streaming, omit the `'unknown'` model sentinel so the name stays low-cardinality. - name: model !== 'unknown' || !(client && hasSpanStreamingEnabled(client)) ? `${operation} ${model}` : operation, + name: getSpanName(operation, attributes), op: getGenAiSpanOp(operation), attributes: attributes as Record, }); @@ -101,34 +101,6 @@ function createGenAiSpan(data: MistralChannelContext, operation: string, options return span; } -type AsyncIterableStream = { [Symbol.asyncIterator]: () => AsyncIterator }; - -function isAsyncIterable(value: unknown): value is AsyncIterableStream { - return !!value && typeof (value as AsyncIterableStream)[Symbol.asyncIterator] === 'function'; -} - -/** - * For a streaming call the result is an `EventStream` the caller consumes later. We can't swap what the - * method returns, but the stream in `data.result` is the same instance the caller holds and `asyncEnd` - * fires before iteration — so we patch its async iterator in place to run through `instrumentStream`, - * which accumulates streamed attributes and ends the span when iteration finishes. Only a streaming call - * resolves to an async-iterable, so that check alone distinguishes it. Returns `true` to hand - * span-ending ownership to `instrumentStream`; `false` for non-streaming/errored results. - */ -function wrapStreamResult(span: Span, data: MistralChannelContext, options: MistralOptions): boolean { - const result = data.result; - if (!isAsyncIterable(result)) { - return false; - } - - const { recordOutputs } = resolveAIRecordingOptions(options); - const iterate = result[Symbol.asyncIterator].bind(result); - const instrumented = instrumentStream({ [Symbol.asyncIterator]: iterate }, span, recordOutputs ?? false); - result[Symbol.asyncIterator] = () => instrumented; - - return true; -} - /** * Diagnostics-channel-based Mistral integration. Subscribes to the `orchestrion:@mistralai/mistralai:*` * diagnostics_channels injected into the SDK's chat, embeddings and agents methods, so it requires diff --git a/packages/server-utils/src/orchestrion/config/mistral.ts b/packages/server-utils/src/orchestrion/config/mistral.ts index a81000ebe389..01dd2b7320ff 100644 --- a/packages/server-utils/src/orchestrion/config/mistral.ts +++ b/packages/server-utils/src/orchestrion/config/mistral.ts @@ -7,17 +7,32 @@ import { getModuleNames } from './module-names'; // 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: { ...MODULE, filePath: 'esm/sdk/chat.js' }, + module: CHAT_FILE, functionQuery: { className: 'Chat', methodName: 'complete', kind: 'Auto' as const }, }, { channelName: 'chat', - module: { ...MODULE, filePath: 'esm/sdk/chat.js' }, + 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' }, @@ -25,12 +40,12 @@ export const mistralConfig = [ }, { channelName: 'agents', - module: { ...MODULE, filePath: 'esm/sdk/agents.js' }, + module: AGENTS_FILE, functionQuery: { className: 'Agents', methodName: 'complete', kind: 'Auto' as const }, }, { - channelName: 'agents', - module: { ...MODULE, filePath: 'esm/sdk/agents.js' }, + channelName: 'agents-stream', + module: AGENTS_FILE, functionQuery: { className: 'Agents', methodName: 'stream', kind: 'Auto' as const }, }, ] satisfies InstrumentationConfig[]; @@ -39,6 +54,8 @@ 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..e148156f8b1f --- /dev/null +++ b/packages/server-utils/test/ai/lib/tracing/mistral.test.ts @@ -0,0 +1,495 @@ +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('keeps `chat unknown` when the model is missing in static mode', async () => { + const endedSpans = setupClient('static'); + const client = instrumentMistralAiClient(fakeClient()); + + await client.chat.complete({ messages: [] }); + + expect(spanToStaticSpanJSON(endedSpans[0]!).description).toBe('chat unknown'); + }); + + it('drops the `unknown` model sentinel under span streaming', async () => { + const endedSpans = setupClient('stream'); + 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 under span streaming', async () => { + const endedSpans = setupClient('stream'); + const client = instrumentMistralAiClient(fakeClient()); + + await client.agents.complete({ agentId: 'ag_01abcdef', messages: [] }); + + const span = spanToStaticSpanJSON(endedSpans[0]!); + expect(span.description).toBe('invoke_agent'); + // The id is still recorded, just not in the name. + expect(span.data[GEN_AI_AGENT_NAME]).toBe('ag_01abcdef'); + }); + + it('keeps the agent id in the span name in static mode', async () => { + const endedSpans = setupClient('static'); + const client = instrumentMistralAiClient(fakeClient()); + + await client.agents.complete({ agentId: 'ag_01abcdef', messages: [] }); + + expect(spanToStaticSpanJSON(endedSpans[0]!).description).toBe('invoke_agent 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('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('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'); + }); + }); +}); From 4c27331b7a50ce9f5214fb597fb6fb7b1e4425e6 Mon Sep 17 00:00:00 2001 From: RulaKhaled Date: Mon, 14 Sep 2026 21:58:32 +0300 Subject: [PATCH 15/24] fix(node): Keep a cancelled Mistral stream out of error status Cancelling a stream can reject a `read()` that is already in flight, because the cancel aborts the HTTP body the stream reads from. The cancel wrappers awaited the underlying cancel before ending the span, so that rejection reached `settle` first and recorded a deliberate abort as `internal_error`. The caller's intent is now recorded synchronously, before the underlying cancel is awaited, and an error arriving afterwards no longer sets error status. That makes the outcome independent of which of the two settles first. A stream that fails on its own still ends as `internal_error`; only a cancel the caller asked for suppresses it. Note that a spec `ReadableStream` resolves an in-flight read with `done: true` on cancel rather than rejecting it, so this is only reachable on a stream backed by a live connection. The regression test drives the reader directly for that reason. Co-Authored-By: Claude Opus 5 --- .../server-utils/src/ai/mistral/streaming.ts | 16 ++++- .../test/ai/lib/tracing/mistral.test.ts | 66 +++++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/packages/server-utils/src/ai/mistral/streaming.ts b/packages/server-utils/src/ai/mistral/streaming.ts index 9947abd774fe..ab7e5d447298 100644 --- a/packages/server-utils/src/ai/mistral/streaming.ts +++ b/packages/server-utils/src/ai/mistral/streaming.ts @@ -137,6 +137,7 @@ function wrapReader( 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; @@ -168,6 +169,7 @@ function wrapReader( if (prop === 'cancel' && cancel) { return async (reason?: unknown): Promise => { + markCancelled(); try { return await cancel(reason); } finally { @@ -205,18 +207,26 @@ export function instrumentEventStream(stream: unknown, span: Span, recordOutputs 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) { + if (error !== undefined && !cancelled) { span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); } @@ -244,12 +254,14 @@ export function instrumentEventStream(stream: unknown, span: Span, recordOutputs if (typeof readable.getReader === 'function') { const getReader = readable.getReader.bind(readable); - readable.getReader = (...args: unknown[]) => wrapReader(getReader(...args), state, recordOutputs, claim, settle); + 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 { diff --git a/packages/server-utils/test/ai/lib/tracing/mistral.test.ts b/packages/server-utils/test/ai/lib/tracing/mistral.test.ts index e148156f8b1f..9343cebf486e 100644 --- a/packages/server-utils/test/ai/lib/tracing/mistral.test.ts +++ b/packages/server-utils/test/ai/lib/tracing/mistral.test.ts @@ -249,6 +249,72 @@ describe('instrumentMistralAiClient', () => { 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 }); From a7a81f00f6a7457c71cc086f2681f67d5e7a4f00 Mon Sep 17 00:00:00 2001 From: RulaKhaled Date: Mon, 14 Sep 2026 22:15:02 +0300 Subject: [PATCH 16/24] fix(node): Instrument Mistral streams drained via tee, pipeTo or pipeThrough `tee`, `pipeTo` and `pipeThrough` acquire their reader through internal slots rather than by calling the public `getReader`, so patching `getReader` and the async iterator left them uninstrumented. A stream drained that way delivered every chunk correctly and produced no gen_ai span at all: the span was opened, never ended, and so never flushed. The surrounding trace still looked complete, which made the loss silent. These three now pull through the wrapped reader, so accumulation and span ending stay in one place and a teed stream is still recorded once rather than twice. The sub-stream follows `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, which is the hole #24054 fixed there. `@sentry/openai` and friends need none of this because their streams are plain async iterables. Mistral's `EventStream` extends `ReadableStream`, so it has drain paths they do not. Workers AI solves the same problem by returning a new stream, which is not available here: the orchestrion path publishes on `asyncEnd` after the caller already holds the stream, and Node's `tracingChannel` returns the original value regardless of what a subscriber assigns to `ctx.result`. Co-Authored-By: Claude Opus 5 --- .../server-utils/src/ai/mistral/streaming.ts | 55 +++++++++++ .../test/ai/lib/tracing/mistral.test.ts | 97 +++++++++++++++++++ 2 files changed, 152 insertions(+) diff --git a/packages/server-utils/src/ai/mistral/streaming.ts b/packages/server-utils/src/ai/mistral/streaming.ts index ab7e5d447298..74acc6e43b70 100644 --- a/packages/server-utils/src/ai/mistral/streaming.ts +++ b/packages/server-utils/src/ai/mistral/streaming.ts @@ -32,6 +32,9 @@ interface StreamReaderLike { 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`. */ @@ -184,6 +187,38 @@ function wrapReader( }); } +/** + * 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. @@ -270,5 +305,25 @@ export function instrumentEventStream(stream: unknown, span: Span, recordOutputs }; } + // 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/test/ai/lib/tracing/mistral.test.ts b/packages/server-utils/test/ai/lib/tracing/mistral.test.ts index 9343cebf486e..5f99e719725e 100644 --- a/packages/server-utils/test/ai/lib/tracing/mistral.test.ts +++ b/packages/server-utils/test/ai/lib/tracing/mistral.test.ts @@ -330,6 +330,103 @@ describe('instrumentMistralAiClient', () => { 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({ From 72a43ce928754bfe1549e7e10681ca1dfc805aed Mon Sep 17 00:00:00 2001 From: RulaKhaled Date: Mon, 14 Sep 2026 23:57:22 +0300 Subject: [PATCH 17/24] test(node): Cover the Mistral structured-output entry points `chat.parse` and `chat.parseStream` had orchestrion entries but were only exercised against a stubbed client, so nothing proved the transform actually matched those methods on the real SDK. Drives both through the mock server with a zod `responseFormat` and asserts one span each. They call the underlying request functions directly rather than `this.complete` / `this.stream`, so the assertion also pins down that they neither go uninstrumented nor produce a second span from the sibling method. Co-Authored-By: Claude Opus 5 --- .../suites/tracing/mistral/scenario-parse.mjs | 90 +++++++++++++++++++ .../suites/tracing/mistral/test.ts | 30 +++++++ 2 files changed, 120 insertions(+) create mode 100644 dev-packages/node-integration-tests/suites/tracing/mistral/scenario-parse.mjs 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/test.ts b/dev-packages/node-integration-tests/suites/tracing/mistral/test.ts index 285608b57f3f..6b59ac1b1a62 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mistral/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mistral/test.ts @@ -285,4 +285,34 @@ describe('Mistral integration', () => { .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(); + }); + }); }); From 8a65c638a32c0ca2405e0d55136cf0310661d1c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Tue, 15 Sep 2026 10:54:51 +0200 Subject: [PATCH 18/24] Update dev-packages/node-integration-tests/suites/tracing/mistral/instrument-manual.mjs Co-authored-by: Francesco Gringl-Novy --- .../suites/tracing/mistral/instrument-manual.mjs | 1 - 1 file changed, 1 deletion(-) 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 index 62a3870004b8..f1a5eac39be7 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mistral/instrument-manual.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/mistral/instrument-manual.mjs @@ -7,7 +7,6 @@ Sentry.init({ tracesSampleRate: 1.0, dataCollection: { genAI: { inputs: true, outputs: true } }, transport: loggingTransport, - traceLifecycle: 'stream', // `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'), From 16f1dde1c09d3ec0ab259107b8f2ef335bb98d72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Tue, 15 Sep 2026 10:55:04 +0200 Subject: [PATCH 19/24] Update dev-packages/node-integration-tests/suites/tracing/mistral/instrument-with-options.mjs Co-authored-by: Francesco Gringl-Novy --- .../suites/tracing/mistral/instrument-with-options.mjs | 1 - 1 file changed, 1 deletion(-) 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 index 7d67ddb7df15..707c1d886a60 100644 --- 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 @@ -6,7 +6,6 @@ Sentry.init({ release: '1.0', tracesSampleRate: 1.0, transport: loggingTransport, - traceLifecycle: 'stream', integrations: [ Sentry.mistralAIIntegration({ recordInputs: true, From ca6cdadee905238d6eff82435659114a622df89d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Tue, 15 Sep 2026 10:55:19 +0200 Subject: [PATCH 20/24] Update dev-packages/node-integration-tests/suites/tracing/mistral/instrument-with-pii.mjs Co-authored-by: Francesco Gringl-Novy --- .../suites/tracing/mistral/instrument-with-pii.mjs | 1 - 1 file changed, 1 deletion(-) 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 index 657bed0a3a8c..1c507ba84b9c 100644 --- 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 @@ -7,5 +7,4 @@ Sentry.init({ tracesSampleRate: 1.0, dataCollection: { genAI: { inputs: true, outputs: true } }, transport: loggingTransport, - traceLifecycle: 'stream', }); From f736fe82c60bb36227951439fc28d576e3797ed4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Tue, 15 Sep 2026 10:55:30 +0200 Subject: [PATCH 21/24] Update dev-packages/node-integration-tests/suites/tracing/mistral/instrument.mjs Co-authored-by: Francesco Gringl-Novy --- .../node-integration-tests/suites/tracing/mistral/instrument.mjs | 1 - 1 file changed, 1 deletion(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/mistral/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/mistral/instrument.mjs index 5e0b6fb5592f..cc192fb89834 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mistral/instrument.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/mistral/instrument.mjs @@ -7,5 +7,4 @@ Sentry.init({ tracesSampleRate: 1.0, dataCollection: { genAI: { inputs: false, outputs: false } }, transport: loggingTransport, - traceLifecycle: 'stream', }); From 89bf15385011312f30e8b531f97043cd754b546c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Tue, 15 Sep 2026 10:56:26 +0200 Subject: [PATCH 22/24] Update packages/server-utils/src/integrations/mistral.ts Co-authored-by: Francesco Gringl-Novy --- packages/server-utils/src/integrations/mistral.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/server-utils/src/integrations/mistral.ts b/packages/server-utils/src/integrations/mistral.ts index e6e0e8ef5944..7d53c2047cf8 100644 --- a/packages/server-utils/src/integrations/mistral.ts +++ b/packages/server-utils/src/integrations/mistral.ts @@ -102,8 +102,6 @@ function createGenAiSpan( } /** - * Diagnostics-channel-based Mistral integration. Subscribes to the `orchestrion:@mistralai/mistralai:*` - * diagnostics_channels injected into the SDK's chat, embeddings and agents methods, so it requires - * the Sentry runtime hook or bundler plugin. + * An integration to instrument @mistralai/mistrailai. */ export const mistralAIIntegration = defineIntegration(_mistralAIIntegration); From 4870f05093ab67c8d2199d55b937f271af386796 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Tue, 15 Sep 2026 11:08:09 +0200 Subject: [PATCH 23/24] ref: Remove low cardinality span names for transactions --- .../suites/tracing/mistral/test.ts | 2 -- packages/server-utils/src/ai/mistral/utils.ts | 18 ++++--------- .../test/ai/lib/tracing/mistral.test.ts | 25 +++---------------- 3 files changed, 8 insertions(+), 37 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/mistral/test.ts b/dev-packages/node-integration-tests/suites/tracing/mistral/test.ts index 6b59ac1b1a62..49fe09771da1 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mistral/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mistral/test.ts @@ -180,8 +180,6 @@ describe('Mistral integration', () => { s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'agentcmpl-mock123', ); expect(agentSpan).toBeDefined(); - // Agent ids are one value per agent, so the name stays at the bare operation under span - // streaming; the id is still recorded on `gen_ai.agent.name`. expect(agentSpan!.name).toBe('invoke_agent'); expect(agentSpan!.status).toBe('ok'); expect(agentSpan!.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('invoke_agent'); diff --git a/packages/server-utils/src/ai/mistral/utils.ts b/packages/server-utils/src/ai/mistral/utils.ts index 800134e2cd03..e1786582a2ef 100644 --- a/packages/server-utils/src/ai/mistral/utils.ts +++ b/packages/server-utils/src/ai/mistral/utils.ts @@ -1,10 +1,8 @@ /* eslint-disable typescript-eslint/no-deprecated */ import type { Span, SpanAttributeValue } from '@sentry/core'; -import { getClient, hasSpanStreamingEnabled } from '@sentry/core'; import type { GenAiOutputMessage } from '../core/utils'; import { setOutputMessagesAttribute } from '../core/utils'; import { - GEN_AI_AGENT_NAME, GEN_AI_REQUEST_FREQUENCY_PENALTY, GEN_AI_REQUEST_MAX_TOKENS, GEN_AI_REQUEST_MODEL, @@ -42,23 +40,17 @@ export function contentToString(content: unknown): string { } /** - * Build the span name for an instrumented Mistral call. - * - * Agent calls carry no model, and their `agentId` is one value per agent, so it is left out of the - * name whenever span streaming asks for low-cardinality names. The `'unknown'` model sentinel is - * dropped there for the same reason. + * 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 client = getClient(); - const lowCardinalityNames = !!client && hasSpanStreamingEnabled(client); + const model = attributes[GEN_AI_REQUEST_MODEL]; - const detail = attributes[operationName === 'invoke_agent' ? GEN_AI_AGENT_NAME : GEN_AI_REQUEST_MODEL]; - - if (lowCardinalityNames && (operationName === 'invoke_agent' || typeof detail !== 'string')) { + if (operationName === 'invoke_agent' || typeof model !== 'string') { return operationName; } - return `${operationName} ${typeof detail === 'string' ? detail : 'unknown'}`; + return `${operationName} ${model}`; } /** diff --git a/packages/server-utils/test/ai/lib/tracing/mistral.test.ts b/packages/server-utils/test/ai/lib/tracing/mistral.test.ts index 5f99e719725e..89ee08ea34cc 100644 --- a/packages/server-utils/test/ai/lib/tracing/mistral.test.ts +++ b/packages/server-utils/test/ai/lib/tracing/mistral.test.ts @@ -128,44 +128,25 @@ describe('instrumentMistralAiClient', () => { expect(spanToStaticSpanJSON(endedSpans[0]!).description).toBe('chat mistral-small-latest'); }); - it('keeps `chat unknown` when the model is missing in static mode', async () => { + 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 unknown'); - }); - - it('drops the `unknown` model sentinel under span streaming', async () => { - const endedSpans = setupClient('stream'); - 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 under span streaming', async () => { - const endedSpans = setupClient('stream'); + 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'); - // The id is still recorded, just not in the name. expect(span.data[GEN_AI_AGENT_NAME]).toBe('ag_01abcdef'); }); - - it('keeps the agent id in the span name in static mode', async () => { - const endedSpans = setupClient('static'); - const client = instrumentMistralAiClient(fakeClient()); - - await client.agents.complete({ agentId: 'ag_01abcdef', messages: [] }); - - expect(spanToStaticSpanJSON(endedSpans[0]!).description).toBe('invoke_agent ag_01abcdef'); - }); }); describe('gen_ai.request.stream', () => { From 1579110e3bd0ef4c2fdaaa919a8ecfaf791c3b2c Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Tue, 15 Sep 2026 11:11:58 +0200 Subject: [PATCH 24/24] ref: Match OpenAI and AntropicAI usage of originalResult --- packages/server-utils/src/ai/mistral/index.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/server-utils/src/ai/mistral/index.ts b/packages/server-utils/src/ai/mistral/index.ts index 2d28b170b815..96a335d0ebf2 100644 --- a/packages/server-utils/src/ai/mistral/index.ts +++ b/packages/server-utils/src/ai/mistral/index.ts @@ -125,9 +125,9 @@ function instrumentMethod( attributes: requestAttributes as Record, }; - let originalResult!: Promise; - if (isStreamRequested) { + let originalResult!: Promise; + const instrumentedPromise = startSpanManual(spanConfig, (span: Span) => { originalResult = originalMethod.apply(context, args); @@ -155,6 +155,8 @@ function instrumentMethod( return wrapPromiseWithMethods(originalResult, instrumentedPromise); } + let originalResult!: Promise; + const instrumentedPromise = startSpan(spanConfig, (span: Span) => { originalResult = originalMethod.apply(context, args);