Skip to content

Commit d09469c

Browse files
authored
fix: show exported tool results in trace viewers (#101371)
* fix: show exported tool results in trace viewers * fix(diagnostics-otel): emit semconv response key and execute_tool identity so trace viewers show tool results --------- Co-authored-by: Alex Knight <[email protected]>
1 parent 59bc7b0 commit d09469c

3 files changed

Lines changed: 233 additions & 23 deletions

File tree

docs/gateway/opentelemetry.md

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -162,11 +162,15 @@ Boolean `captureContent: true` enables `inputMessages`, `outputMessages`, `toolI
162162
</Note>
163163

164164
`toolInputs`/`toolOutputs` content is captured for the built-in agent
165-
runtime's tool executions (`openclaw.content.tool_input` on
166-
completed/error spans, `openclaw.content.tool_output` on completed spans).
167-
External harness tool calls (Codex, Claude CLI) emit `tool.execution.*` spans
168-
without content payloads. Captured content travels on a trusted,
169-
listener-only channel and is never placed on the public diagnostic event bus.
165+
runtime's tool executions (`openclaw.content.tool_input` and
166+
`gen_ai.tool.call.arguments` on completed/error spans;
167+
`openclaw.content.tool_output` and `gen_ai.tool.call.result` on completed
168+
spans). The `openclaw.content.*` names remain the stable OpenClaw attribute
169+
names; the `gen_ai.tool.call.*` copies mirror them for semconv-native viewers.
170+
External harness tool calls (Codex, Claude CLI) emit
171+
`tool.execution.*` spans without content payloads. Captured content travels on a
172+
trusted, listener-only channel and is never placed on the public diagnostic event
173+
bus.
170174

171175
## Sampling and flushing
172176

@@ -346,7 +350,7 @@ Liveness warnings also emit:
346350
- On completion: `openclaw.harness.result_classification`, `openclaw.harness.yield_detected`, `openclaw.harness.items.started`, `openclaw.harness.items.completed`, `openclaw.harness.items.active`
347351
- On error: `openclaw.harness.phase`, `openclaw.errorCategory`, optional `openclaw.harness.cleanup_failed`
348352
- `openclaw.tool.execution`
349-
- `gen_ai.tool.name`, `openclaw.toolName`, `openclaw.tool.source`, optional `openclaw.tool.owner`, `openclaw.tool.params.*`
353+
- `gen_ai.tool.name`, `gen_ai.operation.name` (`execute_tool`), `openclaw.toolName`, `openclaw.tool.source`, optional `gen_ai.tool.call.id`, `openclaw.tool.owner`, `openclaw.tool.params.*`
350354
- Optional `openclaw.errorCategory`/`openclaw.errorCode` on errors, `openclaw.deniedReason` and `openclaw.outcome=blocked` when denied by policy or sandbox
351355
- `openclaw.exec`
352356
- `openclaw.exec.target`, `openclaw.exec.mode`, `openclaw.outcome`, `openclaw.failureKind`, `openclaw.exec.command_length`, `openclaw.exec.exit_code`, `openclaw.exec.exit_signal`, `openclaw.exec.timed_out`

extensions/diagnostics-otel/src/service.test.ts

Lines changed: 138 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4827,6 +4827,10 @@ describe("diagnostics-otel service", () => {
48274827
expect(Object.hasOwn(toolOptions?.attributes ?? {}, "openclaw.content.tool_output")).toBe(
48284828
false,
48294829
);
4830+
expect(Object.hasOwn(toolOptions?.attributes ?? {}, "gen_ai.tool.call.arguments")).toBe(false);
4831+
expect(Object.hasOwn(toolOptions?.attributes ?? {}, "gen_ai.tool.call.result")).toBe(false);
4832+
expect(toolOptions?.attributes?.["gen_ai.tool.call.id"]).toBe("tool-1");
4833+
expect(toolOptions?.attributes?.["gen_ai.operation.name"]).toBe("execute_tool");
48304834
expect(toolOptions?.startTime).toBeTypeOf("number");
48314835
await service.stop?.(ctx);
48324836
});
@@ -4892,10 +4896,19 @@ describe("diagnostics-otel service", () => {
48924896
"sk-1234567890abcdef1234567890abcdef", // pragma: allowlist secret
48934897
);
48944898
expect(toolAttrs?.["openclaw.content.tool_input"]).toBe("tool input");
4899+
expect(toolAttrs?.["gen_ai.tool.call.id"]).toBe("tool-1");
4900+
expect(toolAttrs?.["gen_ai.operation.name"]).toBe("execute_tool");
4901+
expect(toolAttrs?.["gen_ai.tool.call.arguments"]).toBe(
4902+
toolAttrs?.["openclaw.content.tool_input"],
4903+
);
4904+
expect(typeof toolAttrs?.["openclaw.content.tool_output"]).toBe("string");
48954905
expect(String(toolAttrs?.["openclaw.content.tool_output"]).length).toBeLessThanOrEqual(
48964906
MAX_TEST_OTEL_CONTENT_ATTRIBUTE_CHARS + OTEL_TRUNCATED_SUFFIX_MAX_CHARS,
48974907
);
48984908
expect(String(toolAttrs?.["openclaw.content.tool_output"])).not.toContain("a".repeat(11));
4909+
expect(toolAttrs?.["gen_ai.tool.call.result"]).toBe(
4910+
toolAttrs?.["openclaw.content.tool_output"],
4911+
);
48994912
await service.stop?.(ctx);
49004913
});
49014914

@@ -5011,7 +5024,7 @@ describe("diagnostics-otel service", () => {
50115024
},
50125025
{
50135026
role: "tool",
5014-
parts: [{ type: "tool_call_response", id: "call-1", result: { rows: 1 } }],
5027+
parts: [{ type: "tool_call_response", id: "call-1", response: { rows: 1 } }],
50155028
},
50165029
]);
50175030
expect(JSON.parse(stringAttribute(attrs, "gen_ai.output.messages"))).toEqual([
@@ -5034,6 +5047,130 @@ describe("diagnostics-otel service", () => {
50345047
await service.stop?.(ctx);
50355048
});
50365049

5050+
test("emits semconv response text for tool response parts", async () => {
5051+
const service = createDiagnosticsOtelService();
5052+
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, {
5053+
traces: true,
5054+
captureContent: {
5055+
enabled: true,
5056+
inputMessages: true,
5057+
outputMessages: false,
5058+
},
5059+
});
5060+
await service.start(ctx);
5061+
5062+
emitTrustedModelCallCompletedWithContent(
5063+
{
5064+
runId: "run-1",
5065+
callId: "call-1",
5066+
provider: "openai",
5067+
model: "gpt-5.4",
5068+
durationMs: 80,
5069+
},
5070+
{
5071+
inputMessages: [
5072+
{
5073+
role: "tool",
5074+
parts: [
5075+
{
5076+
type: "tool_call_response",
5077+
id: "call-1",
5078+
result: [
5079+
{ type: "text", text: "first line" },
5080+
{ type: "text", text: "second line" },
5081+
],
5082+
},
5083+
],
5084+
},
5085+
{
5086+
role: "toolResult",
5087+
toolCallId: "call-2",
5088+
content: [
5089+
{ type: "text", text: "alpha" },
5090+
{ type: "text", text: "beta" },
5091+
],
5092+
},
5093+
],
5094+
},
5095+
);
5096+
await flushDiagnosticEvents();
5097+
5098+
const modelCall = telemetryState.tracer.startSpan.mock.calls.find(
5099+
(call) => call[0] === "openclaw.model.call",
5100+
);
5101+
const attrs = (modelCall?.[1] as { attributes?: Record<string, unknown> } | undefined)
5102+
?.attributes;
5103+
expect(JSON.parse(stringAttribute(attrs, "gen_ai.input.messages"))).toEqual([
5104+
{
5105+
role: "tool",
5106+
parts: [
5107+
{
5108+
type: "tool_call_response",
5109+
id: "call-1",
5110+
response: "first line\nsecond line",
5111+
},
5112+
],
5113+
},
5114+
{
5115+
role: "tool",
5116+
parts: [
5117+
{
5118+
type: "tool_call_response",
5119+
id: "call-2",
5120+
response: "alpha\nbeta",
5121+
},
5122+
],
5123+
},
5124+
]);
5125+
await service.stop?.(ctx);
5126+
});
5127+
5128+
test("flattens oversized pure-text tool results with a truncation marker", async () => {
5129+
const service = createDiagnosticsOtelService();
5130+
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, {
5131+
traces: true,
5132+
captureContent: {
5133+
enabled: true,
5134+
inputMessages: true,
5135+
outputMessages: false,
5136+
},
5137+
});
5138+
await service.start(ctx);
5139+
5140+
const textParts = Array.from({ length: 201 }, (_, index) => ({
5141+
type: "text",
5142+
text: `line-${index}`,
5143+
}));
5144+
emitTrustedModelCallCompletedWithContent(
5145+
{
5146+
runId: "run-1",
5147+
callId: "call-1",
5148+
provider: "openai",
5149+
model: "gpt-5.4",
5150+
durationMs: 80,
5151+
},
5152+
{
5153+
inputMessages: [{ role: "toolResult", toolCallId: "call-1", content: textParts }],
5154+
},
5155+
);
5156+
await flushDiagnosticEvents();
5157+
5158+
const modelCall = telemetryState.tracer.startSpan.mock.calls.find(
5159+
(call) => call[0] === "openclaw.model.call",
5160+
);
5161+
const attrs = (modelCall?.[1] as { attributes?: Record<string, unknown> } | undefined)
5162+
?.attributes;
5163+
const messages = JSON.parse(stringAttribute(attrs, "gen_ai.input.messages")) as {
5164+
parts: { response?: unknown }[];
5165+
}[];
5166+
const expected = `${textParts
5167+
.slice(0, 200)
5168+
.map((part) => part.text)
5169+
.join("\n")}\n...(1 more text parts omitted)`;
5170+
expect(messages[0]?.parts[0]?.response).toBe(expected);
5171+
await service.stop?.(ctx);
5172+
});
5173+
50375174
test("normalizes snake_case tool_call parts the same as camelCase toolCall parts", async () => {
50385175
const service = createDiagnosticsOtelService();
50395176
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, {

extensions/diagnostics-otel/src/service.ts

Lines changed: 85 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,11 @@ import {
3030
ATTR_GEN_AI_INPUT_MESSAGES,
3131
ATTR_GEN_AI_OUTPUT_MESSAGES,
3232
ATTR_GEN_AI_SYSTEM_INSTRUCTIONS,
33+
ATTR_GEN_AI_TOOL_CALL_ARGUMENTS,
34+
ATTR_GEN_AI_TOOL_CALL_ID,
35+
ATTR_GEN_AI_TOOL_CALL_RESULT,
3336
ATTR_GEN_AI_TOOL_DEFINITIONS,
37+
GEN_AI_OPERATION_NAME_VALUE_EXECUTE_TOOL,
3438
} from "@opentelemetry/semantic-conventions/incubating";
3539
import { waitForDiagnosticEventsDrained } from "openclaw/plugin-sdk/diagnostic-runtime";
3640
import { createNodeProxyAgent } from "openclaw/plugin-sdk/fetch-runtime";
@@ -912,11 +916,53 @@ function textPart(content: string): Record<string, unknown> {
912916
return { type: "text", content };
913917
}
914918

919+
// Shared text-part reading for gen_ai message normalization: OpenClaw emits
920+
// {type:"text", text}; some harness shapes carry {type:"text", content}.
921+
function textPartContent(part: Record<string, unknown>): string | undefined {
922+
if (part.type !== "text") {
923+
return undefined;
924+
}
925+
if (typeof part.text === "string") {
926+
return part.text;
927+
}
928+
return typeof part.content === "string" ? part.content : undefined;
929+
}
930+
931+
// Tool results usually arrive as arrays of text parts. Flatten pure-text arrays
932+
// into one plain string so the part's `response` renders as readable text in
933+
// trace viewers; mixed/structured results keep their raw (bounded, redacted) shape.
934+
function toolCallResponseValue(value: unknown): unknown {
935+
if (!Array.isArray(value)) {
936+
return value;
937+
}
938+
const textItems: string[] = [];
939+
for (const item of value) {
940+
const text =
941+
typeof item === "string" ? item : isRecord(item) ? textPartContent(item) : undefined;
942+
if (typeof text !== "string") {
943+
return value;
944+
}
945+
textItems.push(text);
946+
}
947+
const kept = textItems.slice(0, MAX_OTEL_CONTENT_ARRAY_ITEMS);
948+
const joined = kept.filter((text) => text.length > 0).join("\n");
949+
if (joined.length === 0) {
950+
return value;
951+
}
952+
const omitted = textItems.length - kept.length;
953+
return omitted > 0 ? `${joined}\n...(${omitted} more text parts omitted)` : joined;
954+
}
955+
915956
function toolCallResponsePart(part: Record<string, unknown>): Record<string, unknown> {
916957
return {
917958
type: "tool_call_response",
918959
...(typeof part.id === "string" ? { id: part.id } : {}),
919-
result: part.result ?? part.response ?? part.content ?? part.details ?? "",
960+
// Semconv gen_ai.*.messages requires the `response` key on tool_call_response
961+
// parts (gen-ai-input-messages.json, since v1.37). Schema-validating viewers
962+
// (e.g. Phoenix) silently drop parts keyed `result`, hiding tool output.
963+
response: toolCallResponseValue(
964+
part.response ?? part.result ?? part.content ?? part.details ?? "",
965+
),
920966
};
921967
}
922968

@@ -945,10 +991,9 @@ function contentParts(value: unknown): Record<string, unknown>[] {
945991
if (!isRecord(part)) {
946992
continue;
947993
}
948-
if (part.type === "text" && typeof part.text === "string") {
949-
parts.push(textPart(part.text));
950-
} else if (part.type === "text" && typeof part.content === "string") {
951-
parts.push(textPart(part.content));
994+
const text = textPartContent(part);
995+
if (text !== undefined) {
996+
parts.push(textPart(text));
952997
} else if (part.type === "thinking" && typeof part.thinking === "string") {
953998
parts.push({ type: "reasoning", content: part.thinking });
954999
} else if (part.type === "toolCall" && typeof part.name === "string") {
@@ -1002,7 +1047,7 @@ function normalizeGenAiMessage(
10021047
: [
10031048
toolCallResponsePart({
10041049
id: value.toolCallId,
1005-
result: value.content ?? value.details ?? "",
1050+
response: value.content ?? value.details ?? "",
10061051
}),
10071052
];
10081053
} else {
@@ -1113,6 +1158,20 @@ function assignOtelContentAttribute(
11131158
}
11141159
}
11151160

1161+
function assignOtelToolIdentityAttributes(
1162+
attributes: Record<string, string | number | boolean>,
1163+
evt: { toolCallId?: string },
1164+
): void {
1165+
// Semconv execute_tool identity, span-only by design: metric attrs must stay
1166+
// low-cardinality, and unlike the dropped openclaw.toolCallId passthrough keys
1167+
// (DROPPED_OTEL_ATTRIBUTE_KEYS) the semconv id is a deliberate per-span export.
1168+
attributes["gen_ai.operation.name"] = GEN_AI_OPERATION_NAME_VALUE_EXECUTE_TOOL;
1169+
const toolCallId = evt.toolCallId?.trim();
1170+
if (toolCallId) {
1171+
attributes[ATTR_GEN_AI_TOOL_CALL_ID] = toolCallId;
1172+
}
1173+
}
1174+
11161175
function assignOtelModelContentAttributes(
11171176
attributes: Record<string, string | number | boolean>,
11181177
content: OtelModelCallContent | undefined,
@@ -1150,11 +1209,21 @@ function assignOtelToolContentAttributes(
11501209
content: OtelToolCallContent | undefined,
11511210
policy: OtelContentCapturePolicy,
11521211
): void {
1212+
// Mirror captured content onto the semconv keys next to the shipped
1213+
// openclaw.content.* names; normalize once so both copies stay byte-identical.
11531214
if (policy.toolInputs) {
1154-
assignOtelContentAttribute(attributes, "openclaw.content.tool_input", content?.toolInput);
1215+
const toolInput = normalizeOtelContentValue(content?.toolInput);
1216+
if (toolInput) {
1217+
attributes[ATTR_GEN_AI_TOOL_CALL_ARGUMENTS] = toolInput;
1218+
attributes["openclaw.content.tool_input"] = toolInput;
1219+
}
11551220
}
11561221
if (policy.toolOutputs) {
1157-
assignOtelContentAttribute(attributes, "openclaw.content.tool_output", content?.toolOutput);
1222+
const toolOutput = normalizeOtelContentValue(content?.toolOutput);
1223+
if (toolOutput) {
1224+
attributes[ATTR_GEN_AI_TOOL_CALL_RESULT] = toolOutput;
1225+
attributes["openclaw.content.tool_output"] = toolOutput;
1226+
}
11581227
}
11591228
}
11601229

@@ -3556,10 +3625,12 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
35563625
if (!tracesEnabled || !metadata.trusted) {
35573626
return;
35583627
}
3628+
const spanAttrs = toolExecutionBaseAttrs(evt);
3629+
assignOtelToolIdentityAttributes(spanAttrs, evt);
35593630
trackTrustedSpan(
35603631
evt,
35613632
metadata,
3562-
spanWithDuration("openclaw.tool.execution", toolExecutionBaseAttrs(evt), undefined, {
3633+
spanWithDuration("openclaw.tool.execution", spanAttrs, undefined, {
35633634
parentContext: activeTrustedParentContext(evt, metadata),
35643635
startTimeMs: evt.ts,
35653636
}),
@@ -3576,10 +3647,9 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
35763647
if (!tracesEnabled) {
35773648
return;
35783649
}
3579-
const spanAttrs: Record<string, string | number | boolean> = {
3580-
...toolExecutionBaseAttrs(evt),
3581-
};
3650+
const spanAttrs: Record<string, string | number | boolean> = { ...attrs };
35823651
addRunAttrs(spanAttrs, evt);
3652+
assignOtelToolIdentityAttributes(spanAttrs, evt);
35833653
assignOtelToolContentAttributes(spanAttrs, toolContent, contentCapturePolicy);
35843654
const span =
35853655
takeTrackedTrustedSpan(evt, metadata) ??
@@ -3604,11 +3674,9 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
36043674
if (!tracesEnabled) {
36053675
return;
36063676
}
3607-
const spanAttrs: Record<string, string | number | boolean> = {
3608-
...toolExecutionBaseAttrs(evt),
3609-
"openclaw.errorCategory": lowCardinalityAttr(evt.errorCategory, "other"),
3610-
};
3677+
const spanAttrs: Record<string, string | number | boolean> = { ...attrs };
36113678
addRunAttrs(spanAttrs, evt);
3679+
assignOtelToolIdentityAttributes(spanAttrs, evt);
36123680
if (evt.errorCode) {
36133681
spanAttrs["openclaw.errorCode"] = lowCardinalityAttr(evt.errorCode, "other");
36143682
}
@@ -3644,6 +3712,7 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
36443712
"openclaw.deniedReason": lowCardinalityAttr(evt.deniedReason, "other"),
36453713
};
36463714
addRunAttrs(spanAttrs, evt);
3715+
assignOtelToolIdentityAttributes(spanAttrs, evt);
36473716
const span = spanWithDuration("openclaw.tool.execution", spanAttrs, 0, {
36483717
parentContext: activeTrustedParentContext(evt, metadata),
36493718
endTimeMs: evt.ts,

0 commit comments

Comments
 (0)