@dbx-tools/appkit-mastra 0.9.32 → 0.9.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,362 +1,19 @@
1
1
  /**
2
- * Repairs traffic between Mastra / the AI SDK and Databricks Model Serving's
3
- * OpenAI-compatible `/chat/completions` route, in both directions.
4
- *
5
- * Outbound ({@link rewriteServingBody}), because the transcript Mastra
6
- * persists is not always a transcript the provider will accept back:
7
- * Databricks-hosted Claude rejects replayed extended-thinking blocks and reads
8
- * a trailing assistant message as a prefill request, while GPT tool calls need
9
- * model-specific reasoning-effort normalization on Chat Completions.
10
- *
11
- * Inbound ({@link rewriteServingResponseBody} and
12
- * {@link rewriteServingResponseStream}), because Databricks-hosted Gemini and
13
- * Claude can answer with native content-parts arrays where the OpenAI contract
14
- * (and therefore the AI SDK's response schema) requires a plain string.
15
- *
16
- * Every repair here is a provider quirk rather than a schema violation, so all
17
- * of them are applied on the wire (see the `globalThis.fetch` wrapper in
18
- * `model.ts`) rather than by changing what the agent stores or what the UI
19
- * shows.
2
+ * Databricks Model Serving wire repairs shared with the model gateway.
20
3
  *
21
4
  * @module
22
5
  */
23
6
 
24
- import { policy } from "@dbx-tools/model";
25
- import { json, stringUtils } from "@dbx-tools/shared-core";
26
- import { type ChatMessage, type ChatRole, openaiChat } from "@dbx-tools/shared-model";
27
-
28
- /**
29
- * A chat message as it arrives on the serving wire, plus the extended-thinking
30
- * fields Databricks-hosted Claude adds. The OpenAI-standard part of the shape
31
- * is {@link ChatMessage} from `@dbx-tools/shared-model`; only the provider
32
- * extensions this module strips are declared here.
33
- */
34
- export interface ServingChatMessage extends ChatMessage {
35
- /** Narrowed to the roles this repair pass reasons about, plus Claude's `reasoning` turn. */
36
- role: ChatRole | "reasoning";
37
- reasoning?: unknown;
38
- reasoning_content?: unknown;
39
- }
40
-
41
- /** Signed Claude reasoning blocks that cannot be replayed after mutation. */
42
- const REASONING_PART_TYPES: ReadonlySet<string> = new Set([
43
- "reasoning",
44
- "thinking",
45
- "redacted_thinking",
46
- ]);
47
-
48
- /**
49
- * Parse, sanitize, and re-serialize a `/serving-endpoints/...` POST
50
- * body. Returns the original string verbatim when the body is not
51
- * JSON or no rewrite was needed.
52
- */
53
- export function rewriteServingBody(body: string): string {
54
- const parsed = json.parseRecord(body);
55
- if (!parsed) return body;
56
-
57
- // Runs regardless of `messages`: Databricks refuses to parse a body carrying
58
- // an unknown top-level field, so this failure is not specific to a transcript.
59
- const toolReasoning = applyToolReasoningCompatibility(parsed);
60
- let changed = openaiChat.stripUnsupportedChatFields(parsed).length > 0 || toolReasoning;
61
-
62
- if (Array.isArray(parsed.messages)) {
63
- const messages = parsed.messages as ServingChatMessage[];
64
- // Evaluated eagerly, not short-circuited: one transcript can need both
65
- // reasoning blocks stripped AND a trailing assistant prefill folded back.
66
- const stripped = stripReasoningFromServingMessages(messages);
67
- const repaired = repairAssistantPrefill(messages);
68
- changed = changed || stripped || repaired;
69
- }
70
-
71
- return changed ? JSON.stringify(parsed) : body;
72
- }
73
-
74
- /** Prepared fetch arguments plus the post-sanitize body used for diagnostics. */
75
- export interface RewrittenServingRequest {
76
- input: Parameters<typeof fetch>[0];
77
- init: Parameters<typeof fetch>[1];
78
- body: string;
79
- }
80
-
81
- /**
82
- * Read and rewrite a serving POST whether its JSON body lives on `init` or on
83
- * a `Request`. A changed Request is rebuilt without stale byte headers.
84
- */
85
- export async function rewriteServingRequest(
86
- input: Parameters<typeof fetch>[0],
87
- init?: Parameters<typeof fetch>[1],
88
- ): Promise<RewrittenServingRequest> {
89
- const request = new Request(input, init);
90
- const body = await request.clone().text();
91
- const rewritten = rewriteServingBody(body);
92
- if (rewritten === body) return { input, init, body };
93
-
94
- const headers = new Headers(request.headers);
95
- headers.delete("content-length");
96
- headers.delete("content-encoding");
97
- return {
98
- input: new Request(request, { body: rewritten, headers }),
99
- init: undefined,
100
- body: rewritten,
101
- };
102
- }
103
-
104
- /**
105
- * Normalize Chat Completions reasoning for GPT models carrying function tools.
106
- *
107
- * GPT 5.6 requires reasoning disabled for tool calls on this route, so `none`
108
- * wins over a caller/default effort. Responses-only models are routed away
109
- * from Chat Completions before this sanitizer runs.
110
- */
111
- export function applyToolReasoningCompatibility(body: Record<string, unknown>): boolean {
112
- if (!Array.isArray(body.tools) || body.tools.length === 0) {
113
- return false;
114
- }
115
- const model = stringUtils.trimToNull(body.model);
116
- if (!model) return false;
117
- if (policy.chatToolReasoningEffort(model) !== "none") return false;
118
- if (body.reasoning_effort === "none") return false;
119
- body.reasoning_effort = "none";
120
- return true;
121
- }
122
-
123
- /**
124
- * Drop extended-thinking / reasoning blocks from a replayed transcript.
125
- *
126
- * Hybrid Claude endpoints (e.g. Sonnet 4.5+) may emit `reasoning` content
127
- * parts on the first turn. Mastra persists and replays them on the next
128
- * agent step, but Databricks-hosted Claude rejects those blocks on
129
- * multi-turn tool continuations. The UI already captured reasoning for
130
- * display; stripping here keeps provider replay compatible without
131
- * changing what users see in the chat bubble.
132
- */
133
- export function stripReasoningFromServingMessages(messages: ServingChatMessage[]): boolean {
134
- let changed = false;
135
- for (let i = messages.length - 1; i >= 0; i--) {
136
- const msg = messages[i];
137
- if (!msg) continue;
138
- if (msg.role === "reasoning") {
139
- messages.splice(i, 1);
140
- changed = true;
141
- continue;
142
- }
143
- if (msg.reasoning !== undefined) {
144
- delete msg.reasoning;
145
- changed = true;
146
- }
147
- if (msg.reasoning_content !== undefined) {
148
- delete msg.reasoning_content;
149
- changed = true;
150
- }
151
- const parts = openaiChat.chatContentParts(msg.content);
152
- if (!parts) continue;
153
- const filtered = parts.filter((part) => {
154
- const type = part?.type;
155
- if (typeof type === "string" && REASONING_PART_TYPES.has(type)) {
156
- changed = true;
157
- return false;
158
- }
159
- return true;
160
- });
161
- if (filtered.length !== parts.length) {
162
- msg.content = filtered;
163
- }
164
- const hasToolCalls = Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0;
165
- if (msg.role === "assistant" && !hasToolCalls && isEmptyServingContent(msg.content)) {
166
- messages.splice(i, 1);
167
- changed = true;
168
- }
169
- }
170
- return changed;
171
- }
172
-
173
- /**
174
- * Repair a Mastra/AI SDK message replay that Databricks-hosted Claude
175
- * rejects with `"This model does not support assistant message
176
- * prefill. The conversation must end with a user message."`.
177
- *
178
- * The bug pattern: when an assistant turn streams text *and* a
179
- * `tool_call`, the AI SDK persists them as two separate assistant
180
- * entries (text-only and tool-call-only). On the next agent step the
181
- * tool-call entry is replayed *before* the tool result and the
182
- * text entry is replayed *after* it, so the conversation ends with a
183
- * trailing assistant text message. Anthropic interprets that as a
184
- * prefill request and rejects it on Databricks (the upstream Bedrock
185
- * route disallows prefill).
186
- *
187
- * Fix: when the last message is an assistant text with no `tool_calls`
188
- * and the chain immediately before it is `assistant(tool_calls=...)`
189
- * followed only by `tool(...)` results, fold the trailing text back
190
- * into the `content` of that opening assistant and drop the duplicate.
191
- */
192
- export function repairAssistantPrefill(messages: ServingChatMessage[]): boolean {
193
- if (messages.length < 2) return false;
194
- const last = messages[messages.length - 1];
195
- if (!last || last.role !== "assistant" || (last.tool_calls && last.tool_calls.length > 0)) {
196
- return false;
197
- }
198
-
199
- let i = messages.length - 2;
200
- while (i >= 0 && messages[i]?.role === "tool") i--;
201
- if (i < 0) return false;
202
- const opener = messages[i];
203
- if (
204
- !opener ||
205
- opener.role !== "assistant" ||
206
- !opener.tool_calls ||
207
- opener.tool_calls.length === 0
208
- ) {
209
- return false;
210
- }
211
-
212
- const merged = [
213
- stringUtils.trimToNull(textFromServingContent(opener.content)),
214
- stringUtils.trimToNull(textFromServingContent(last.content)),
215
- ]
216
- .filter((s): s is string => s !== null)
217
- .join("\n\n");
218
- opener.content = merged;
219
- messages.pop();
220
- return true;
221
- }
222
-
223
- /**
224
- * Flatten content to the prose a human would read: text parts only, separated
225
- * by blank lines, since the result is spliced into another turn's message body.
226
- */
227
- function textFromServingContent(content: ServingChatMessage["content"]): string {
228
- return openaiChat.chatContentToText(content, { separator: "\n\n", types: ["text"] });
229
- }
230
-
231
- /**
232
- * Whether a turn carries nothing worth replaying. Deliberately stricter than
233
- * "has no text": a part of any other type (an image, a cache marker) counts as
234
- * content, so a message holding one is kept even though it has no prose.
235
- */
236
- function isEmptyServingContent(content: ServingChatMessage["content"]): boolean {
237
- if (content === undefined) return true;
238
- if (typeof content === "string") return content.trim().length === 0;
239
- const parts = openaiChat.chatContentParts(content);
240
- if (!parts) return true;
241
- return parts.every((part) => {
242
- if (part?.type === "text") {
243
- return typeof part.text !== "string" || part.text.trim().length === 0;
244
- }
245
- return false;
246
- });
247
- }
248
-
249
- /**
250
- * Parse, sanitize, and re-serialize a `/serving-endpoints/...` non-streaming
251
- * JSON RESPONSE body. Returns the original string verbatim when the body is
252
- * not JSON or no rewrite was needed, mirroring
253
- * {@link rewriteServingBody} on the request side.
254
- */
255
- export function rewriteServingResponseBody(body: string): string {
256
- const parsed = json.parseRecord(body);
257
- if (!parsed) return body;
258
- return flattenChoiceMessageContent(parsed) ? JSON.stringify(parsed) : body;
259
- }
260
-
261
- /**
262
- * Repair array-valued `choices[].delta.content` inside an OpenAI SSE stream
263
- * without buffering the response. The decoder retains partial lines across
264
- * arbitrary network chunk boundaries; each complete `data:` line is parsed and
265
- * re-encoded only when its content needs normalization.
266
- */
267
- export function rewriteServingResponseStream(
268
- body: ReadableStream<Uint8Array>,
269
- ): ReadableStream<Uint8Array> {
270
- const decoder = new TextDecoder();
271
- const encoder = new TextEncoder();
272
- let buffer = "";
273
-
274
- return body.pipeThrough(
275
- new TransformStream<Uint8Array, Uint8Array>({
276
- transform(chunk, controller) {
277
- buffer += decoder.decode(chunk, { stream: true });
278
- let newline = buffer.indexOf("\n");
279
- while (newline >= 0) {
280
- const line = buffer.slice(0, newline + 1);
281
- buffer = buffer.slice(newline + 1);
282
- controller.enqueue(encoder.encode(rewriteServingResponseStreamLine(line)));
283
- newline = buffer.indexOf("\n");
284
- }
285
- },
286
- flush(controller) {
287
- buffer += decoder.decode();
288
- if (buffer) controller.enqueue(encoder.encode(rewriteServingResponseStreamLine(buffer)));
289
- },
290
- }),
291
- );
292
- }
293
-
294
- /** Normalize one SSE line while preserving its `data:` spacing and line ending. */
295
- function rewriteServingResponseStreamLine(line: string): string {
296
- const lineEnding = line.endsWith("\r\n") ? "\r\n" : line.endsWith("\n") ? "\n" : "";
297
- const content = lineEnding ? line.slice(0, -lineEnding.length) : line;
298
- const match = /^(data:\s*)(.*)$/.exec(content);
299
- if (!match || match[2] === "[DONE]") return line;
300
-
301
- const parsed = json.parseRecord(match[2]);
302
- if (!parsed || !flattenChoiceDeltaContent(parsed)) return line;
303
- return `${match[1]}${JSON.stringify(parsed)}${lineEnding}`;
304
- }
305
-
306
- /**
307
- * Collapse a structured `choices[].message.content` array to the plain string
308
- * the OpenAI Chat Completions contract specifies.
309
- *
310
- * Databricks-hosted Gemini answers a non-streaming `/chat/completions` call
311
- * with the Gemini-native parts shape:
312
- *
313
- * ```json
314
- * "content": [{ "type": "text", "text": "...", "thoughtSignature": "..." }]
315
- * ```
316
- *
317
- * `@ai-sdk/openai-compatible` validates the response against the OpenAI
318
- * schema, where `content` is a nullable string, so it throws
319
- * `Type validation failed: ... expected string, received array` and the whole
320
- * call fails (`AI_APICallError: Invalid JSON response` on an HTTP 200). Mastra
321
- * uses `doGenerate` for its side calls, so the visible symptom is
322
- * `Error generating title` - every thread keeps its placeholder name.
323
- *
324
- * Flattening on the wire keeps the repair in one place: neither the agent's
325
- * stored transcript nor the UI has to know the provider emitted parts. Any
326
- * non-text part (a reasoning block, `thoughtSignature`-only entry, or inline
327
- * image) contributes nothing, matching {@link openaiChat.chatContentToText},
328
- * and an all-parts-empty message flattens to `""` rather than being dropped, so
329
- * `finish_reason` and `usage` still round-trip.
330
- */
331
- export function flattenChoiceMessageContent(payload: Record<string, unknown>): boolean {
332
- return flattenChoiceContent(payload, "message");
333
- }
334
-
335
- /**
336
- * Collapse structured streaming `choices[].delta.content` arrays to strings.
337
- * Claude reasoning-only chunks become an empty content delta while text parts
338
- * are concatenated in order.
339
- */
340
- export function flattenChoiceDeltaContent(payload: Record<string, unknown>): boolean {
341
- return flattenChoiceContent(payload, "delta");
342
- }
343
-
344
- /** Shared choice walker for buffered `message` and streaming `delta` payloads. */
345
- function flattenChoiceContent(
346
- payload: Record<string, unknown>,
347
- field: "message" | "delta",
348
- ): boolean {
349
- if (!Array.isArray(payload.choices)) return false;
350
- let changed = false;
351
- for (const choice of payload.choices) {
352
- if (!choice || typeof choice !== "object") continue;
353
- const container = (choice as Record<string, unknown>)[field];
354
- if (!container || typeof container !== "object") continue;
355
- const target = container as { content?: unknown };
356
- const parts = openaiChat.chatContentParts(target.content);
357
- if (!parts) continue;
358
- target.content = openaiChat.chatContentToText(parts, { types: ["text"] });
359
- changed = true;
360
- }
361
- return changed;
362
- }
7
+ export {
8
+ applyToolReasoningCompatibility,
9
+ flattenChoiceDeltaContent,
10
+ flattenChoiceMessageContent,
11
+ repairAssistantPrefill,
12
+ rewriteServingBody,
13
+ rewriteServingRequest,
14
+ rewriteServingResponseBody,
15
+ rewriteServingResponseStream,
16
+ stripReasoningFromServingMessages,
17
+ type RewrittenServingRequest,
18
+ type ServingChatMessage,
19
+ } from "@dbx-tools/model/serving-wire";
package/src/trace-io.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Copy each Mastra chat turn's request/response onto the active OTel
3
- * root span so Databricks-managed MLflow can display them.
2
+ * Copy each Mastra chat turn's request/response onto its OTel root span
3
+ * so Databricks-managed MLflow can display them.
4
4
  *
5
5
  * Why this exists: MLflow's UC `<target>_trace_unified` view picks the
6
6
  * one span whose `parent_span_id` is empty and reads request/response
@@ -10,16 +10,19 @@
10
10
  * the view never reads, so chat traces arrive with both columns null
11
11
  * even when the spans themselves look healthy.
12
12
  *
13
- * The active span inside the Mastra sub-app is still AppKit's HTTP
14
- * server span (OTel context is async-local). With `OTEL_PROPAGATORS=none`
15
- * that span is the trace root - see the package README's observability
16
- * section for why Apps ingress `traceparent` otherwise hides every turn.
13
+ * AppKit's HTTP instrumentation records the server span in OTel RPC metadata,
14
+ * which remains available even when Express makes a middleware-layer child
15
+ * active. When instrumentation is unavailable, this module creates one
16
+ * request-lifetime server span and runs Mastra under it.
17
17
  *
18
18
  * @module
19
19
  */
20
20
 
21
+ import { StringDecoder } from "node:string_decoder";
22
+
21
23
  import { json, log, object } from "@dbx-tools/shared-core";
22
- import { trace } from "@opentelemetry/api";
24
+ import { context, SpanKind, trace, type Span } from "@opentelemetry/api";
25
+ import { getRPCMetadata, RPCType } from "@opentelemetry/core";
23
26
  import type express from "express";
24
27
 
25
28
  const logger = log.logger("mastra/trace-io");
@@ -27,6 +30,12 @@ const logger = log.logger("mastra/trace-io");
27
30
  /** Cap on each payload copied onto a span, so one turn cannot bloat the export. */
28
31
  export const TRACE_IO_LIMIT = 8_000;
29
32
 
33
+ /** Bound for a non-streaming body captured through `res.end` instead of `res.json`. */
34
+ const RESPONSE_BODY_LIMIT = TRACE_IO_LIMIT * 4;
35
+
36
+ /** Tracer scope used only when AppKit did not create a recording HTTP span. */
37
+ const CHAT_TURN_TRACER = "@dbx-tools/appkit-mastra/trace-io";
38
+
30
39
  /**
31
40
  * Mount-relative Mastra agent invoke paths that carry a chat turn body
32
41
  * (`messages`) and stream an assistant answer. Resume / approve verbs
@@ -39,6 +48,25 @@ const AGENT_TURN_ROUTE = /^(?:\/agents\/[^/]+\/(?:stream|generate)(?:\/|$)|\/cha
39
48
  export const MLFLOW_SPAN_INPUTS_ATTR = "mlflow.spanInputs";
40
49
  export const MLFLOW_SPAN_OUTPUTS_ATTR = "mlflow.spanOutputs";
41
50
 
51
+ interface TraceTarget {
52
+ span: Span;
53
+ owned: boolean;
54
+ }
55
+
56
+ /** Return the assistant text from one SSE line, or an empty string. */
57
+ function assistantTextFromSseLine(line: string): string {
58
+ if (!line.startsWith("data:")) return "";
59
+ const frame = json.parse(line.slice(5));
60
+ if (!object.isRecord(frame) || frame.type !== "text-delta") return "";
61
+ const text =
62
+ typeof frame.delta === "string"
63
+ ? frame.delta
64
+ : object.isRecord(frame.payload)
65
+ ? frame.payload.text
66
+ : undefined;
67
+ return typeof text === "string" ? text : "";
68
+ }
69
+
42
70
  /**
43
71
  * Concatenate the assistant's answer out of an AI SDK SSE transcript.
44
72
  *
@@ -50,28 +78,109 @@ export const MLFLOW_SPAN_OUTPUTS_ATTR = "mlflow.spanOutputs";
50
78
  export function assistantTextFromSse(body: string): string {
51
79
  const parts: string[] = [];
52
80
  for (const line of body.split("\n")) {
53
- if (!line.startsWith("data:")) continue;
54
- // SSE carries comment/keepalive lines that are not JSON; they never hold
55
- // assistant text, so skipping them is the intended path rather than an error.
56
- const frame = json.parse(line.slice(5));
57
- if (!object.isRecord(frame) || frame.type !== "text-delta") continue;
58
- const text =
59
- typeof frame.delta === "string"
60
- ? frame.delta
61
- : object.isRecord(frame.payload)
62
- ? frame.payload.text
63
- : undefined;
64
- if (typeof text === "string") parts.push(text);
81
+ const text = assistantTextFromSseLine(line);
82
+ if (text) parts.push(text);
83
+ }
84
+ return parts.join("").slice(0, TRACE_IO_LIMIT);
85
+ }
86
+
87
+ /** Read the final assistant text from a non-streaming Mastra response. */
88
+ export function assistantTextFromJson(body: unknown): string {
89
+ if (!object.isRecord(body) || typeof body.text !== "string") return "";
90
+ return body.text.slice(0, TRACE_IO_LIMIT);
91
+ }
92
+
93
+ /** Incrementally assemble UTF-8 SSE text while retaining a bounded JSON fallback. */
94
+ class AssistantResponseCollector {
95
+ private readonly decoder = new StringDecoder("utf8");
96
+ private answer = "";
97
+ private body = "";
98
+ private pendingLine = "";
99
+ private finished = false;
100
+
101
+ write(chunk: unknown): void {
102
+ if (this.finished) return;
103
+ if (typeof chunk === "string") {
104
+ this.consume(chunk);
105
+ return;
106
+ }
107
+ if (ArrayBuffer.isView(chunk)) {
108
+ this.consume(
109
+ this.decoder.write(Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength)),
110
+ );
111
+ }
112
+ }
113
+
114
+ finish(): string {
115
+ if (this.finished) return this.answer || this.jsonAnswer();
116
+ this.finished = true;
117
+ this.consume(this.decoder.end());
118
+ this.consumeSseLine(this.pendingLine);
119
+ this.pendingLine = "";
120
+ return this.answer || this.jsonAnswer();
121
+ }
122
+
123
+ private consume(text: string): void {
124
+ if (!text) return;
125
+ if (this.body.length < RESPONSE_BODY_LIMIT) {
126
+ this.body += text.slice(0, RESPONSE_BODY_LIMIT - this.body.length);
127
+ }
128
+ this.pendingLine += text;
129
+ let newline = this.pendingLine.indexOf("\n");
130
+ while (newline >= 0) {
131
+ this.consumeSseLine(this.pendingLine.slice(0, newline).replace(/\r$/, ""));
132
+ this.pendingLine = this.pendingLine.slice(newline + 1);
133
+ newline = this.pendingLine.indexOf("\n");
134
+ }
135
+ if (this.pendingLine.length > RESPONSE_BODY_LIMIT) {
136
+ this.pendingLine = this.pendingLine.slice(-RESPONSE_BODY_LIMIT);
137
+ }
138
+ }
139
+
140
+ private consumeSseLine(line: string): void {
141
+ if (this.answer.length >= TRACE_IO_LIMIT) return;
142
+ const text = assistantTextFromSseLine(line);
143
+ this.answer += text.slice(0, TRACE_IO_LIMIT - this.answer.length);
144
+ }
145
+
146
+ private jsonAnswer(): string {
147
+ return assistantTextFromJson(json.parse(this.body));
65
148
  }
66
- return parts.join("");
67
149
  }
68
150
 
69
151
  /**
70
- * Express middleware that stamps chat turn I/O onto the active OTel span.
152
+ * Select the span MLflow treats as the request root.
71
153
  *
72
- * Tee `res.write` / `res.end` instead of listening for `finish`: the HTTP
73
- * instrumentation ends the root span on finish, and an attribute set after
74
- * a span ends never reaches the exporter.
154
+ * OTel HTTP instrumentation stores its server span in RPC metadata. That span
155
+ * wins over an active Express child. If no recording local span exists, create
156
+ * one under the current context so enabled W3C propagation remains intact.
157
+ */
158
+ function resolveTraceTarget(): TraceTarget | undefined {
159
+ const activeContext = context.active();
160
+ const rpc = getRPCMetadata(activeContext);
161
+ if (rpc?.type === RPCType.HTTP && rpc.span.isRecording()) {
162
+ return { span: rpc.span, owned: false };
163
+ }
164
+
165
+ const active = trace.getActiveSpan();
166
+ if (active?.isRecording()) return { span: active, owned: false };
167
+
168
+ const span = trace
169
+ .getTracer(CHAT_TURN_TRACER)
170
+ .startSpan("mastra.chat_turn", { kind: SpanKind.SERVER }, activeContext);
171
+ if (!span.isRecording()) {
172
+ span.end();
173
+ return undefined;
174
+ }
175
+ return { span, owned: true };
176
+ }
177
+
178
+ /**
179
+ * Express middleware that stamps chat turn I/O onto the exported OTel root.
180
+ *
181
+ * Tee `res.write`, `res.json`, and `res.end` instead of listening for `finish`:
182
+ * HTTP instrumentation ends its server span on finish, and attributes set
183
+ * after a span ends never reach the exporter.
75
184
  *
76
185
  * `path` is mount-relative (what the Mastra sub-app sees), e.g.
77
186
  * `/agents/support/stream`.
@@ -85,48 +194,71 @@ export function chatTurnTraceIoMiddleware(
85
194
  next();
86
195
  return;
87
196
  }
88
- const span = trace.getActiveSpan();
89
- if (!span) {
197
+ const target = resolveTraceTarget();
198
+ if (!target) {
90
199
  next();
91
200
  return;
92
201
  }
93
202
 
94
203
  const messages = (req.body as { messages?: unknown } | undefined)?.messages;
95
204
  if (messages !== undefined) {
96
- span.setAttribute(MLFLOW_SPAN_INPUTS_ATTR, JSON.stringify(messages).slice(0, TRACE_IO_LIMIT));
205
+ target.span.setAttribute(
206
+ MLFLOW_SPAN_INPUTS_ATTR,
207
+ JSON.stringify(messages).slice(0, TRACE_IO_LIMIT),
208
+ );
97
209
  }
98
210
 
99
- const chunks: string[] = [];
211
+ const collector = new AssistantResponseCollector();
212
+ let outputRecorded = false;
213
+ let ownedSpanEnded = false;
100
214
  const passThroughWrite = res.write.bind(res) as (...args: unknown[]) => boolean;
101
215
  const passThroughEnd = res.end.bind(res) as (...args: unknown[]) => unknown;
102
- const collect = (chunk: unknown): void => {
103
- if (typeof chunk === "string") chunks.push(chunk);
104
- else if (Buffer.isBuffer(chunk)) chunks.push(chunk.toString("utf8"));
216
+ const passThroughJson = res.json.bind(res) as (body?: unknown) => express.Response;
217
+ const recordOutput = (answer: string): void => {
218
+ if (outputRecorded || !answer) return;
219
+ target.span.setAttribute(MLFLOW_SPAN_OUTPUTS_ATTR, answer.slice(0, TRACE_IO_LIMIT));
220
+ outputRecorded = true;
221
+ };
222
+ const endOwnedSpan = (): void => {
223
+ if (!target.owned || ownedSpanEnded) return;
224
+ ownedSpanEnded = true;
225
+ target.span.end();
105
226
  };
106
227
 
107
228
  res.write = ((chunk: unknown, ...rest: unknown[]) => {
108
- collect(chunk);
229
+ collector.write(chunk);
109
230
  return passThroughWrite(chunk, ...rest);
110
231
  }) as typeof res.write;
111
232
 
233
+ res.json = ((body?: unknown) => {
234
+ recordOutput(assistantTextFromJson(body));
235
+ return passThroughJson(body);
236
+ }) as typeof res.json;
237
+
112
238
  res.end = ((chunk?: unknown, ...rest: unknown[]) => {
113
- collect(chunk);
114
- const answer = assistantTextFromSse(chunks.join(""));
115
- if (answer) {
116
- span.setAttribute(MLFLOW_SPAN_OUTPUTS_ATTR, answer.slice(0, TRACE_IO_LIMIT));
239
+ collector.write(chunk);
240
+ recordOutput(collector.finish());
241
+ try {
242
+ return passThroughEnd(chunk, ...rest);
243
+ } finally {
244
+ endOwnedSpan();
117
245
  }
118
- return passThroughEnd(chunk, ...rest);
119
246
  }) as typeof res.end;
120
247
 
121
- next();
248
+ res.once("close", () => {
249
+ recordOutput(collector.finish());
250
+ endOwnedSpan();
251
+ });
252
+
253
+ context.with(trace.setSpan(context.active(), target.span), next);
122
254
  }
123
255
 
124
256
  /**
125
257
  * Install {@link chatTurnTraceIoMiddleware} on a Mastra Express sub-app.
126
258
  *
127
259
  * Call before `MastraServer.init()` so the layer sits ahead of the agent
128
- * routes. Safe to call unconditionally: with no active span (local, no
129
- * OTLP) the middleware is a no-op.
260
+ * routes. Safe to call unconditionally: when no recording tracer is registered,
261
+ * the middleware is a no-op.
130
262
  */
131
263
  export function attachChatTurnTraceIo(app: express.Express): void {
132
264
  app.use(chatTurnTraceIoMiddleware);