Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
"opener": "^1.5.2",
"portfinder": "^1.0.36",
"protobufjs": "^7.5.3",
"react-json-view": "^1.21.3",
"socket.io": "^4.8.1",
"strip-ansi": "^7.1.0",
"typeorm": "^0.3.21",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -52,10 +53,21 @@ const SpanPanel = ({ span }: Props) => {
return null;
}

const attributes = span.attributes?.agentscope
?.function as unknown as Record<string, unknown>;
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<string, unknown> | undefined;
const agentscopeOutput = getNestedValue(
span.attributes,
'agentscope.function.output',
) as Record<string, unknown> | undefined;
const operation_name = getNestedValue(
span.attributes,
'gen_ai.operation.name',
) as string | undefined;
const renderCol = (title: string, value: string | ReactNode) => {
return (
<div className="flex col-span-1 pt-4 pb-4 pl-4">
Expand Down Expand Up @@ -97,16 +109,12 @@ const SpanPanel = ({ span }: Props) => {
<SpanSection
title={t('common.input')}
description={t('description.trace.input')}
content={
attributes?.input as unknown as Record<string, unknown>
}
content={agentscopeInput || {}}
/>
<SpanSection
title={t('common.output')}
description={t('description.trace.output')}
content={
attributes?.output as unknown as Record<string, unknown>
}
content={agentscopeOutput || {}}
/>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,31 +7,32 @@ 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[];
}

// Helper function to get display kind - extracted to avoid recalculation
const getDisplayKind = (attributes: Record<string, unknown>): string => {
const genAi = attributes.gen_ai as Record<string, unknown> | undefined;
const agentscope = attributes.agentscope as
| Record<string, unknown>
| undefined;

const operationName = (genAi?.operation as Record<string, unknown>)
?.name as string;
const agent_name = (genAi?.agent as Record<string, unknown>)?.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<string, unknown>)?.model as
const model_name = getNestedValue(attributes, 'gen_ai.request.model') as
| string
| undefined;
const tool_name = (genAi?.tool as Record<string, unknown>)?.name as
const tool_name = getNestedValue(attributes, 'gen_ai.tool.name') as
| string
| undefined;
const format_target = (agentscope?.format as Record<string, unknown>)
?.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);
Expand Down Expand Up @@ -75,11 +76,11 @@ export const TraceTree = ({ spans }: Props) => {
const spanTitleDataMap = useMemo(() => {
const map = new Map<string, SpanTitleData>();
spans.forEach((span) => {
const agentscope = span.attributes.agentscope as
| Record<string, unknown>
| undefined;
const funcName = (agentscope?.function as Record<string, unknown>)
?.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,
Expand Down
123 changes: 95 additions & 28 deletions packages/client/src/pages/TracePage/TraceDetailPage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,36 @@ import {
} from '@/utils/common';
import { SpanData } from '@shared/types/trace';
import { getNestedValue } from '@shared/utils/objectUtils';
import ReactJson from 'react-json-view';

interface SpanTreeNode {
span: SpanData;
children?: SpanTreeNode[];
}

const GEN_AI_OPERATION_COLORS: Record<
string,
string
> = {
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;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

getNestedValue 工具函数已经被更新,可以同时处理扁平(flat)和嵌套(nested)的对象结构。因此,这里的 ?? attrs['gen_ai.operation.name'] 回退逻辑是多余的,可以移除以使代码更简洁。

    const op = getNestedValue(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 <XCircleIcon className="size-4 text-destructive" />;
Expand Down Expand Up @@ -135,6 +159,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;

Expand Down Expand Up @@ -217,21 +254,36 @@ 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) };

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

为火焰图的条形设置 Math.max(widthPercent, 1) (1%) 的最小宽度,对于持续时间非常短的 span 可能会导致视觉上的严重失真。例如,一个仅占总时长 0.01% 的 span 会被渲染成 1% 的宽度,看起来比实际长了100倍。建议使用一个更小的最小百分比(如 0.1),或者考虑使用基于像素的最小宽度(如 1px),这样既能保证可见性,又不会过度扭曲其在时间轴上的比例。

        return { left: leftPercent, width: Math.max(widthPercent, 0.1) };

};

// 火焰图条左边缘与行内容区对齐,不随层级缩进,便于时间轴对比
const FLAME_BAR_LEFT_OFFSET_PX = 8;

const renderTreeNode = (node: SpanTreeNode, level = 0): React.ReactNode => {
const duration =
Number(
BigInt(node.span.endTimeUnixNano) -
BigInt(node.span.startTimeUnixNano),
BigInt(node.span.startTimeUnixNano),
) / 1e9;
const isSelected = selectedSpanId === node.span.spanId;
const hasChildren = node.children && node.children.length > 0;
const flameStyle = getFlameBarStyle(node.span);

return (
<div key={node.span.spanId} className="w-full">
<div
className={`flex items-center gap-2 p-2 rounded-md cursor-pointer hover:bg-muted overflow-hidden ${
isSelected ? 'bg-muted' : ''
}`}
className={`relative flex items-center gap-2 p-2 rounded-md cursor-pointer hover:bg-muted overflow-hidden ${isSelected ? 'bg-muted' : ''
}`}
style={{ paddingLeft: `${level * 16 + 8}px` }}
onClick={() => setSelectedSpanId(node.span.spanId)}
>
Expand Down Expand Up @@ -267,12 +319,39 @@ const TraceDetailPage = ({ traceId }: TraceDetailPageProps) => {
<span className="text-xs break-all max-w-[400px]">
{node.span.name}
</span>
{flameGraphTimeRange && (
<>
<br />
<span className="text-xs text-muted-foreground">
{formatDurationWithUnit(duration)}
</span>
</>
)}
</TooltipContent>
</Tooltip>
<span className="text-xs text-muted-foreground shrink-0">
{formatDurationWithUnit(duration)}
</span>
{getStatusIcon(node.span.status?.code || 0)}
{/* 火焰图条:绝对定位,左边缘固定不随层级缩进 */}
{flameGraphTimeRange && (
<div
className="absolute bottom-0 h-1 bg-muted overflow-hidden pointer-events-none"
style={{
left: `${FLAME_BAR_LEFT_OFFSET_PX}px`,
right: 8,
}}
>
<div
className="absolute top-0 bottom-0 rounded-sm transition-colors"
style={{
left: `${flameStyle.left}%`,
width: `${flameStyle.width}%`,
backgroundColor: getFlameBarColor(node.span),
}}
/>
</div>
)}
</div>
{hasChildren &&
expandedNodes.has(node.span.spanId) &&
Expand Down Expand Up @@ -410,9 +489,9 @@ const TraceDetailPage = ({ traceId }: TraceDetailPageProps) => {
BigInt(
displaySpan.endTimeUnixNano,
) -
BigInt(
displaySpan.startTimeUnixNano,
),
BigInt(
displaySpan.startTimeUnixNano,
),
) / 1e9,
)}
</div>
Expand Down Expand Up @@ -465,13 +544,9 @@ const TraceDetailPage = ({ traceId }: TraceDetailPageProps) => {
<CopyIcon className="size-3" />
</Button>
</div>
<pre className="bg-muted p-3 rounded-md overflow-auto max-h-[300px] text-xs">
{JSON.stringify(
extractInput(displaySpan),
null,
2,
)}
</pre>
<div className="bg-muted p-3 rounded-md overflow-auto max-h-[300px] text-xs">
<ReactJson src={extractInput(displaySpan) as object} name={false} displayDataTypes={false} />
</div>
</div>
<Separator />
<div>
Expand All @@ -498,13 +573,9 @@ const TraceDetailPage = ({ traceId }: TraceDetailPageProps) => {
<CopyIcon className="size-3" />
</Button>
</div>
<pre className="bg-muted p-3 rounded-md overflow-auto max-h-[300px] text-xs">
{JSON.stringify(
extractOutput(displaySpan),
null,
2,
)}
</pre>
<div className="bg-muted p-3 rounded-md overflow-auto max-h-[300px] text-xs">
<ReactJson src={extractInput(displaySpan) as object} name={false} displayDataTypes={false} />
</div>
</div>
</div>
</AccordionContent>
Expand Down Expand Up @@ -535,13 +606,9 @@ const TraceDetailPage = ({ traceId }: TraceDetailPageProps) => {
<CopyIcon className="size-3" />
</Button>
</div>
<pre className="bg-muted p-3 rounded-md overflow-auto text-xs">
{JSON.stringify(
displaySpan.attributes,
null,
2,
)}
</pre>
<div className="bg-muted p-3 rounded-md overflow-auto text-xs">
<ReactJson src={displaySpan.attributes as object} name={false} displayDataTypes={false} />
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
Expand Down
14 changes: 12 additions & 2 deletions packages/server/src/dao/Trace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,14 +190,24 @@ export class SpanDao {
attributes: Record<string, unknown>,
): 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<string, unknown>,
): 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
Expand Down
Loading