Skip to content

Commit bc0fbe4

Browse files
nicohrubecclaude
andcommitted
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) <noreply@anthropic.com>
1 parent f3e7762 commit bc0fbe4

15 files changed

Lines changed: 881 additions & 183 deletions

File tree

‎dev-packages/node-integration-tests/suites/tracing/mistral/test.ts‎

Lines changed: 244 additions & 183 deletions
Large diffs are not rendered by default.

‎packages/node/src/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export {
2525
mongoIntegration,
2626
mongooseIntegration,
2727
mysqlIntegration,
28+
mistralAIIntegration,
2829
mysql2Integration,
2930
openAIIntegration,
3031
postgresIntegration,
@@ -40,6 +41,7 @@ export {
4041
instrumentOpenAiClient,
4142
instrumentAnthropicAiClient,
4243
instrumentGoogleGenAIClient,
44+
instrumentMistralClient,
4345
createLangChainCallbackHandler,
4446
instrumentLangChainEmbeddings,
4547
instrumentStateGraph,

‎packages/server-utils/src/ai/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
export { instrumentOpenAiClient } from './openai';
88
export { instrumentAnthropicAiClient } from './anthropic-ai';
99
export { instrumentGoogleGenAIClient } from './google-genai';
10+
export { instrumentMistralClient } from './mistral';
1011
export { instrumentWorkersAiClient } from './workers-ai';
1112
export { createLangChainCallbackHandler, instrumentLangChainEmbeddings } from './langchain';
1213
export { instrumentStateGraph, instrumentStateGraphCompile, instrumentCreateReactAgent } from './langgraph';
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type { InstrumentedMethodRegistry } from '../core/utils';
2+
3+
export const MISTRAL_INTEGRATION_NAME = 'Mistral' as const;
4+
5+
// https://docs.mistral.ai/api/
6+
// `*.stream` methods are intrinsically streaming (no `stream: true` param), so they are flagged here.
7+
export const MISTRAL_METHOD_REGISTRY = {
8+
'chat.complete': { operation: 'chat' },
9+
'chat.stream': { operation: 'chat', streaming: true },
10+
'embeddings.create': { operation: 'embeddings' },
11+
'agents.complete': { operation: 'invoke_agent' },
12+
'agents.stream': { operation: 'invoke_agent', streaming: true },
13+
'fim.complete': { operation: 'text_completion' },
14+
'fim.stream': { operation: 'text_completion', streaming: true },
15+
} as const satisfies InstrumentedMethodRegistry;
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import {
2+
getClient,
3+
hasSpanStreamingEnabled,
4+
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
5+
SPAN_STATUS_ERROR,
6+
startSpan,
7+
startSpanManual,
8+
stringify,
9+
} from '@sentry/core';
10+
import type { Span, SpanAttributeValue } from '@sentry/core';
11+
import {
12+
GEN_AI_AGENT_NAME,
13+
GEN_AI_EMBEDDINGS_INPUT,
14+
GEN_AI_INPUT_MESSAGES,
15+
GEN_AI_OPERATION_NAME,
16+
GEN_AI_PROVIDER_NAME,
17+
GEN_AI_SYSTEM_INSTRUCTIONS,
18+
} from '@sentry/conventions/attributes';
19+
import type { InstrumentedMethodEntry } from '../core/utils';
20+
import {
21+
buildMethodPath,
22+
extractSystemInstructions,
23+
getGenAiSpanOp,
24+
resolveAIRecordingOptions,
25+
wrapPromiseWithMethods,
26+
} from '../core/utils';
27+
import { MISTRAL_METHOD_REGISTRY } from './constants';
28+
import { instrumentStream } from './streaming';
29+
import type { MistralOptions } from './types';
30+
import { addResponseAttributes, extractRequestParameters, getModelForSpanName } from './utils';
31+
32+
/**
33+
* Extract request attributes from method arguments.
34+
*/
35+
export function extractRequestAttributes(args: unknown[], operationName: string): Record<string, unknown> {
36+
const attributes: Record<string, unknown> = {
37+
[GEN_AI_PROVIDER_NAME]: 'mistral',
38+
[GEN_AI_OPERATION_NAME]: operationName,
39+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ai.mistral',
40+
};
41+
42+
if (args.length > 0 && typeof args[0] === 'object' && args[0] !== null) {
43+
const params = args[0] as Record<string, unknown>;
44+
45+
if (operationName === 'invoke_agent' && typeof params.agentId === 'string') {
46+
attributes[GEN_AI_AGENT_NAME] = params.agentId;
47+
}
48+
49+
Object.assign(attributes, extractRequestParameters(params));
50+
}
51+
52+
return attributes;
53+
}
54+
55+
/**
56+
* Record AI request inputs on the span, if recording is enabled.
57+
*/
58+
export function addRequestAttributes(span: Span, params: Record<string, unknown>, operationName: string): void {
59+
if (operationName === 'embeddings') {
60+
const input = params.inputs;
61+
if (input == null || (typeof input === 'string' && input.length === 0) || (Array.isArray(input) && !input.length)) {
62+
return;
63+
}
64+
span.setAttribute(GEN_AI_EMBEDDINGS_INPUT, stringify(input, String));
65+
return;
66+
}
67+
68+
// FIM completions carry a raw `prompt` string rather than a messages array.
69+
if (operationName === 'text_completion') {
70+
if (typeof params.prompt === 'string' && params.prompt.length > 0) {
71+
span.setAttribute(GEN_AI_INPUT_MESSAGES, stringify(params.prompt));
72+
}
73+
return;
74+
}
75+
76+
const src = 'messages' in params ? params.messages : undefined;
77+
if (!src || (Array.isArray(src) && src.length === 0)) {
78+
return;
79+
}
80+
81+
const { systemInstructions, filteredMessages } = extractSystemInstructions(src);
82+
if (systemInstructions) {
83+
span.setAttribute(GEN_AI_SYSTEM_INSTRUCTIONS, systemInstructions);
84+
}
85+
span.setAttribute(GEN_AI_INPUT_MESSAGES, stringify(filteredMessages));
86+
}
87+
88+
/**
89+
* Instrument a single Mistral SDK method with a gen_ai span.
90+
* @see https://docs.sentry.io/platforms/javascript/guides/node/tracing/instrumentation/ai-agents-module/#manual-instrumentation
91+
*/
92+
function instrumentMethod<T extends unknown[], R>(
93+
originalMethod: (...args: T) => Promise<R>,
94+
instrumentedMethod: InstrumentedMethodEntry,
95+
context: unknown,
96+
options: MistralOptions,
97+
): (...args: T) => Promise<R> {
98+
return function instrumentedCall(...args: T): Promise<R> {
99+
const operationName = instrumentedMethod.operation || 'unknown';
100+
const requestAttributes = extractRequestAttributes(args, operationName);
101+
102+
const params = args[0] as Record<string, unknown> | undefined;
103+
const model = getModelForSpanName(params, operationName);
104+
// `*.stream` methods are always streaming; `complete` methods stream only with `stream: true`.
105+
const isStreamRequested = !!instrumentedMethod.streaming || params?.stream === true;
106+
const client = getClient();
107+
108+
const spanConfig = {
109+
// With span streaming, omit the `'unknown'` model sentinel so the name stays low-cardinality.
110+
name:
111+
model !== 'unknown' || !(client && hasSpanStreamingEnabled(client))
112+
? `${operationName} ${model}`
113+
: operationName,
114+
op: getGenAiSpanOp(operationName),
115+
attributes: requestAttributes as Record<string, SpanAttributeValue>,
116+
};
117+
118+
if (isStreamRequested) {
119+
let originalResult!: Promise<R>;
120+
121+
const instrumentedPromise = startSpanManual(spanConfig, (span: Span) => {
122+
originalResult = originalMethod.apply(context, args);
123+
124+
if (options.recordInputs && params) {
125+
addRequestAttributes(span, params, operationName);
126+
}
127+
128+
return (async () => {
129+
try {
130+
const result = await originalResult;
131+
return instrumentStream(
132+
result as AsyncIterable<unknown>,
133+
span,
134+
options.recordOutputs ?? false,
135+
) as unknown as R;
136+
} catch (error) {
137+
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
138+
span.end();
139+
throw error;
140+
}
141+
})();
142+
});
143+
144+
return wrapPromiseWithMethods(originalResult, instrumentedPromise);
145+
}
146+
147+
let originalResult!: Promise<R>;
148+
149+
const instrumentedPromise = startSpan(spanConfig, (span: Span) => {
150+
originalResult = originalMethod.apply(context, args);
151+
152+
if (options.recordInputs && params) {
153+
addRequestAttributes(span, params, operationName);
154+
}
155+
156+
return originalResult.then(result => {
157+
addResponseAttributes(span, result, options.recordOutputs);
158+
return result;
159+
});
160+
});
161+
162+
return wrapPromiseWithMethods(originalResult, instrumentedPromise);
163+
};
164+
}
165+
166+
/**
167+
* Create a deep proxy for Mistral client instrumentation.
168+
*/
169+
function createDeepProxy<T extends object>(target: T, currentPath = '', options: MistralOptions): T {
170+
return new Proxy(target, {
171+
get(obj: object, prop: string): unknown {
172+
const value = (obj as Record<string, unknown>)[prop];
173+
const methodPath = buildMethodPath(currentPath, String(prop));
174+
175+
const instrumentedMethod = MISTRAL_METHOD_REGISTRY[methodPath as keyof typeof MISTRAL_METHOD_REGISTRY];
176+
if (typeof value === 'function' && instrumentedMethod) {
177+
return instrumentMethod(value as (...args: unknown[]) => Promise<unknown>, instrumentedMethod, obj, options);
178+
}
179+
180+
if (typeof value === 'function') {
181+
// Preserve the original `this` for uninstrumented methods (private class fields).
182+
return value.bind(obj);
183+
}
184+
185+
if (value && typeof value === 'object') {
186+
return createDeepProxy(value, methodPath, options);
187+
}
188+
189+
return value;
190+
},
191+
}) as T;
192+
}
193+
194+
/**
195+
* Instrument a Mistral client with Sentry tracing.
196+
* Can be used across Node.js, Cloudflare Workers, and Vercel Edge.
197+
*/
198+
export function instrumentMistralClient<T extends object>(client: T, options?: MistralOptions): T {
199+
return createDeepProxy(client, '', resolveAIRecordingOptions(options));
200+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import type { Span } from '@sentry/core';
2+
import { endStreamSpan } from '../core/utils';
3+
import type { MistralCompletionChunk } from './types';
4+
5+
/**
6+
* State accumulated while consuming a Mistral event stream.
7+
*/
8+
interface StreamingState {
9+
responseTexts: string[];
10+
finishReasons: string[];
11+
responseId: string;
12+
responseModel: string;
13+
promptTokens: number | undefined;
14+
completionTokens: number | undefined;
15+
totalTokens: number | undefined;
16+
}
17+
18+
function processChunk(chunk: MistralCompletionChunk, state: StreamingState, recordOutputs: boolean): void {
19+
state.responseId = chunk.id ?? state.responseId;
20+
state.responseModel = chunk.model ?? state.responseModel;
21+
22+
if (chunk.usage) {
23+
// Input tokens stay constant across the stream; output tokens are only finalized in the last
24+
// event, so we overwrite on every event that carries usage to guarantee the totals are set.
25+
state.promptTokens = chunk.usage.promptTokens;
26+
state.completionTokens = chunk.usage.completionTokens;
27+
state.totalTokens = chunk.usage.totalTokens;
28+
}
29+
30+
for (const choice of chunk.choices ?? []) {
31+
if (recordOutputs && typeof choice.delta?.content === 'string' && choice.delta.content) {
32+
state.responseTexts.push(choice.delta.content);
33+
}
34+
if (choice.finishReason) {
35+
state.finishReasons.push(choice.finishReason);
36+
}
37+
}
38+
}
39+
40+
/**
41+
* Instrument a Mistral event stream, accumulating response attributes and ending the span when
42+
* iteration finishes. Mistral yields `CompletionEvent` objects that wrap the chunk under `data`.
43+
*/
44+
export async function* instrumentStream<T>(
45+
stream: AsyncIterable<T>,
46+
span: Span,
47+
recordOutputs: boolean,
48+
): AsyncGenerator<T, void, unknown> {
49+
const state: StreamingState = {
50+
responseTexts: [],
51+
finishReasons: [],
52+
responseId: '',
53+
responseModel: '',
54+
promptTokens: undefined,
55+
completionTokens: undefined,
56+
totalTokens: undefined,
57+
};
58+
59+
try {
60+
for await (const event of stream) {
61+
const chunk =
62+
event && typeof event === 'object' && 'data' in event
63+
? (event as { data: MistralCompletionChunk }).data
64+
: (event as unknown as MistralCompletionChunk);
65+
if (chunk && typeof chunk === 'object') {
66+
processChunk(chunk, state, recordOutputs);
67+
}
68+
yield event;
69+
}
70+
} finally {
71+
endStreamSpan(span, { ...state, toolCalls: [] }, recordOutputs);
72+
}
73+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type { GenAiOptions } from '../core/utils';
2+
3+
/** Options for the Mistral integration. */
4+
export type MistralOptions = GenAiOptions;
5+
6+
/**
7+
* A single streaming chunk. Field names are camelCase because the SDK deserializes the snake_case
8+
* wire payload into typed objects before instrumentation sees them. Streaming APIs actually yield
9+
* `CompletionEvent` objects that wrap this under `data`.
10+
* @see https://docs.mistral.ai/api/#tag/chat/operation/stream_chat
11+
*/
12+
export interface MistralCompletionChunk {
13+
id: string;
14+
model: string;
15+
choices?: Array<{
16+
delta?: { content?: string | Array<unknown> | null };
17+
finishReason?: string | null;
18+
}>;
19+
usage?: {
20+
promptTokens?: number;
21+
completionTokens?: number;
22+
totalTokens?: number;
23+
};
24+
}

0 commit comments

Comments
 (0)