Skip to content

Commit 4df7cc7

Browse files
epinzurclaude
andauthored
Add MAPPING span tracing for tool toModelOutput transforms (#16347)
## Description This PR adds observability tracing for tool `toModelOutput` transformations by emitting a `MAPPING` span type. When a tool defines a `toModelOutput` function and it executes successfully, a child span is created to track the transformation of the tool result before it's passed to the model. ### Changes Made 1. **New Span Type**: Added `MAPPING` span type to `SpanType` enum in `observability/types/tracing.ts` to represent inline data transforms between pipeline stages. 2. **New Attributes Interface**: Created `MappingAttributes` interface to capture mapping-specific metadata: - `mappingType`: Identifier of the mapping (e.g., `toModelOutput`) - `toolCallId`: Associated tool call ID when mapping operates on a tool result 3. **Span Emission Logic**: Updated `llm-mapping-step.ts` to: - Create a child `MAPPING` span when `toModelOutput` is defined and the tool result is non-null - Capture the tool result as span input and the transformed output as span output - Mark the span as errored if `toModelOutput` throws, then re-throw the error - Skip span creation if the tool has no `toModelOutput` or if the result is null/undefined 4. **Comprehensive Tests**: Added four test cases covering: - Successful mapping span emission with correct attributes and output - No span emission when tool lacks `toModelOutput` - No span emission when tool result is null/undefined - Error handling when `toModelOutput` throws ## Related Issue(s) Fixes #15486 ## Type of Change - [ ] Bug fix (non-breaking change that fixes an issue) - [x] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Code refactoring - [ ] Performance improvement - [x] Test update ## Checklist - [x] I have made corresponding changes to the documentation (if applicable) - [x] I have added tests that prove my fix is effective or that my feature works - [x] I have addressed all Coderabbit comments on this PR https://claude.ai/code/session_018bEDqEtGS2XkdsqBjye6JC <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## ELI5 When a tool changes its result before the AI model sees it, the system now records that transformation in traces so you can see both the original tool output and what the model actually received. ## Overview This PR adds observability for tool toModelOutput transformations by introducing a MAPPING span type and emitting a child MAPPING span when a tool defines toModelOutput and the tool result is non-null. The span records the raw tool result as input and the transformed model-facing output as span output, and marks the span errored if the transform throws. No mapping span is created when toModelOutput is absent or the tool result is null/undefined. ## Changes - Added SpanType.MAPPING = 'mapping' and exported MappingAttributes in packages/core/src/observability/types/tracing.ts to represent inline data transforms and carry mapping-specific attributes (mappingType, toolCallId). - Updated packages/core/src/loop/workflows/agentic-execution/llm-mapping-step.ts to: - create a child MAPPING span (EntityType.TOOL) when tool.toModelOutput exists and the tool result is non-null, - set span input to the raw tool result and end the span with the transformed modelOutput, - mark the span errored (endSpan: true) and re-throw if toModelOutput throws, - avoid creating a span when toModelOutput is undefined or tool result is null/undefined, - attach modelOutput into providerMetadata.mastra.modelOutput when present. - Added tests in packages/core/src/loop/workflows/agentic-execution/llm-mapping-step.test.ts covering: - successful MAPPING span emission with expected attributes and output, - no span when the tool lacks toModelOutput, - no span when tool result is null/undefined, - error handling when toModelOutput throws (span errored and error re-thrown). - Updated client SDK route types (client-sdks/client-js/src/route-types.generated.ts) to include 'mapping' in relevant union types. - Documented the change in the changeset (.changeset/loud-ads-double.md) and regenerated client types as part of the commit. ## Issue Resolution Addresses issue #15486 by capturing toModelOutput return values in tracing so traces can distinguish "not invoked" vs "no-op" vs "transformed" for tool-to-model mappings. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ccaa8fa commit 4df7cc7

5 files changed

Lines changed: 253 additions & 8 deletions

File tree

.changeset/loud-ads-double.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@mastra/core': patch
3+
---
4+
5+
Tool `toModelOutput` invocations now emit a `MAPPING` tracing span, showing the transformed output the model receives.

client-sdks/client-js/src/route-types.generated.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11363,6 +11363,7 @@ export type GetObservabilityTraces_QueryParams = {
1136311363
| 'rag_vector_operation'
1136411364
| 'rag_action'
1136511365
| 'graph_action'
11366+
| 'mapping'
1136611367
)
1136711368
| undefined
1136811369
)
@@ -11528,7 +11529,8 @@ export type GetObservabilityTraces_Response = {
1152811529
| 'rag_embedding'
1152911530
| 'rag_vector_operation'
1153011531
| 'rag_action'
11531-
| 'graph_action';
11532+
| 'graph_action'
11533+
| 'mapping';
1153211534
/** Whether this is an event (point-in-time) vs a span (duration) */
1153311535
isEvent: boolean;
1153411536
/** When the span started */
@@ -11735,6 +11737,7 @@ export type GetObservabilityBranches_QueryParams = {
1173511737
| 'rag_vector_operation'
1173611738
| 'rag_action'
1173711739
| 'graph_action'
11740+
| 'mapping'
1173811741
)
1173911742
| undefined
1174011743
)
@@ -11901,7 +11904,8 @@ export type GetObservabilityBranches_Response = {
1190111904
| 'rag_embedding'
1190211905
| 'rag_vector_operation'
1190311906
| 'rag_action'
11904-
| 'graph_action';
11907+
| 'graph_action'
11908+
| 'mapping';
1190511909
/** Whether this is an event (point-in-time) vs a span (duration) */
1190611910
isEvent: boolean;
1190711911
/** When the span started */
@@ -12091,7 +12095,8 @@ export type GetObservabilityTracesTraceIdBranchesSpanId_Response = {
1209112095
| 'rag_embedding'
1209212096
| 'rag_vector_operation'
1209312097
| 'rag_action'
12094-
| 'graph_action';
12098+
| 'graph_action'
12099+
| 'mapping';
1209512100
/** Whether this is an event (point-in-time) vs a span (duration) */
1209612101
isEvent: boolean;
1209712102
/** When the span started */
@@ -12274,7 +12279,8 @@ export type GetObservabilityTracesTraceId_Response = {
1227412279
| 'rag_embedding'
1227512280
| 'rag_vector_operation'
1227612281
| 'rag_action'
12277-
| 'graph_action';
12282+
| 'graph_action'
12283+
| 'mapping';
1227812284
/** Whether this is an event (point-in-time) vs a span (duration) */
1227912285
isEvent: boolean;
1228012286
/** When the span started */
@@ -12451,7 +12457,8 @@ export type GetObservabilityTracesTraceIdLight_Response = {
1245112457
| 'rag_embedding'
1245212458
| 'rag_vector_operation'
1245312459
| 'rag_action'
12454-
| 'graph_action';
12460+
| 'graph_action'
12461+
| 'mapping';
1245512462
/** Whether this is an event (point-in-time) vs a span (duration) */
1245612463
isEvent: boolean;
1245712464
/** When the span started */
@@ -12549,7 +12556,8 @@ export type GetObservabilityTracesTraceIdSpansSpanId_Response = {
1254912556
| 'rag_embedding'
1255012557
| 'rag_vector_operation'
1255112558
| 'rag_action'
12552-
| 'graph_action';
12559+
| 'graph_action'
12560+
| 'mapping';
1255312561
/** Whether this is an event (point-in-time) vs a span (duration) */
1255412562
isEvent: boolean;
1255512563
/** When the span started */
@@ -12854,6 +12862,7 @@ export type GetObservabilityTracesTraceIdSpanIdScores_Response = {
1285412862
| 'rag_vector_operation'
1285512863
| 'rag_action'
1285612864
| 'graph_action'
12865+
| 'mapping'
1285712866
)
1285812867
| undefined;
1285912868
structuredOutput?: boolean | undefined;

packages/core/src/loop/workflows/agentic-execution/llm-mapping-step.test.ts

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1195,4 +1195,198 @@ describe('createLLMMappingStep toModelOutput', () => {
11951195
// toModelOutput should NOT be called for undefined results
11961196
expect(toModelOutputMock).not.toHaveBeenCalled();
11971197
});
1198+
1199+
// ---- MAPPING span coverage (issue #15486) ----
1200+
1201+
type MockChildSpan = {
1202+
createOptions: any;
1203+
endOptions?: any;
1204+
errorOptions?: any;
1205+
ended: boolean;
1206+
errored: boolean;
1207+
end: Mock;
1208+
error: Mock;
1209+
};
1210+
1211+
function createMockParentSpan() {
1212+
const childSpans: MockChildSpan[] = [];
1213+
const parentSpan: any = {
1214+
createChildSpan: vi.fn((opts: any) => {
1215+
const child: MockChildSpan = {
1216+
createOptions: opts,
1217+
ended: false,
1218+
errored: false,
1219+
end: vi.fn(),
1220+
error: vi.fn(),
1221+
};
1222+
child.end = vi.fn((endOpts: any) => {
1223+
child.endOptions = endOpts;
1224+
child.ended = true;
1225+
}) as any;
1226+
child.error = vi.fn((errOpts: any) => {
1227+
child.errorOptions = errOpts;
1228+
child.errored = true;
1229+
}) as any;
1230+
childSpans.push(child);
1231+
return child;
1232+
}),
1233+
};
1234+
return { parentSpan, childSpans };
1235+
}
1236+
1237+
it('should emit a MAPPING child span when toModelOutput is defined and runs', async () => {
1238+
const { parentSpan, childSpans } = createMockParentSpan();
1239+
const toModelOutputMock = vi.fn((output: any) => ({ type: 'text', value: output.temperature }));
1240+
1241+
const llmMappingStep = createLLMMappingStep(
1242+
{
1243+
models: {} as any,
1244+
controller,
1245+
messageList,
1246+
runId: 'test-run',
1247+
_internal: { generateId: () => 'test-message-id' },
1248+
tools: {
1249+
weather: {
1250+
execute: async () => ({ temperature: 72 }),
1251+
toModelOutput: toModelOutputMock,
1252+
inputSchema: z.object({}),
1253+
},
1254+
},
1255+
modelSpanTracker: {
1256+
getTracingContext: () => ({ currentSpan: parentSpan }),
1257+
},
1258+
} as any,
1259+
llmExecutionStep,
1260+
);
1261+
1262+
const inputData: ToolCallOutput[] = [
1263+
{ toolCallId: 'call-1', toolName: 'weather', args: {}, result: { temperature: 72 } },
1264+
];
1265+
1266+
await llmMappingStep.execute(createExecuteParams(inputData));
1267+
1268+
expect(parentSpan.createChildSpan).toHaveBeenCalledTimes(1);
1269+
expect(childSpans).toHaveLength(1);
1270+
const [span] = childSpans;
1271+
expect(span.createOptions).toMatchObject({
1272+
type: 'mapping',
1273+
name: "tool output mapping: 'weather'",
1274+
entityType: 'tool',
1275+
entityId: 'weather',
1276+
entityName: 'weather',
1277+
input: { temperature: 72 },
1278+
attributes: {
1279+
mappingType: 'toModelOutput',
1280+
toolCallId: 'call-1',
1281+
},
1282+
});
1283+
expect(span.ended).toBe(true);
1284+
expect(span.endOptions).toEqual({ output: { type: 'text', value: 72 } });
1285+
expect(span.errored).toBe(false);
1286+
});
1287+
1288+
it('should NOT emit a MAPPING span when the tool has no toModelOutput', async () => {
1289+
const { parentSpan, childSpans } = createMockParentSpan();
1290+
1291+
const llmMappingStep = createLLMMappingStep(
1292+
{
1293+
models: {} as any,
1294+
controller,
1295+
messageList,
1296+
runId: 'test-run',
1297+
_internal: { generateId: () => 'test-message-id' },
1298+
tools: {
1299+
plain: {
1300+
execute: async () => ({ temperature: 72 }),
1301+
inputSchema: z.object({}),
1302+
},
1303+
},
1304+
modelSpanTracker: {
1305+
getTracingContext: () => ({ currentSpan: parentSpan }),
1306+
},
1307+
} as any,
1308+
llmExecutionStep,
1309+
);
1310+
1311+
const inputData: ToolCallOutput[] = [
1312+
{ toolCallId: 'call-1', toolName: 'plain', args: {}, result: { temperature: 72 } },
1313+
];
1314+
1315+
await llmMappingStep.execute(createExecuteParams(inputData));
1316+
1317+
expect(parentSpan.createChildSpan).not.toHaveBeenCalled();
1318+
expect(childSpans).toHaveLength(0);
1319+
});
1320+
1321+
it('should NOT emit a MAPPING span when tool result is null/undefined even if toModelOutput is defined', async () => {
1322+
const { parentSpan, childSpans } = createMockParentSpan();
1323+
1324+
const llmMappingStep = createLLMMappingStep(
1325+
{
1326+
models: {} as any,
1327+
controller,
1328+
messageList,
1329+
runId: 'test-run',
1330+
_internal: { generateId: () => 'test-message-id' },
1331+
tools: {
1332+
hitlTool: {
1333+
toModelOutput: vi.fn(),
1334+
inputSchema: z.object({}),
1335+
},
1336+
},
1337+
modelSpanTracker: {
1338+
getTracingContext: () => ({ currentSpan: parentSpan }),
1339+
},
1340+
} as any,
1341+
llmExecutionStep,
1342+
);
1343+
1344+
const inputData: ToolCallOutput[] = [{ toolCallId: 'call-1', toolName: 'hitlTool', args: {}, result: undefined }];
1345+
1346+
await llmMappingStep.execute(createExecuteParams(inputData));
1347+
1348+
expect(parentSpan.createChildSpan).not.toHaveBeenCalled();
1349+
expect(childSpans).toHaveLength(0);
1350+
});
1351+
1352+
it('should mark the MAPPING span as errored and re-throw when toModelOutput throws', async () => {
1353+
const { parentSpan, childSpans } = createMockParentSpan();
1354+
const failure = new Error('transform failed');
1355+
const toModelOutputMock = vi.fn(() => {
1356+
throw failure;
1357+
});
1358+
1359+
const llmMappingStep = createLLMMappingStep(
1360+
{
1361+
models: {} as any,
1362+
controller,
1363+
messageList,
1364+
runId: 'test-run',
1365+
_internal: { generateId: () => 'test-message-id' },
1366+
tools: {
1367+
broken: {
1368+
execute: async () => ({ data: 'raw' }),
1369+
toModelOutput: toModelOutputMock,
1370+
inputSchema: z.object({}),
1371+
},
1372+
},
1373+
modelSpanTracker: {
1374+
getTracingContext: () => ({ currentSpan: parentSpan }),
1375+
},
1376+
} as any,
1377+
llmExecutionStep,
1378+
);
1379+
1380+
const inputData: ToolCallOutput[] = [
1381+
{ toolCallId: 'call-1', toolName: 'broken', args: {}, result: { data: 'raw' } },
1382+
];
1383+
1384+
await expect(llmMappingStep.execute(createExecuteParams(inputData))).rejects.toBe(failure);
1385+
1386+
expect(childSpans).toHaveLength(1);
1387+
const [span] = childSpans;
1388+
expect(span.ended).toBe(false);
1389+
expect(span.errored).toBe(true);
1390+
expect(span.errorOptions).toEqual({ error: failure, endSpan: true });
1391+
});
11981392
});

packages/core/src/loop/workflows/agentic-execution/llm-mapping-step.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { ToolSet } from '@internal/ai-sdk-v5';
22
import { z } from 'zod/v4';
33
import { sanitizeToolName } from '../../../agent/message-list/utils/tool-name';
4-
import { createObservabilityContext } from '../../../observability';
4+
import { createObservabilityContext, EntityType, SpanType } from '../../../observability';
55
import type { ProcessorState } from '../../../processors';
66
import { ProcessorRunner } from '../../../processors/runner';
77
import type { ChunkType, ProviderMetadata } from '../../../stream/types';
@@ -108,9 +108,13 @@ export function createLLMMappingStep<Tools extends ToolSet = ToolSet, OUTPUT = u
108108
*
109109
* Looks up the tool from dynamically loaded tools (`_internal.stepTools`, e.g. via
110110
* ToolSearchProcessor) first, then falls back to the agent's static tool definitions.
111+
*
112+
* When toModelOutput is defined, the transform runs under a MAPPING child span so
113+
* traces can distinguish "never invoked" from "ran no-op" from "ran transforming."
111114
*/
112115
async function getProviderMetadataWithModelOutput(toolCall: {
113116
toolName: string;
117+
toolCallId?: string;
114118
result?: unknown;
115119
providerMetadata?: Record<string, unknown>;
116120
}) {
@@ -121,7 +125,26 @@ export function createLLMMappingStep<Tools extends ToolSet = ToolSet, OUTPUT = u
121125
| undefined;
122126
let modelOutput: unknown;
123127
if (tool?.toModelOutput && toolCall.result != null) {
124-
modelOutput = await tool.toModelOutput(toolCall.result);
128+
const parentSpan = observabilityContext?.tracingContext?.currentSpan;
129+
const mappingSpan = parentSpan?.createChildSpan({
130+
type: SpanType.MAPPING,
131+
name: `tool output mapping: '${toolCall.toolName}'`,
132+
entityType: EntityType.TOOL,
133+
entityId: toolCall.toolName,
134+
entityName: toolCall.toolName,
135+
input: toolCall.result,
136+
attributes: {
137+
mappingType: 'toModelOutput',
138+
toolCallId: toolCall.toolCallId,
139+
},
140+
});
141+
try {
142+
modelOutput = await tool.toModelOutput(toolCall.result);
143+
mappingSpan?.end({ output: modelOutput });
144+
} catch (err) {
145+
mappingSpan?.error({ error: err as Error, endSpan: true });
146+
throw err;
147+
}
125148
}
126149

127150
const existingMastra = (toolCall.providerMetadata as any)?.mastra;

packages/core/src/observability/types/tracing.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ export enum SpanType {
8484
RAG_ACTION = 'rag_action',
8585
/** Graph operations (build / traverse) - not RAG-specific */
8686
GRAPH_ACTION = 'graph_action',
87+
/** Inline data mapping between pipeline stages (e.g. a tool's `toModelOutput` transform) */
88+
MAPPING = 'mapping',
8789
}
8890

8991
export { EntityType };
@@ -342,6 +344,17 @@ export interface MCPToolCallAttributes extends AIBaseAttributes {
342344
success?: boolean;
343345
}
344346

347+
/**
348+
* Mapping attributes — for inline data transforms between pipeline stages
349+
* (e.g. a tool's `toModelOutput` reshaping the tool result before the model sees it).
350+
*/
351+
export interface MappingAttributes extends AIBaseAttributes {
352+
/** Identifier of the mapping (e.g. `toModelOutput`) so UIs can group related mappings */
353+
mappingType?: string;
354+
/** Associated tool call id when the mapping operates on a tool result */
355+
toolCallId?: string;
356+
}
357+
345358
/**
346359
* Processor attributes
347360
*/
@@ -647,6 +660,7 @@ export interface SpanTypeMap {
647660
[SpanType.RAG_VECTOR_OPERATION]: RagVectorOperationAttributes;
648661
[SpanType.RAG_ACTION]: RagActionAttributes;
649662
[SpanType.GRAPH_ACTION]: GraphActionAttributes;
663+
[SpanType.MAPPING]: MappingAttributes;
650664
}
651665

652666
/**

0 commit comments

Comments
 (0)