@@ -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" ;
3539import { waitForDiagnosticEventsDrained } from "openclaw/plugin-sdk/diagnostic-runtime" ;
3640import { 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+
915956function 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+
11161175function 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