Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
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
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;
Comment on lines +193 to +196

Choose a reason for hiding this comment

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

medium

The logic for extracting input and output tokens has been improved to handle string values by attempting conversion to a number. This adds robustness to the data processing.

}
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
106 changes: 72 additions & 34 deletions packages/server/src/otel/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Comment on lines +127 to 130

Choose a reason for hiding this comment

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

high

The removal of this.unflattenAttributes directly addresses the PR's goal of preserving the original OTLP structure, which is a significant architectural change.

}

Expand Down Expand Up @@ -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;
Comment on lines +190 to +192

Choose a reason for hiding this comment

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

medium

The hasGenAi check is now more comprehensive, supporting both flat and nested attribute structures. This ensures better compatibility and accurate detection of gen_ai attributes.

if (hasGenAi) {
return { span_name: span.name || '', attributes: attributes };
}

Expand Down Expand Up @@ -312,14 +314,7 @@ export class SpanProcessor {

private static decodeAnyValue(value: unknown): unknown {
const valueObj = value as Record<string, unknown>;
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;
Expand All @@ -328,25 +323,53 @@ 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' &&
Object.keys(valueObj.bytes_value).length > 0
) {
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;
}
Expand Down Expand Up @@ -391,30 +414,45 @@ export class SpanProcessor {
};
}

private static unflattenAttributes(
flat: Record<string, unknown>,
): Record<string, unknown> {
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<string, unknown>,
): Record<string, unknown> {
const result: Record<string, unknown> = {};
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<string, unknown>;
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(obj)) {
out[k] = this.parseJsonValue(v);
}
return out;
}
return value;
}
Comment on lines +417 to +454

Choose a reason for hiding this comment

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

high

The introduction of parseJsonValue and its recursive application within loadJsonStrings is a robust solution for deeply parsing JSON strings embedded in attributes. This ensures that nested JSON structures are correctly deserialized without inadvertently modifying arrays or byte values.


private static decodeResource(resource: unknown): SpanResource {
const r = resource as Record<string, unknown>;
return {
Expand Down
12 changes: 9 additions & 3 deletions packages/shared/src/utils/objectUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
}
Comment on lines +10 to +16

Choose a reason for hiding this comment

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

high

The getNestedValue utility now first attempts direct key access for flat structures before falling back to nested access. This is a crucial enhancement for supporting both attribute formats as described in the pull request, ensuring backward compatibility and flexibility.


// If not found, try nested access (for nested structure)
const keys: string[] = pathString.split(separator);
return keys.reduce<unknown>((acc, key) => {
if (acc && typeof acc === 'object' && key in acc) {
return (acc as Record<string, unknown>)[key];
Expand Down
Loading