diff --git a/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.spec.tsx b/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.spec.tsx index 9f5a4ebe976370..0e6b030fb87dc1 100644 --- a/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.spec.tsx +++ b/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.spec.tsx @@ -129,4 +129,108 @@ describe('AttributesTreeValue', () => { expect(screen.getByText('null')).toBeInTheDocument(); }); + + it('renders JSON object values as structured data', () => { + const jsonContent = { + ...defaultProps.content, + value: '{"key": "value", "number": 42}', + }; + + render(); + + expect(screen.getByText('key')).toBeInTheDocument(); + expect(screen.getByText('value')).toBeInTheDocument(); + expect(screen.getByText('42')).toBeInTheDocument(); + }); + + it('renders JSON array values as structured data', () => { + const jsonContent = { + ...defaultProps.content, + value: '[1, 2, 3]', + }; + + render(); + + expect(screen.getByText('1')).toBeInTheDocument(); + expect(screen.getByText('2')).toBeInTheDocument(); + expect(screen.getByText('3')).toBeInTheDocument(); + }); + + it('renders invalid JSON containing braces as plain text', () => { + const invalidJsonContent = { + ...defaultProps.content, + value: 'not {json', + }; + + render(); + + expect(screen.getByText('not {json')).toBeInTheDocument(); + }); + + it('renders plain strings without braces as plain text', () => { + const plainContent = { + ...defaultProps.content, + value: 'hello world', + }; + + render(); + + expect(screen.getByText('hello world')).toBeInTheDocument(); + }); + + it('does not render JSON as structured data when disableRichValue is true', () => { + const jsonContent = { + ...defaultProps.content, + value: '{"key": "value"}', + }; + + render( + + ); + + expect(screen.getByText('{"key": "value"}')).toBeInTheDocument(); + expect(screen.queryByRole('list')).not.toBeInTheDocument(); + }); + + it('renders simple JSON with compact class', () => { + const jsonContent = { + ...defaultProps.content, + value: '{"boop": "bop"}', + }; + + render(); + + const pre = screen.getByText('bop').closest('pre'); + expect(pre).toHaveClass('compact'); + }); + + it('renders short JSON with nested objects without compact class', () => { + const jsonContent = { + ...defaultProps.content, + value: '{"a":{"b":{"c":{"d":1}}}}', + }; + + render(); + + const pre = screen.getByText('a').closest('pre'); + expect(pre).not.toHaveClass('compact'); + }); + + it('renders long JSON without compact class', () => { + const jsonContent = { + ...defaultProps.content, + value: '{"k": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}', + }; + + render(); + + const pre = screen + .getByText('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa') + .closest('pre'); + expect(pre).not.toHaveClass('compact'); + }); }); diff --git a/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.tsx b/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.tsx index f35e567e1980ae..aeb856dcbf0ad0 100644 --- a/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.tsx +++ b/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.tsx @@ -3,6 +3,7 @@ import styled from '@emotion/styled'; import {openNavigateToExternalLinkModal} from 'sentry/actionCreators/modal'; import {ExternalLink} from 'sentry/components/links/externalLink'; +import {StructuredEventData} from 'sentry/components/structuredEventData'; import {type RenderFunctionBaggage} from 'sentry/utils/discover/fieldRenderers'; import {isUrl} from 'sentry/utils/string/isUrl'; import {AnnotatedAttributeTooltip} from 'sentry/views/explore/components/annotatedAttributeTooltip'; @@ -15,6 +16,26 @@ import type { AttributesTreeRowConfig, } from './attributesTree'; +function tryParseJson(value: unknown) { + if (typeof value !== 'string') { + return undefined; + } + + try { + return JSON.parse(value) as unknown; + } catch { + return undefined; + } +} + +function hasNestedObject(value: unknown) { + if (typeof value !== 'object' || value === null) { + return false; + } + const values = Array.isArray(value) ? value : Object.values(value); + return values.some(v => typeof v === 'object' && v !== null); +} + export function AttributesTreeValue({ config, content, @@ -32,11 +53,12 @@ export function AttributesTreeValue // Check if we have a custom renderer for this attribute const attributeKey = originalAttribute.original_attribute_key; const renderer = renderers[attributeKey]; + const value = String(content.value); - const defaultValue = {String(content.value)}; + const defaultValue = {value}; if (config?.disableRichValue) { - return String(content.value); + return value; } if (renderer) { @@ -57,12 +79,27 @@ export function AttributesTreeValue ); } } - return isUrl(String(content.value)) ? ( + + const parsedJson = tryParseJson(content.value); + if (typeof parsedJson === 'object' && parsedJson !== null) { + return ( + + ); + } + + return isUrl(value) ? ( { e.preventDefault(); - openNavigateToExternalLinkModal({linkText: String(content.value)}); + openNavigateToExternalLinkModal({linkText: value}); }} > {defaultValue} @@ -86,3 +123,29 @@ const AttributeLinkText = styled('span')` white-space: normal; } `; + +const AttributeStructuredData = styled(StructuredEventData)` + margin: 0; + padding: 0; + background: transparent; + white-space: pre-wrap; + word-break: break-word; + + &.compact { + display: inline; + + span[data-base-with-toggle='true'] { + display: inline; + padding-left: 0; + } + + button { + display: none; + } + + div { + display: inline; + padding-left: 0; + } + } +`; diff --git a/static/app/views/performance/newTraceDetails/traceDrawer/details/highlightedAttributes.tsx b/static/app/views/performance/newTraceDetails/traceDrawer/details/highlightedAttributes.tsx index aad22481b0ec7e..65408c076632ab 100644 --- a/static/app/views/performance/newTraceDetails/traceDrawer/details/highlightedAttributes.tsx +++ b/static/app/views/performance/newTraceDetails/traceDrawer/details/highlightedAttributes.tsx @@ -20,7 +20,7 @@ import { } from 'sentry/views/insights/pages/agents/utils/query'; import {Referrer} from 'sentry/views/insights/pages/agents/utils/referrers'; import {SpanFields} from 'sentry/views/insights/types'; -import {tryParseJson} from 'sentry/views/performance/newTraceDetails/traceDrawer/details/utils'; +import {tryParseJsonRecursive} from 'sentry/views/performance/newTraceDetails/traceDrawer/details/utils'; type HighlightedAttribute = { name: string; @@ -41,7 +41,7 @@ function getAIToolDefinitions( ): any[] | null { const toolDefinitions = attributes['gen_ai.tool.definitions']; if (toolDefinitions) { - const parsed = tryParseJson(toolDefinitions.toString()); + const parsed = tryParseJsonRecursive(toolDefinitions.toString()); if (Array.isArray(parsed)) { return parsed; } @@ -49,7 +49,7 @@ function getAIToolDefinitions( const availableTools = attributes['gen_ai.request.available_tools']; if (availableTools) { - const parsed = tryParseJson(availableTools.toString()); + const parsed = tryParseJsonRecursive(availableTools.toString()); if (Array.isArray(parsed)) { return parsed; } diff --git a/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiInput.tsx b/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiInput.tsx index f82f60316b4904..f2519aac4ca5aa 100644 --- a/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiInput.tsx +++ b/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiInput.tsx @@ -22,7 +22,7 @@ import {AIContentRenderer} from 'sentry/views/performance/newTraceDetails/traceD import {TraceDrawerComponents} from 'sentry/views/performance/newTraceDetails/traceDrawer/details/styles'; import { parseJsonWithFix, - tryParseJson, + tryParseJsonRecursive, } from 'sentry/views/performance/newTraceDetails/traceDrawer/details/utils'; import type {EapSpanNode} from 'sentry/views/performance/newTraceDetails/traceModels/traceTreeNode/eapSpanNode'; import type {SpanNode} from 'sentry/views/performance/newTraceDetails/traceModels/traceTreeNode/spanNode'; @@ -106,7 +106,7 @@ function parseAIMessages(messages: string): AIMessage[] | string { if (!message.role || !message.content) { return null; } - const parsedContent = tryParseJson(message.content); + const parsedContent = tryParseJsonRecursive(message.content); return { role: message.role, content: diff --git a/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/attributes.tsx b/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/attributes.tsx index 9208b624d5fceb..195038a93bba35 100644 --- a/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/attributes.tsx +++ b/static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/attributes.tsx @@ -35,7 +35,7 @@ import { findSpanAttributeValue, getTraceAttributesTreeActions, sortAttributes, - tryParseJson, + tryParseJsonRecursive, } from 'sentry/views/performance/newTraceDetails/traceDrawer/details/utils'; import type {EapSpanNode} from 'sentry/views/performance/newTraceDetails/traceModels/traceTreeNode/eapSpanNode'; import type {UptimeCheckNode} from 'sentry/views/performance/newTraceDetails/traceModels/traceTreeNode/uptimeCheckNode'; @@ -49,7 +49,7 @@ const HIDDEN_ATTRIBUTES = ['is_segment', 'project_id', 'received']; const TRUNCATED_TEXT_ATTRIBUTES = ['gen_ai.response.text', 'gen_ai.embeddings.input']; const jsonRenderer = (props: CustomRenderersProps) => { - const value = tryParseJson(props.item.value); + const value = tryParseJsonRecursive(props.item.value); return ; }; diff --git a/static/app/views/performance/newTraceDetails/traceDrawer/details/styles.tsx b/static/app/views/performance/newTraceDetails/traceDrawer/details/styles.tsx index 986749ef90e48e..cb419c7b95cca2 100644 --- a/static/app/views/performance/newTraceDetails/traceDrawer/details/styles.tsx +++ b/static/app/views/performance/newTraceDetails/traceDrawer/details/styles.tsx @@ -59,7 +59,7 @@ import {getIsAiNode} from 'sentry/views/insights/pages/agents/utils/aiTraceNodes import {getIsMCPNode} from 'sentry/views/insights/pages/mcp/utils/mcpTraceNodes'; import {traceAnalytics} from 'sentry/views/performance/newTraceDetails/traceAnalytics'; import {useDrawerContainerRef} from 'sentry/views/performance/newTraceDetails/traceDrawer/details/drawerContainerRefContext'; -import {tryParseJson} from 'sentry/views/performance/newTraceDetails/traceDrawer/details/utils'; +import {tryParseJsonRecursive} from 'sentry/views/performance/newTraceDetails/traceDrawer/details/utils'; import { makeTraceContinuousProfilingLink, makeTransactionProfilingLink, @@ -1319,7 +1319,7 @@ function MultilineJSON({ const {hoverProps, isHovered} = useHover({}); const theme = useTheme(); - const json = useMemo(() => tryParseJson(value), [value]); + const json = useMemo(() => tryParseJsonRecursive(value), [value]); // Ensure root ('$') is always expanded, while children follow maxDefaultDepth rules const computedExpandedPaths = useMemo(() => { diff --git a/static/app/views/performance/newTraceDetails/traceDrawer/details/utils.tsx b/static/app/views/performance/newTraceDetails/traceDrawer/details/utils.tsx index 76b1b2910e4248..1e63f23019d792 100644 --- a/static/app/views/performance/newTraceDetails/traceDrawer/details/utils.tsx +++ b/static/app/views/performance/newTraceDetails/traceDrawer/details/utils.tsx @@ -247,7 +247,7 @@ export function getTraceAttributesTreeActions( /** * Attempts to parse a JSON string, recursively unwrapping double-stringified arrays. */ -export function tryParseJson(value: unknown): unknown { +export function tryParseJsonRecursive(value: unknown): unknown { if (typeof value !== 'string') { return value; } @@ -256,7 +256,7 @@ export function tryParseJson(value: unknown): unknown { if (!Array.isArray(parsedValue)) { return parsedValue; } - return parsedValue.map((item: unknown): unknown => tryParseJson(item)); + return parsedValue.map((item: unknown): unknown => tryParseJsonRecursive(item)); } catch { return value; }