Skip to content

Commit 8fed9a2

Browse files
feat(SpanDetail): add rich-media rendering for GenAI attributes
Implements specialized rendering for OTel GenAI semantic convention attributes inside SpanDetail, addressing #3808. Changes: - Add src/utils/genai/detect.ts with classifySpan, isGenAITrace, and RICH_MEDIA_ATTRIBUTE_KEYS for attribute key routing - Add GenAIAttributeRenderer component that renders gen_ai.input.messages and gen_ai.output.messages as preformatted text, and structured JSON attributes (gen_ai.tool.call.arguments, gen_ai.tool.call.result, etc.) via react-json-view-lite (already bundled at v2.5.0) - Modify SpanDetail/index.tsx to split span.attributes into richMediaAttrs and standardAttrs; richMediaAttrs route through GenAIAttributeRenderer, standardAttrs continue to AccordionAttributes unchanged - Rendering is lazy: GenAIAttributeRenderer returns null when the attributes accordion is collapsed, avoiding parse cost for large gen_ai.input.messages payloads at trace load time - Zero regression: richMediaAttrs is empty for all non-GenAI spans; AccordionAttributes receives the full attribute list in that case Part of jaegertracing/jaeger#8401
1 parent f0253c3 commit 8fed9a2

5 files changed

Lines changed: 400 additions & 1 deletion

File tree

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
// Copyright (c) 2026 The Jaeger Authors.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import React from 'react';
5+
import { render, screen } from '@testing-library/react';
6+
import { describe, it, expect } from 'vitest';
7+
8+
import GenAIAttributeRenderer from './GenAIAttributeRenderer';
9+
import type { IAttribute } from '../../../../types/otel';
10+
11+
function makeAttr(key: string, value: string): IAttribute {
12+
return { key, value };
13+
}
14+
15+
describe('GenAIAttributeRenderer', () => {
16+
it('returns null for a non-rich-media gen_ai attribute', () => {
17+
const { container } = render(
18+
<GenAIAttributeRenderer attribute={makeAttr('gen_ai.operation.name', 'chat')} isOpen />
19+
);
20+
expect(container.firstChild).toBeNull();
21+
});
22+
23+
it('returns null for a standard non-genai attribute', () => {
24+
const { container } = render(
25+
<GenAIAttributeRenderer attribute={makeAttr('http.method', 'GET')} isOpen />
26+
);
27+
expect(container.firstChild).toBeNull();
28+
});
29+
30+
it('renders nothing when isOpen is false (lazy load)', () => {
31+
const { container } = render(
32+
<GenAIAttributeRenderer
33+
attribute={makeAttr('gen_ai.tool.call.arguments', '{"query":"test"}')}
34+
isOpen={false}
35+
/>
36+
);
37+
expect(container.firstChild).toBeNull();
38+
});
39+
40+
it('renders JSON view for gen_ai.tool.call.arguments when open', () => {
41+
render(
42+
<GenAIAttributeRenderer
43+
attribute={makeAttr('gen_ai.tool.call.arguments', '{"query":"Paris"}')}
44+
isOpen
45+
/>
46+
);
47+
expect(screen.getByText('gen_ai.tool.call.arguments')).toBeInTheDocument();
48+
// react-json-view-lite renders the key from the parsed object
49+
expect(screen.getByText('query')).toBeInTheDocument();
50+
});
51+
52+
it('renders JSON view for gen_ai.tool.call.result when open', () => {
53+
render(
54+
<GenAIAttributeRenderer
55+
attribute={makeAttr('gen_ai.tool.call.result', '{"answer":"Paris"}')}
56+
isOpen
57+
/>
58+
);
59+
expect(screen.getByText('gen_ai.tool.call.result')).toBeInTheDocument();
60+
expect(screen.getByText('answer')).toBeInTheDocument();
61+
});
62+
63+
it('renders pre block for gen_ai.input.messages when open', () => {
64+
const messages = JSON.stringify([{ role: 'user', content: 'What is the capital of France?' }]);
65+
render(
66+
<GenAIAttributeRenderer attribute={makeAttr('gen_ai.input.messages', messages)} isOpen />
67+
);
68+
expect(screen.getByText('gen_ai.input.messages')).toBeInTheDocument();
69+
expect(screen.getByText(/What is the capital of France/)).toBeInTheDocument();
70+
});
71+
72+
it('renders pre block for gen_ai.output.messages when open', () => {
73+
const messages = JSON.stringify([{ role: 'assistant', content: 'The capital is **Paris**.' }]);
74+
render(
75+
<GenAIAttributeRenderer attribute={makeAttr('gen_ai.output.messages', messages)} isOpen />
76+
);
77+
expect(screen.getByText(/The capital is/)).toBeInTheDocument();
78+
});
79+
80+
it('falls back to raw string when JSON parse fails for a json-type key', () => {
81+
render(
82+
<GenAIAttributeRenderer
83+
attribute={makeAttr('gen_ai.tool.call.arguments', 'not-valid-json')}
84+
isOpen
85+
/>
86+
);
87+
expect(screen.getByText('not-valid-json')).toBeInTheDocument();
88+
});
89+
90+
it('renders JSON view for gen_ai.retrieval.documents when open', () => {
91+
const docs = JSON.stringify([{ title: 'Paris', snippet: 'Capital of France' }]);
92+
render(
93+
<GenAIAttributeRenderer attribute={makeAttr('gen_ai.retrieval.documents', docs)} isOpen />
94+
);
95+
expect(screen.getByText('gen_ai.retrieval.documents')).toBeInTheDocument();
96+
expect(screen.getByText('title')).toBeInTheDocument();
97+
});
98+
});
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// Copyright (c) 2026 The Jaeger Authors.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import React, { useState } from 'react';
5+
import { JsonView, allExpanded, defaultStyles } from 'react-json-view-lite';
6+
import 'react-json-view-lite/dist/index.css';
7+
8+
import { RICH_MEDIA_ATTRIBUTE_KEYS } from '../../../../utils/genai/detect';
9+
import type { IAttribute } from '../../../../types/otel';
10+
11+
type Props = {
12+
attribute: IAttribute;
13+
isOpen: boolean;
14+
};
15+
16+
function tryParseJson(value: unknown): unknown | null {
17+
if (typeof value === 'string') {
18+
try {
19+
return JSON.parse(value);
20+
} catch {
21+
return null;
22+
}
23+
}
24+
if (typeof value === 'object' && value !== null) {
25+
return value;
26+
}
27+
return null;
28+
}
29+
30+
/**
31+
* Extracts human-readable text from a gen_ai messages attribute value.
32+
* The OTel GenAI spec stores messages as a JSON array of objects with
33+
* a 'content' field. Falls back to the raw string if parsing fails.
34+
*/
35+
function extractMessageText(value: unknown): string {
36+
const parsed = tryParseJson(value);
37+
if (Array.isArray(parsed)) {
38+
return parsed
39+
.map(msg => {
40+
if (typeof msg === 'object' && msg !== null && 'content' in msg) {
41+
const role = 'role' in msg ? `[${msg.role}]` : '';
42+
return `${role}\n${String((msg as Record<string, unknown>).content)}`;
43+
}
44+
return String(msg);
45+
})
46+
.join('\n\n');
47+
}
48+
return typeof value === 'string' ? value : JSON.stringify(value, null, 2);
49+
}
50+
51+
type JsonRendererProps = { value: unknown };
52+
53+
function JsonRenderer({ value }: JsonRendererProps) {
54+
const parsed = tryParseJson(value);
55+
if (parsed !== null) {
56+
return (
57+
<div className="GenAIAttributeRenderer--json">
58+
<JsonView data={parsed} shouldExpandNode={allExpanded} style={defaultStyles} />
59+
</div>
60+
);
61+
}
62+
// Fallback: raw string if JSON parse fails
63+
return <pre className="GenAIAttributeRenderer--pre">{String(value)}</pre>;
64+
}
65+
66+
type MarkdownRendererProps = { value: unknown };
67+
68+
function MarkdownRenderer({ value }: MarkdownRendererProps) {
69+
const text = extractMessageText(value);
70+
return <pre className="GenAIAttributeRenderer--pre">{text}</pre>;
71+
}
72+
73+
/**
74+
* Renders rich content for specific OTel GenAI attributes when the
75+
* containing accordion section is open. Returns null for any attribute
76+
* key not in RICH_MEDIA_ATTRIBUTE_KEYS, ensuring zero cost for
77+
* non-GenAI spans.
78+
*/
79+
export default function GenAIAttributeRenderer({ attribute, isOpen }: Props): React.ReactElement | null {
80+
const renderKind = RICH_MEDIA_ATTRIBUTE_KEYS[attribute.key];
81+
if (renderKind === undefined) return null;
82+
83+
// Lazy: do not parse or render when the section is collapsed
84+
if (!isOpen) return null;
85+
86+
return (
87+
<div className="GenAIAttributeRenderer">
88+
<span className="GenAIAttributeRenderer--key">{attribute.key}</span>
89+
{renderKind === 'json' ? (
90+
<JsonRenderer value={attribute.value} />
91+
) : (
92+
<MarkdownRenderer value={attribute.value} />
93+
)}
94+
</div>
95+
);
96+
}

packages/jaeger-ui/src/components/TracePage/TraceTimelineViewer/SpanDetail/index.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,15 @@ import AccordionEvents from './AccordionEvents';
1010
import AccordionLinks from './AccordionLinks';
1111
import AccordionText from './AccordionText';
1212
import DetailState from './DetailState';
13+
import GenAIAttributeRenderer from './GenAIAttributeRenderer';
1314
import { formatDuration } from '../utils';
1415
import CopyIcon from '../../../common/CopyIcon';
1516
import LabeledList from '../../../common/LabeledList';
1617

1718
import { TNil } from '../../../../types';
1819
import { Hyperlink } from '../../../../types/hyperlink';
1920
import { IOtelSpan, IAttribute, IEvent } from '../../../../types/otel';
21+
import { RICH_MEDIA_ATTRIBUTE_KEYS } from '../../../../utils/genai/detect';
2022

2123
import './index.css';
2224

@@ -63,6 +65,11 @@ export default function SpanDetail(props: SpanDetailProps) {
6365
// Get links for display in AccordionLinks
6466
const links = span.links || [];
6567

68+
// Split attributes: rich-media GenAI attributes rendered by GenAIAttributeRenderer;
69+
// all others passed to AccordionAttributes unchanged.
70+
const richMediaAttrs = span.attributes.filter(a => a.key in RICH_MEDIA_ATTRIBUTE_KEYS);
71+
const standardAttrs = span.attributes.filter(a => !(a.key in RICH_MEDIA_ATTRIBUTE_KEYS));
72+
6673
// Display labels based on terminology flag
6774
const attributesLabel = useOtelTerms ? 'Attributes' : 'Tags';
6875
const resourceLabel = useOtelTerms ? 'Resource' : 'Process';
@@ -100,12 +107,19 @@ export default function SpanDetail(props: SpanDetailProps) {
100107
<div>
101108
<div>
102109
<AccordionAttributes
103-
data={span.attributes}
110+
data={standardAttrs}
104111
label={attributesLabel}
105112
linksGetter={linksGetter}
106113
isOpen={isAttributesOpen}
107114
onToggle={() => attributesToggle(span.spanID)}
108115
/>
116+
{richMediaAttrs.length > 0 && (
117+
<div className="SpanDetail--genAISection">
118+
{richMediaAttrs.map(attr => (
119+
<GenAIAttributeRenderer key={attr.key} attribute={attr} isOpen={isAttributesOpen} />
120+
))}
121+
</div>
122+
)}
109123
{span.resource.attributes && span.resource.attributes.length > 0 && (
110124
<AccordionAttributes
111125
className="ub-mb1"
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
// Copyright (c) 2026 The Jaeger Authors.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { describe, it, expect } from 'vitest';
5+
import { classifySpan, hasGenAIAttributes, isGenAITrace, RICH_MEDIA_ATTRIBUTE_KEYS } from './detect';
6+
import type { IAttribute, IOtelSpan, IOtelTrace } from '../../types/otel';
7+
8+
function makeSpan(attrs: Record<string, string>): IOtelSpan {
9+
const attributes: IAttribute[] = Object.entries(attrs).map(([key, value]) => ({ key, value }));
10+
return { attributes } as unknown as IOtelSpan;
11+
}
12+
13+
function makeTrace(spans: IOtelSpan[]): IOtelTrace {
14+
return { spans } as unknown as IOtelTrace;
15+
}
16+
17+
describe('hasGenAIAttributes', () => {
18+
it('returns true when any attribute key starts with gen_ai.', () => {
19+
expect(hasGenAIAttributes([{ key: 'gen_ai.operation.name', value: 'chat' }])).toBe(true);
20+
});
21+
22+
it('returns false when no attribute key starts with gen_ai.', () => {
23+
expect(hasGenAIAttributes([{ key: 'http.method', value: 'GET' }])).toBe(false);
24+
});
25+
26+
it('returns false for empty attributes array', () => {
27+
expect(hasGenAIAttributes([])).toBe(false);
28+
});
29+
});
30+
31+
describe('classifySpan', () => {
32+
it('returns STANDARD for span with no gen_ai.* attributes', () => {
33+
expect(classifySpan(makeSpan({ 'http.method': 'GET' }))).toBe('STANDARD');
34+
});
35+
36+
it('returns LLM_CALL for gen_ai.operation.name = chat', () => {
37+
expect(classifySpan(makeSpan({ 'gen_ai.operation.name': 'chat' }))).toBe('LLM_CALL');
38+
});
39+
40+
it('returns LLM_CALL for gen_ai.operation.name = text_completion', () => {
41+
expect(classifySpan(makeSpan({ 'gen_ai.operation.name': 'text_completion' }))).toBe('LLM_CALL');
42+
});
43+
44+
it('returns LLM_CALL for gen_ai.operation.name = generate_content', () => {
45+
expect(classifySpan(makeSpan({ 'gen_ai.operation.name': 'generate_content' }))).toBe('LLM_CALL');
46+
});
47+
48+
it('returns LLM_CALL for gen_ai.operation.name = embeddings', () => {
49+
expect(classifySpan(makeSpan({ 'gen_ai.operation.name': 'embeddings' }))).toBe('LLM_CALL');
50+
});
51+
52+
it('returns TOOL_CALL for gen_ai.operation.name = execute_tool', () => {
53+
expect(classifySpan(makeSpan({ 'gen_ai.operation.name': 'execute_tool' }))).toBe('TOOL_CALL');
54+
});
55+
56+
it('returns AGENT for gen_ai.operation.name = invoke_agent', () => {
57+
expect(classifySpan(makeSpan({ 'gen_ai.operation.name': 'invoke_agent' }))).toBe('AGENT');
58+
});
59+
60+
it('returns AGENT for gen_ai.operation.name = create_agent', () => {
61+
expect(classifySpan(makeSpan({ 'gen_ai.operation.name': 'create_agent' }))).toBe('AGENT');
62+
});
63+
64+
it('returns AGENT for gen_ai.operation.name = invoke_workflow', () => {
65+
expect(classifySpan(makeSpan({ 'gen_ai.operation.name': 'invoke_workflow' }))).toBe('AGENT');
66+
});
67+
68+
it('returns RETRIEVAL for gen_ai.operation.name = retrieval', () => {
69+
expect(classifySpan(makeSpan({ 'gen_ai.operation.name': 'retrieval' }))).toBe('RETRIEVAL');
70+
});
71+
72+
it('returns UNKNOWN_GENAI for span with gen_ai.* attrs but no operation.name', () => {
73+
expect(classifySpan(makeSpan({ 'gen_ai.provider.name': 'openai' }))).toBe('UNKNOWN_GENAI');
74+
});
75+
76+
it('returns UNKNOWN_GENAI for unrecognized gen_ai.operation.name value', () => {
77+
expect(classifySpan(makeSpan({ 'gen_ai.operation.name': 'future_unknown_op' }))).toBe('UNKNOWN_GENAI');
78+
});
79+
});
80+
81+
describe('isGenAITrace', () => {
82+
it('returns true when at least one span has gen_ai.* attributes', () => {
83+
const trace = makeTrace([
84+
makeSpan({ 'http.method': 'GET' }),
85+
makeSpan({ 'gen_ai.operation.name': 'chat' }),
86+
]);
87+
expect(isGenAITrace(trace)).toBe(true);
88+
});
89+
90+
it('returns false when no span has gen_ai.* attributes', () => {
91+
const trace = makeTrace([
92+
makeSpan({ 'http.method': 'GET' }),
93+
makeSpan({ 'db.system': 'postgresql' }),
94+
]);
95+
expect(isGenAITrace(trace)).toBe(false);
96+
});
97+
98+
it('returns false for empty trace', () => {
99+
expect(isGenAITrace(makeTrace([]))).toBe(false);
100+
});
101+
});
102+
103+
describe('RICH_MEDIA_ATTRIBUTE_KEYS', () => {
104+
it('classifies gen_ai.input.messages as markdown', () => {
105+
expect(RICH_MEDIA_ATTRIBUTE_KEYS['gen_ai.input.messages']).toBe('markdown');
106+
});
107+
108+
it('classifies gen_ai.output.messages as markdown', () => {
109+
expect(RICH_MEDIA_ATTRIBUTE_KEYS['gen_ai.output.messages']).toBe('markdown');
110+
});
111+
112+
it('classifies gen_ai.tool.call.arguments as json', () => {
113+
expect(RICH_MEDIA_ATTRIBUTE_KEYS['gen_ai.tool.call.arguments']).toBe('json');
114+
});
115+
116+
it('classifies gen_ai.tool.call.result as json', () => {
117+
expect(RICH_MEDIA_ATTRIBUTE_KEYS['gen_ai.tool.call.result']).toBe('json');
118+
});
119+
120+
it('does not classify gen_ai.operation.name as a rich-media key', () => {
121+
expect(RICH_MEDIA_ATTRIBUTE_KEYS['gen_ai.operation.name']).toBeUndefined();
122+
});
123+
});

0 commit comments

Comments
 (0)