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..f05ac044c411 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mistral/scenario-embeddings.mjs @@ -0,0 +1,67 @@ +import { Mistral } from '@mistralai/mistralai'; +import * as Sentry from '@sentry/node'; +import express from 'express'; + +function startMockServer() { + const app = express(); + app.use(express.json()); + + app.post('/v1/embeddings', (req, res) => { + const { model, inputs } = req.body; + + if (model === 'error-model') { + res.status(404).set('x-request-id', 'mock-request-123').end('Model not found'); + return; + } + + // Distinct id per call shape so tests can target the single-input span unambiguously. + res.send({ + id: Array.isArray(inputs) ? 'embd-mock-multi' : 'embd-mock123', + object: 'list', + model, + data: [{ object: 'embedding', embedding: [0.1, 0.2, 0.3], index: 0 }], + usage: { prompt_tokens: 8, total_tokens: 8 }, + }); + }); + + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +async function run() { + const server = await startMockServer(); + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const client = new Mistral({ + apiKey: 'mock-api-key', + serverURL: `http://localhost:${server.address().port}`, + }); + + await client.embeddings.create({ + model: 'mistral-embed', + inputs: 'Embedding test!', + }); + + try { + await client.embeddings.create({ + model: 'error-model', + inputs: 'Error embedding test!', + }); + } catch { + // expected + } + + await client.embeddings.create({ + model: 'mistral-embed', + inputs: ['First input text', 'Second input text'], + }); + }); + + await Sentry.flush(2000); + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/mistral/test.ts b/dev-packages/node-integration-tests/suites/tracing/mistral/test.ts new file mode 100644 index 000000000000..3961e8b6d9f7 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mistral/test.ts @@ -0,0 +1,202 @@ +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_SYSTEM_INSTRUCTIONS, + GEN_AI_USAGE_INPUT_TOKENS, + GEN_AI_USAGE_OUTPUT_TOKENS, + GEN_AI_USAGE_TOTAL_TOKENS, +} from '@sentry/conventions/attributes'; +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmTests } from '../../../utils/runner'; + +const PROVIDER = '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(); + }); + + createEsmTests(__dirname, 'scenario-chat.mjs', 'instrument.mjs', (createRunner, test) => { + test('creates chat spans with genAI recording disabled', async () => { + await createRunner() + .expect({ + span: container => { + const chatSpan = container.items.find(s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123'); + expect(chatSpan).toBeDefined(); + expect(chatSpan!.name).toBe('chat mistral-small-latest'); + expect(chatSpan!.status).toBe('ok'); + expect(chatSpan!.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('chat'); + expect(chatSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]?.value).toBe('gen_ai.chat'); + expect(chatSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]?.value).toBe(ORIGIN); + expect(chatSpan!.attributes[GEN_AI_PROVIDER_NAME]?.value).toBe(PROVIDER); + expect(chatSpan!.attributes[GEN_AI_REQUEST_MODEL]?.value).toBe('mistral-small-latest'); + expect(chatSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE]?.value).toBe(0.7); + expect(chatSpan!.attributes[GEN_AI_REQUEST_MAX_TOKENS]?.value).toBe(100); + expect(chatSpan!.attributes[GEN_AI_RESPONSE_MODEL]?.value).toBe('mistral-small-latest'); + expect(chatSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]?.value).toBe('["stop"]'); + expect(chatSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value).toBe(10); + expect(chatSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]?.value).toBe(15); + expect(chatSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]?.value).toBe(25); + // recording disabled → no prompt/response content + expect(chatSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toBeUndefined(); + expect(chatSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toBeUndefined(); + + const streamSpan = container.items.find( + s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-stream-123', + ); + expect(streamSpan).toBeDefined(); + expect(streamSpan!.name).toBe('chat mistral-large-latest'); + expect(streamSpan!.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('chat'); + expect(streamSpan!.attributes[GEN_AI_RESPONSE_STREAMING]?.value).toBe(true); + expect(streamSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]?.value).toBe(30); + }, + }) + .start() + .completed(); + }); + }); + + createEsmTests(__dirname, 'scenario-chat.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { + test('records chat inputs and outputs with PII enabled', async () => { + await createRunner() + .expect({ + span: container => { + const chatSpan = container.items.find(s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123'); + expect(chatSpan).toBeDefined(); + // The system message is split out into gen_ai.system_instructions. + expect(chatSpan!.attributes[GEN_AI_SYSTEM_INSTRUCTIONS]?.value).toContain('You are a helpful assistant.'); + expect(chatSpan!.attributes[GEN_AI_INPUT_MESSAGES]?.value).toBe( + '[{"role":"user","content":"What is the capital of France?"}]', + ); + expect(chatSpan!.attributes[GEN_AI_RESPONSE_TEXT]?.value).toBe('Hello from Mistral mock!'); + }, + }) + .start() + .completed(); + }); + }); + + createEsmTests(__dirname, 'scenario-chat.mjs', 'instrument-with-options.mjs', (createRunner, test) => { + test('records chat inputs and outputs with explicit integration options', async () => { + await createRunner() + .expect({ + span: container => { + const chatSpan = container.items.find(s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123'); + expect(chatSpan).toBeDefined(); + expect(chatSpan!.attributes[GEN_AI_INPUT_MESSAGES]?.value).toContain('What is the capital of France?'); + expect(chatSpan!.attributes[GEN_AI_RESPONSE_TEXT]?.value).toBe('Hello from Mistral mock!'); + }, + }) + .start() + .completed(); + }); + }); + + createEsmTests(__dirname, 'scenario-embeddings.mjs', 'instrument.mjs', (createRunner, test) => { + test('creates embeddings spans', async () => { + await createRunner() + .expect({ + span: container => { + const embeddingsSpan = container.items.find( + s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'embd-mock123', + ); + expect(embeddingsSpan).toBeDefined(); + expect(embeddingsSpan!.name).toBe('embeddings mistral-embed'); + expect(embeddingsSpan!.status).toBe('ok'); + expect(embeddingsSpan!.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('embeddings'); + expect(embeddingsSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]?.value).toBe('gen_ai.embeddings'); + expect(embeddingsSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]?.value).toBe(ORIGIN); + expect(embeddingsSpan!.attributes[GEN_AI_PROVIDER_NAME]?.value).toBe(PROVIDER); + expect(embeddingsSpan!.attributes[GEN_AI_REQUEST_MODEL]?.value).toBe('mistral-embed'); + expect(embeddingsSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value).toBe(8); + expect(embeddingsSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]?.value).toBe(8); + expect(embeddingsSpan!.attributes[GEN_AI_EMBEDDINGS_INPUT]).toBeUndefined(); + }, + }) + .start() + .completed(); + }); + }); + + createEsmTests(__dirname, 'scenario-embeddings.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { + test('records embeddings input with PII enabled', async () => { + await createRunner() + .expect({ + span: container => { + const embeddingsSpan = container.items.find( + s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'embd-mock123', + ); + expect(embeddingsSpan).toBeDefined(); + expect(embeddingsSpan!.attributes[GEN_AI_EMBEDDINGS_INPUT]?.value).toContain('Embedding test!'); + }, + }) + .start() + .completed(); + }); + }); + + createEsmTests(__dirname, 'scenario-agents.mjs', 'instrument.mjs', (createRunner, test) => { + test('creates invoke_agent spans', async () => { + await createRunner() + .expect({ + span: container => { + const agentSpan = container.items.find( + s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'agentcmpl-mock123', + ); + expect(agentSpan).toBeDefined(); + expect(agentSpan!.name).toBe('invoke_agent 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(); + }); + }); + + createEsmTests(__dirname, 'scenario-agents.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { + test('records agent inputs and outputs with PII enabled', async () => { + await createRunner() + .expect({ + span: container => { + const agentSpan = container.items.find( + s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'agentcmpl-mock123', + ); + expect(agentSpan).toBeDefined(); + expect(agentSpan!.attributes[GEN_AI_INPUT_MESSAGES]?.value).toContain('Who is the best French painter?'); + expect(agentSpan!.attributes[GEN_AI_RESPONSE_TEXT]?.value).toBe('Hello from Mistral agent!'); + }, + }) + .start() + .completed(); + }); + }); +}); 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/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..10bfcfa9bf18 --- /dev/null +++ b/packages/server-utils/src/ai/mistral/constants.ts @@ -0,0 +1,13 @@ +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 }, +} 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..45d15f8fa160 --- /dev/null +++ b/packages/server-utils/src/ai/mistral/index.ts @@ -0,0 +1,192 @@ +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; + } + + 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/langchain.ts b/packages/server-utils/src/integrations/langchain.ts index b6a43579de0f..52fb4a438a2c 100644 --- a/packages/server-utils/src/integrations/langchain.ts +++ b/packages/server-utils/src/integrations/langchain.ts @@ -8,6 +8,7 @@ import { LANGCHAIN_INTEGRATION_NAME } from '../ai/langchain/constants'; import { _INTERNAL_getLangChainEmbeddingsSpanOptions } from '../ai/langchain/embeddings'; import type { LangChainOptions } from '../ai/langchain/types'; import { _INTERNAL_mergeLangChainCallbackHandler } from '../ai/langchain/utils'; +import { MISTRAL_INTEGRATION_NAME } from '../ai/mistral/constants'; import { OPENAI_INTEGRATION_NAME } from '../ai/openai/constants'; import { CHANNELS } from '../orchestrion/channels'; import { langchainEmbeddingsChannels } from '../orchestrion/config/langchain'; @@ -21,7 +22,12 @@ const INTEGRATION_NAME = LANGCHAIN_INTEGRATION_NAME; // LangChain drives the underlying AI provider SDKs itself, so while it's active those providers must // not also instrument, or every call would produce two spans (mirrors the OTel path's skip list). -const SKIPPED_PROVIDERS = [OPENAI_INTEGRATION_NAME, ANTHROPIC_AI_INTEGRATION_NAME, GOOGLE_GENAI_INTEGRATION_NAME]; +const SKIPPED_PROVIDERS = [ + OPENAI_INTEGRATION_NAME, + ANTHROPIC_AI_INTEGRATION_NAME, + GOOGLE_GENAI_INTEGRATION_NAME, + MISTRAL_INTEGRATION_NAME, +]; // The chat-model channels carry the live args array of `invoke(input, options)` / `_streamIterator(input, options)`. interface RunnableChannelContext { diff --git a/packages/server-utils/src/integrations/mistral.ts b/packages/server-utils/src/integrations/mistral.ts new file mode 100644 index 000000000000..013cdd3ab0be --- /dev/null +++ b/packages/server-utils/src/integrations/mistral.ts @@ -0,0 +1,134 @@ +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' }, +] 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 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/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..a81000ebe389 --- /dev/null +++ b/packages/server-utils/src/orchestrion/config/mistral.ts @@ -0,0 +1,44 @@ +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 }, + }, +] 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', +} as const; 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, 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==