diff --git a/package.json b/package.json index 69631cdb..fc2a07c4 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "@grpc/grpc-js": "^1.14.1", "@opentelemetry/api": "^1.9.0", "@trpc/server": "^11.0.1", + "@uiw/react-json-view": "^2.0.0-alpha.41", "better-sqlite3": "^12.6.0", "cors": "^2.8.5", "express": "^5.1.0", 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/client/src/pages/TracePage/TraceDetailPage/index.tsx b/packages/client/src/pages/TracePage/TraceDetailPage/index.tsx index d98e6a91..571ab109 100644 --- a/packages/client/src/pages/TracePage/TraceDetailPage/index.tsx +++ b/packages/client/src/pages/TracePage/TraceDetailPage/index.tsx @@ -31,12 +31,31 @@ import { } from '@/utils/common'; import { SpanData } from '@shared/types/trace'; import { getNestedValue } from '@shared/utils/objectUtils'; +import JsonView from '@uiw/react-json-view'; interface SpanTreeNode { span: SpanData; children?: SpanTreeNode[]; } +const GEN_AI_OPERATION_COLORS: Record = { + invoke_agent: 'hsl(217 91% 60% / 0.8)', + chat: 'hsl(142 71% 45% / 0.8)', + create_agent: 'hsl(271 91% 65% / 0.8)', + embeddings: 'hsl(25 95% 53% / 0.8)', + execute_tool: 'hsl(174 72% 45% / 0.8)', + generate_content: 'hsl(239 84% 67% / 0.8)', + retrieval: 'hsl(43 96% 56% / 0.8)', + text_completion: 'hsl(350 89% 60% / 0.8)', +}; + +const getFlameBarColor = (span: SpanData): string => { + const attrs = span.attributes || {}; + const op = (getNestedValue(attrs, 'gen_ai.operation.name') ?? + attrs['gen_ai.operation.name']) as string | undefined; + return (op && GEN_AI_OPERATION_COLORS[op]) ?? 'hsl(0 0% 60% / 0.6)'; +}; + const getStatusIcon = (statusCode: number) => { if (statusCode === 2) { return ; @@ -135,6 +154,19 @@ const TraceDetailPage = ({ traceId }: TraceDetailPageProps) => { return Number(latestEnd - earliestStart) / 1e9; }, [filteredSpans]); + // Time range for flame graph: min = earliest start, max = latest end + const flameGraphTimeRange = useMemo(() => { + if (!filteredSpans.length) return null; + const startTimes = filteredSpans.map((s) => + BigInt(s.startTimeUnixNano), + ); + const endTimes = filteredSpans.map((s) => BigInt(s.endTimeUnixNano)); + const min = startTimes.reduce((a, b) => (a < b ? a : b)); + const max = endTimes.reduce((a, b) => (a > b ? a : b)); + const range = max - min; + return { min, max, range: range > 0n ? range : 1n }; + }, [filteredSpans]); + // Display span (selected or root) const displaySpan = selectedSpan || rootSpan; @@ -217,6 +249,19 @@ const TraceDetailPage = ({ traceId }: TraceDetailPageProps) => { return undefined; }; + const getFlameBarStyle = (span: SpanData) => { + if (!flameGraphTimeRange) return { left: 0, width: 0 }; + const start = BigInt(span.startTimeUnixNano); + const end = BigInt(span.endTimeUnixNano); + const { min, range } = flameGraphTimeRange; + const leftPercent = Number(((start - min) * 10000n) / range) / 100; + const widthPercent = Number(((end - start) * 10000n) / range) / 100; + return { left: leftPercent, width: Math.max(widthPercent, 1) }; + }; + + // 火焰图条左边缘与行内容区对齐,不随层级缩进,便于时间轴对比 + const FLAME_BAR_LEFT_OFFSET_PX = 8; + const renderTreeNode = (node: SpanTreeNode, level = 0): React.ReactNode => { const duration = Number( @@ -225,11 +270,12 @@ const TraceDetailPage = ({ traceId }: TraceDetailPageProps) => { ) / 1e9; const isSelected = selectedSpanId === node.span.spanId; const hasChildren = node.children && node.children.length > 0; + const flameStyle = getFlameBarStyle(node.span); return (
{ {node.span.name} + {flameGraphTimeRange && ( + <> +
+ + {formatDurationWithUnit(duration)} + + + )} {formatDurationWithUnit(duration)} {getStatusIcon(node.span.status?.code || 0)} + {/* 火焰图条:绝对定位,左边缘固定不随层级缩进 */} + {flameGraphTimeRange && ( +
+
+
+ )}
{hasChildren && expandedNodes.has(node.span.spanId) && @@ -465,13 +540,9 @@ const TraceDetailPage = ({ traceId }: TraceDetailPageProps) => {
-
-                                                {JSON.stringify(
-                                                    extractInput(displaySpan),
-                                                    null,
-                                                    2,
-                                                )}
-                                            
+
+ +
@@ -498,13 +569,9 @@ const TraceDetailPage = ({ traceId }: TraceDetailPageProps) => {
-
-                                                {JSON.stringify(
-                                                    extractOutput(displaySpan),
-                                                    null,
-                                                    2,
-                                                )}
-                                            
+
+ +
@@ -535,13 +602,9 @@ const TraceDetailPage = ({ traceId }: TraceDetailPageProps) => { -
-                                        {JSON.stringify(
-                                            displaySpan.attributes,
-                                            null,
-                                            2,
-                                        )}
-                                    
+
+ +
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/1750000000000-FlattenSpanAttributes.ts b/packages/server/src/migrations/1750000000000-FlattenSpanAttributes.ts new file mode 100644 index 00000000..d3f200be --- /dev/null +++ b/packages/server/src/migrations/1750000000000-FlattenSpanAttributes.ts @@ -0,0 +1,214 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Attribute paths whose values are objects that should NOT be further flattened. + * + * These paths represent structured data (function inputs/outputs, tool call + * arguments/results) that must be kept as-is. + */ +const PRESERVE_OBJECT_PATHS = new Set([ + 'agentscope.function.input', + 'agentscope.function.output', + 'gen_ai.tool.call.arguments', + 'gen_ai.tool.call.result', +]); + +// --------------------------------------------------------------------------- +// Helper utilities +// --------------------------------------------------------------------------- + +/** + * Parse a value that may be a JSON string or already an object into a + * Record. + */ +function parseAttributes(value: unknown): Record { + if (typeof value === 'string') { + try { + return JSON.parse(value) as Record; + } catch { + return {}; + } + } + if (value && typeof value === 'object') { + return value as Record; + } + return {}; +} + +/** + * Detect whether the given attributes object uses a nested structure. + * + * Heuristic: if any top-level key does **not** contain a dot and its value is a + * plain object (not an array / Uint8Array / null), we treat the whole + * attributes object as nested. + */ +function isNestedStructure(attributes: Record): boolean { + if (!attributes || Object.keys(attributes).length === 0) { + return false; + } + + for (const [key, value] of Object.entries(attributes)) { + if ( + !key.includes('.') && + value !== null && + typeof value === 'object' && + !Array.isArray(value) + ) { + return true; + } + } + + return false; +} + +/** + * Flatten a nested attributes object into dot-separated keys **while + * preserving** certain paths whose values should remain as objects. + * + * Example: + * ``` + * { agentscope: { function: { input: { args: [], kwargs: { msg: "hi" } } } } } + * // becomes + * { "agentscope.function.input": { args: [], kwargs: { msg: "hi" } } } + * ``` + */ +function flattenAttributes( + attributes: Record, +): Record { + const result: Record = {}; + + function recurse(obj: Record, prefix: string): void { + for (const [key, value] of Object.entries(obj)) { + const currentPath = prefix ? `${prefix}.${key}` : key; + + // If we hit a preserved path, store the whole value and stop. + if (PRESERVE_OBJECT_PATHS.has(currentPath)) { + result[currentPath] = value; + continue; + } + + if ( + value !== null && + typeof value === 'object' && + !Array.isArray(value) + ) { + // Continue flattening nested objects. + recurse(value as Record, currentPath); + } else { + // Primitive / array – store directly. + result[currentPath] = value; + } + } + } + + recurse(attributes, ''); + return result; +} + +// --------------------------------------------------------------------------- +// Migration +// --------------------------------------------------------------------------- + +const BATCH_SIZE = 1000; + +/** + * Migration: Flatten nested span attributes to dot-separated keys. + * + * Existing spans stored with nested attribute objects (e.g. + * `{ gen_ai: { operation: { name: "chat" } } }`) are converted to flat keys + * (`{ "gen_ai.operation.name": "chat" }`). + * + * Certain attribute paths whose values are meaningful objects + * (agentscope.function.input/output, gen_ai.tool.call.arguments/result) are + * preserved as-is and not recursively flattened. + */ +export class FlattenSpanAttributes1750000000000 implements MigrationInterface { + name = 'FlattenSpanAttributes1750000000000'; + + public async up(queryRunner: QueryRunner): Promise { + console.log( + 'Starting migration: Flattening span attributes to dot-separated keys...', + ); + + // ======================================== + // Step 1: Check if table exists + // ======================================== + const tableName = 'span_table'; + + if (!(await queryRunner.hasTable(tableName))) { + console.log( + '⏭️ span_table does not exist. Skipping migration (first-time installation).', + ); + return; + } + + // ======================================== + // Step 2: Count total rows with non-empty attributes + // ======================================== + const countResult: { count: number }[] = await queryRunner.query( + `SELECT COUNT(*) as count FROM ${tableName} WHERE attributes IS NOT NULL AND attributes != '{}'`, + ); + const totalCount = countResult[0]?.count ?? 0; + + if (totalCount === 0) { + console.log('No spans with attributes found. Nothing to migrate.'); + return; + } + + console.log( + `Found ${totalCount} spans with non-empty attributes to inspect.`, + ); + + // ======================================== + // Step 3: Batch process + // ======================================== + let offset = 0; + let totalUpdated = 0; + + while (offset < totalCount) { + const rows: { id: string; attributes: unknown }[] = + await queryRunner.query( + `SELECT id, attributes FROM ${tableName} WHERE attributes IS NOT NULL AND attributes != '{}' LIMIT ${BATCH_SIZE} OFFSET ${offset}`, + ); + + if (rows.length === 0) break; + + for (const row of rows) { + const attributes = parseAttributes(row.attributes); + + if (!isNestedStructure(attributes)) { + continue; + } + + const flattened = flattenAttributes(attributes); + + await queryRunner.query( + `UPDATE ${tableName} SET attributes = ? WHERE id = ?`, + [JSON.stringify(flattened), row.id], + ); + + totalUpdated++; + } + + offset += BATCH_SIZE; + + const progress = Math.min(offset, totalCount); + console.log( + ` Progress: ${progress}/${totalCount} inspected, ${totalUpdated} updated so far.`, + ); + } + + console.log( + `✅ Migration completed. Flattened attributes of ${totalUpdated} spans.`, + ); + } + + public async down(): Promise { + console.log( + '⚠️ Rollback not supported for FlattenSpanAttributes: cannot uniquely reconstruct nested structure.', + ); + console.log( + ' If rollback is needed, restore from a database backup.', + ); + } +} diff --git a/packages/server/src/migrations/index.ts b/packages/server/src/migrations/index.ts index d355ab36..52cca046 100644 --- a/packages/server/src/migrations/index.ts +++ b/packages/server/src/migrations/index.ts @@ -8,6 +8,7 @@ import { MigrationInterface } from 'typeorm'; import { AddMessageReplyForeignKey1730000000000 } from './1730000000000-AddMessageReplyForeignKey'; import { MigrateSpanTable1740000000000 } from './1740000000000-MigrateSpanTable'; +import { FlattenSpanAttributes1750000000000 } from './1750000000000-FlattenSpanAttributes'; /** * 所有迁移列表(按时间戳顺序) @@ -15,5 +16,6 @@ import { MigrateSpanTable1740000000000 } from './1740000000000-MigrateSpanTable' export const migrations: (new () => MigrationInterface)[] = [ AddMessageReplyForeignKey1730000000000, MigrateSpanTable1740000000000, + FlattenSpanAttributes1750000000000, // 未来的迁移在这里添加 ]; 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];