diff --git a/packages/client/src/pages/DashboardPage/RunPage/TracingComponent/TracePanel/SpanPanel/index.tsx b/packages/client/src/pages/DashboardPage/RunPage/TracingComponent/TracePanel/SpanPanel/index.tsx index 0e40b51a..69a5d809 100644 --- a/packages/client/src/pages/DashboardPage/RunPage/TracingComponent/TracePanel/SpanPanel/index.tsx +++ b/packages/client/src/pages/DashboardPage/RunPage/TracingComponent/TracePanel/SpanPanel/index.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'; import { SpanData } from '@shared/types/trace.ts'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { materialDark } from 'react-syntax-highlighter/dist/esm/styles/prism'; +import { getNestedValue } from '@shared/utils/objectUtils'; interface SpanSectionProps { title: string; @@ -52,10 +53,21 @@ const SpanPanel = ({ span }: Props) => { return null; } - const attributes = span.attributes?.agentscope - ?.function as unknown as Record; - const operation_name = span.attributes?.gen_ai?.operation - ?.name as unknown as string; + // Use getNestedValue to support both flat and nested structures + // For flat structure, we need to get input/output directly + // For nested structure, we can get the function object first + const agentscopeInput = getNestedValue( + span.attributes, + 'agentscope.function.input', + ) as Record | undefined; + const agentscopeOutput = getNestedValue( + span.attributes, + 'agentscope.function.output', + ) as Record | undefined; + const operation_name = getNestedValue( + span.attributes, + 'gen_ai.operation.name', + ) as string | undefined; const renderCol = (title: string, value: string | ReactNode) => { return (
@@ -97,16 +109,12 @@ const SpanPanel = ({ span }: Props) => { - } + content={agentscopeInput || {}} /> - } + content={agentscopeOutput || {}} />
); diff --git a/packages/client/src/pages/DashboardPage/RunPage/TracingComponent/TracePanel/TraceTree/index.tsx b/packages/client/src/pages/DashboardPage/RunPage/TracingComponent/TracePanel/TraceTree/index.tsx index 46bc6948..267886af 100644 --- a/packages/client/src/pages/DashboardPage/RunPage/TracingComponent/TracePanel/TraceTree/index.tsx +++ b/packages/client/src/pages/DashboardPage/RunPage/TracingComponent/TracePanel/TraceTree/index.tsx @@ -7,6 +7,7 @@ import { formatDurationWithUnit } from '@/utils/common'; import SpanPanel from '@/pages/DashboardPage/RunPage/TracingComponent/TracePanel/SpanPanel'; import { SpanData } from '@shared/types/trace.ts'; +import { getNestedValue } from '@shared/utils/objectUtils'; interface TraceSpanNode extends SpanData { children: TraceSpanNode[]; @@ -14,24 +15,24 @@ interface TraceSpanNode extends SpanData { // Helper function to get display kind - extracted to avoid recalculation const getDisplayKind = (attributes: Record): string => { - const genAi = attributes.gen_ai as Record | undefined; - const agentscope = attributes.agentscope as - | Record - | undefined; - - const operationName = (genAi?.operation as Record) - ?.name as string; - const agent_name = (genAi?.agent as Record)?.name as + // Use getNestedValue to support both flat and nested structures + const operationName = getNestedValue( + attributes, + 'gen_ai.operation.name', + ) as string | undefined; + const agent_name = getNestedValue(attributes, 'gen_ai.agent.name') as | string | undefined; - const model_name = (genAi?.request as Record)?.model as + const model_name = getNestedValue(attributes, 'gen_ai.request.model') as | string | undefined; - const tool_name = (genAi?.tool as Record)?.name as + const tool_name = getNestedValue(attributes, 'gen_ai.tool.name') as | string | undefined; - const format_target = (agentscope?.format as Record) - ?.target as string | undefined; + const format_target = getNestedValue( + attributes, + 'agentscope.format.target', + ) as string | undefined; if (operationName === 'invoke_agent' && agent_name) { return operationName + ': ' + String(agent_name); @@ -75,11 +76,11 @@ export const TraceTree = ({ spans }: Props) => { const spanTitleDataMap = useMemo(() => { const map = new Map(); spans.forEach((span) => { - const agentscope = span.attributes.agentscope as - | Record - | undefined; - const funcName = (agentscope?.function as Record) - ?.name as string | undefined; + // Use getNestedValue to support both flat and nested structures + const funcName = getNestedValue( + span.attributes, + 'agentscope.function.name', + ) as string | undefined; map.set(span.spanId, { name: funcName || span.name, startTimeUnixNano: span.startTimeUnixNano, diff --git a/packages/server/src/dao/Trace.ts b/packages/server/src/dao/Trace.ts index 39e0465c..101514c0 100644 --- a/packages/server/src/dao/Trace.ts +++ b/packages/server/src/dao/Trace.ts @@ -190,14 +190,24 @@ export class SpanDao { attributes: Record, ): number | undefined { const value = getNestedValue(attributes, 'gen_ai.usage.input_tokens'); - return typeof value === 'number' ? value : undefined; + if (typeof value === 'number' && !Number.isNaN(value)) return value; + if (typeof value === 'string') { + const n = Number(value); + return Number.isNaN(n) ? undefined : n; + } + return undefined; } private static extractOutputTokens( attributes: Record, ): number | undefined { const value = getNestedValue(attributes, 'gen_ai.usage.output_tokens'); - return typeof value === 'number' ? value : undefined; + if (typeof value === 'number' && !Number.isNaN(value)) return value; + if (typeof value === 'string') { + const n = Number(value); + return Number.isNaN(n) ? undefined : n; + } + return undefined; } // Trace listing and filtering methods diff --git a/packages/server/src/migrations/1730000000000-AddMessageReplyForeignKey.ts b/packages/server/src/migrations/1730000000000-AddMessageReplyForeignKey.ts index 16031e3f..f1c45460 100644 --- a/packages/server/src/migrations/1730000000000-AddMessageReplyForeignKey.ts +++ b/packages/server/src/migrations/1730000000000-AddMessageReplyForeignKey.ts @@ -14,7 +14,9 @@ import { * 2. Migrate historical data: create Reply records for all replyIds * 3. Change message_table.replyId to a non-nullable foreign key */ -export class AddMessageReplyForeignKey1730000000000 implements MigrationInterface { +export class AddMessageReplyForeignKey1730000000000 + implements MigrationInterface +{ name = 'AddMessageReplyForeignKey1730000000000'; public async up(queryRunner: QueryRunner): Promise { diff --git a/packages/server/src/otel/processor.ts b/packages/server/src/otel/processor.ts index 2a35d2e0..e424fcac 100644 --- a/packages/server/src/otel/processor.ts +++ b/packages/server/src/otel/processor.ts @@ -7,10 +7,7 @@ import { SpanResource, SpanScope, } from '../../../shared/src/types/trace'; -import { - getNestedValue, - unflattenObject, -} from '../../../shared/src/utils/objectUtils'; +import { getNestedValue } from '../../../shared/src/utils/objectUtils'; import { decodeUnixNano, getTimeDifferenceNano, @@ -127,8 +124,9 @@ export class SpanProcessor { private static decodeAttributes(attributes: unknown): Attributes { const attrs = Array.isArray(attributes) ? attributes : []; - return this.unflattenAttributes( - this.loadJsonStrings(this.decodeKeyValues(attrs)), + // Keep attributes in original structure (no unflatten) - preserve as-is from OTLP + return this.loadJsonStrings( + this.decodeKeyValues(attrs), ) as unknown as Attributes; } @@ -188,7 +186,11 @@ export class SpanProcessor { } // Check if already in new format by looking for gen_ai attributes - if (getNestedValue(attributes, 'gen_ai')) { + // Support both flat structure (e.g., "gen_ai.conversation.id") and nested structure + const hasGenAi = + Object.keys(attributes).some((key) => key.startsWith('gen_ai.')) || + getNestedValue(attributes, 'gen_ai') !== undefined; + if (hasGenAi) { return { span_name: span.name || '', attributes: attributes }; } @@ -312,14 +314,7 @@ export class SpanProcessor { private static decodeAnyValue(value: unknown): unknown { const valueObj = value as Record; - if (valueObj.bool_value !== false && valueObj.bool_value !== undefined) - return valueObj.bool_value; - if (valueObj.int_value !== 0 && valueObj.int_value !== undefined) - return valueObj.int_value; - if (valueObj.double_value !== 0 && valueObj.double_value !== undefined) - return valueObj.double_value; - if (valueObj.string_value !== '' && valueObj.string_value !== undefined) - return valueObj.string_value; + // Prefer complex types first so nested structure is preserved const arrayValue = valueObj.array_value as | { values?: unknown[] } | undefined; @@ -328,14 +323,12 @@ export class SpanProcessor { this.decodeAnyValue(v), ); } - const kvlistValue = valueObj.kvlist_value as | { values?: unknown[] } | undefined; if (kvlistValue?.values) { return this.decodeKeyValues(kvlistValue.values); } - if ( valueObj.bytes_value && typeof valueObj.bytes_value === 'object' && @@ -343,10 +336,40 @@ export class SpanProcessor { ) { return valueObj.bytes_value; } - + // Check primitive fields in priority order: string > int > double > bool + // Handle protobuf defaults: bool_value=false might be default (unset), + // so we check string/int/double first, then bool only if others are not set + // For string/int/double, we skip empty/zero values initially, then fallback + if ( + valueObj.string_value !== undefined && + valueObj.string_value !== null && + valueObj.string_value !== '' + ) { + return valueObj.string_value; + } + if ( + valueObj.int_value !== undefined && + valueObj.int_value !== null && + valueObj.int_value !== 0 + ) { + return valueObj.int_value; + } + if ( + valueObj.double_value !== undefined && + valueObj.double_value !== null && + valueObj.double_value !== 0 + ) { + return valueObj.double_value; + } + // bool_value: only return true (false might be default) + if (valueObj.bool_value === true) { + return valueObj.bool_value; + } + // Fallback: return any remaining values (including false, 0, "") + // This handles cases where false/0/"" are actual values + if (valueObj.string_value !== undefined) return valueObj.string_value; if (valueObj.int_value !== undefined) return valueObj.int_value; if (valueObj.double_value !== undefined) return valueObj.double_value; - if (valueObj.string_value !== undefined) return valueObj.string_value; if (valueObj.bool_value !== undefined) return valueObj.bool_value; return null; } @@ -391,30 +414,45 @@ export class SpanProcessor { }; } - private static unflattenAttributes( - flat: Record, - ): Record { - return unflattenObject(flat); - } - + /** + * Recursively parse JSON strings in attributes so nested values (e.g. from + * kvlist_value or SDK) are usable. Only plain objects are recursed; arrays + * and primitives are left as-is to avoid breaking OTLP array/bytes. + */ private static loadJsonStrings( attributes: Record, ): Record { const result: Record = {}; for (const [key, value] of Object.entries(attributes)) { - if (typeof value === 'string') { - try { - result[key] = JSON.parse(value); - } catch { - result[key] = value; - } - } else { - result[key] = value; - } + result[key] = this.parseJsonValue(value); } return result; } + private static parseJsonValue(value: unknown): unknown { + if (typeof value === 'string') { + try { + return this.parseJsonValue(JSON.parse(value)); + } catch { + return value; + } + } + if ( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + !(value instanceof Uint8Array) + ) { + const obj = value as Record; + const out: Record = {}; + for (const [k, v] of Object.entries(obj)) { + out[k] = this.parseJsonValue(v); + } + return out; + } + return value; + } + private static decodeResource(resource: unknown): SpanResource { const r = resource as Record; return { diff --git a/packages/shared/src/utils/objectUtils.ts b/packages/shared/src/utils/objectUtils.ts index ab1609f4..46bca3e1 100644 --- a/packages/shared/src/utils/objectUtils.ts +++ b/packages/shared/src/utils/objectUtils.ts @@ -7,10 +7,16 @@ export function getNestedValue( return undefined; } - const keys: string[] = Array.isArray(path) - ? path.flatMap((k) => k.split(separator)) - : path.split(separator); + // Convert path to string if it's an array + const pathString = Array.isArray(path) ? path.join(separator) : path; + // First, try direct access for flat structure (e.g., "gen_ai.input.messages" as a key) + if (pathString in obj) { + return obj[pathString]; + } + + // If not found, try nested access (for nested structure) + const keys: string[] = pathString.split(separator); return keys.reduce((acc, key) => { if (acc && typeof acc === 'object' && key in acc) { return (acc as Record)[key];