feat(node): Add Mistral AI integration - #24243
nicohrubec wants to merge 18 commits into
Conversation
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) <noreply@anthropic.com>
size-limit report 📦
|
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>
- 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
`@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) <noreply@anthropic.com>
`@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) <noreply@anthropic.com>
- 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
- 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…lAiClient Align the manual instrumentation API with the other providers, which all use the `instrument<Provider>AiClient` form (`instrumentOpenAiClient`, `instrumentAnthropicAiClient`, `instrumentWorkersAiClient`). Renamed the export and all per-package re-exports. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tion # Conflicts: # packages/deno/test/__snapshots__/mod.test.ts.snap
- 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Want reviews to match your repository better? Bugbot Learning can learn team-specific rules from PR activity. A team admin can enable Learning in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 4920054. Configure here.
4920054 to
bf24777
Compare
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 <noreply@anthropic.com>
…Through `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 <noreply@anthropic.com>
RulaKhaled
left a comment
There was a problem hiding this comment.
overall looks good, direction of instrumentation makes sense. i pushed three commits that fold in some changes:
- manual path no longer swaps the EventStream for a bare async generator, so getReader/tee/pipeTo/cancel survive
- spans close on every drain path now, not just for await: getReader(), cancel(), tee(), pipeTo(), pipeThrough(). the last three take their reader from internal slots, so they were delivering every chunk fine and producing no gen_ai span at all
- a cancelled stream isn't recorded as internal_error anymore
- invoke_agent spans drop the agent id from the name under span streaming, ids are per-agent so it was high cardinality
- gen_ai.request.stream comes from the method, streaming methods get their own channels
- chat.parse / chat.parseStream produce spans, they call the request fns directly rather than this.complete so they were invisible
- array-shaped streamed content is recorded instead of dropped
two that are minor. gen_ai.response.text is now a stringified array per the conventions spec instead of one concatenated blob, and gen_ai.output.messages is set on both paths since it's the non-deprecated replacement. that meant moving setOutputMessagesAttribute out of ai/workers-ai/utils into ai/core/utils so both share it, workers-ai call sites are unchanged and its suites still pass.
- also bumped the two @sentry/node size limits by 1 KB.
- 32 unit tests plus an integration scenario for instrumentMistralAiClient, which had no coverage at all before.
- stacked the e2e app in #24378 — one express app run twice, dev through the runtime hook and prod through an esbuild build, 24 tests across both. needs retargeting to develop once this lands.
`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 <noreply@anthropic.com>
| readable.pipeTo = (destination: unknown, options?: unknown) => | ||
| instrumentedSource(readable).pipeTo(destination as WritableStream<unknown>, options as StreamPipeOptions); | ||
| } | ||
|
|
||
| if (typeof readable.pipeThrough === 'function') { | ||
| readable.pipeThrough = (transform: unknown, options?: unknown) => | ||
| instrumentedSource(readable).pipeThrough( | ||
| transform as ReadableWritablePair<unknown, unknown>, | ||
| options as StreamPipeOptions, | ||
| ); | ||
| } |
There was a problem hiding this comment.
Bug: The instrumentedSource function acquires a stream reader via getReader() but never releases the lock. Subsequent calls to tee(), pipeTo(), or pipeThrough() on the same stream will fail.
Severity: HIGH
Suggested Fix
The instrumentedSource function should be refactored to ensure reader.releaseLock() is called after the stream has been consumed or cancelled. A possible approach, similar to the implementation in packages/deno/src/utils/streaming.ts, is to create a new ReadableStream that wraps the original reader and explicitly releases the lock in its cancel or pull (when done) logic.
Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.
Location: packages/server-utils/src/ai/mistral/streaming.ts#L309-L325
Potential issue: The `instrumentedSource` function, used by the patched `tee()`,
`pipeTo()`, and `pipeThrough()` methods, acquires a reader from the underlying
`ReadableStream` via `getReader()`. However, it never calls `releaseLock()` on this
reader. According to the WHATWG Streams specification, a stream can only have one active
reader. Consequently, any attempt to use the stream again after the first call—for
instance, by calling `tee()` a second time or calling `pipeTo()` after `tee()`—will
cause a `TypeError: ReadableStream is already locked`, crashing the operation. This
affects realistic use cases where a stream is manipulated multiple times.

Adds a
gen_aiintegration for the@mistralai/mistralaiv2 SDK.Sentry.mistralAIIntegration()Sentry.instrumentMistralAiClient(client)Instruments
chat.complete/stream(gen_ai.chat),embeddings.create(gen_ai.embeddings), andagents.complete/stream(gen_ai.invoke_agent), including streaming, withrecordInputs/recordOutputs(PII) controls. Providermistral, originauto.ai.mistral.Trace from my local sample app:

Sorry for the large PR and the AI integrations more generally could also use some refactors. However, to get this out soon I suggest to follow up on this with a broader sweep.