From 8db213f2346c7fa927cc1d1ba838cbd09229ea92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Wed, 25 Mar 2026 11:41:04 -0400 Subject: [PATCH 1/4] feat(logs): Add JSON pretty-printing for log attributes --- .../attributesTreeValue.spec.tsx | 92 +++++++++++++++++++ .../attributesTreeValue.tsx | 49 +++++++++- 2 files changed, 137 insertions(+), 4 deletions(-) diff --git a/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.spec.tsx b/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.spec.tsx index 9f5a4ebe976370..8ff0f64886432f 100644 --- a/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.spec.tsx +++ b/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.spec.tsx @@ -129,4 +129,96 @@ 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 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..18f840baaefe09 100644 --- a/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.tsx +++ b/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.tsx @@ -3,11 +3,13 @@ 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'; import {getAttributeItem} from 'sentry/views/explore/components/traceItemAttributes/utils'; import {TraceItemMetaInfo} from 'sentry/views/explore/utils'; +import {tryParseJson} from 'sentry/views/performance/newTraceDetails/traceDrawer/details/utils'; import type { AttributesFieldRender, @@ -32,11 +34,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 +60,24 @@ export function AttributesTreeValue ); } } - return isUrl(String(content.value)) ? ( + const parsedJson = tryParseJson(content.value); + if (typeof parsedJson === 'object') { + return ( + + ); + } + + return isUrl(value) ? ( { e.preventDefault(); - openNavigateToExternalLinkModal({linkText: String(content.value)}); + openNavigateToExternalLinkModal({linkText: value}); }} > {defaultValue} @@ -86,3 +101,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; + } + } +`; From b47492d62b1d1de2c13e6c8c3e03047499971c5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Fri, 27 Mar 2026 10:41:51 -0400 Subject: [PATCH 2/4] fix: don't render null --- .../components/traceItemAttributes/attributesTreeValue.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.tsx b/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.tsx index 18f840baaefe09..a78af888e0e916 100644 --- a/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.tsx +++ b/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.tsx @@ -61,7 +61,7 @@ export function AttributesTreeValue } } const parsedJson = tryParseJson(content.value); - if (typeof parsedJson === 'object') { + if (typeof parsedJson === 'object' && parsedJson !== null) { return ( Date: Fri, 27 Mar 2026 12:46:51 -0400 Subject: [PATCH 3/4] fix: don't recursively parse JSON data --- .../traceItemAttributes/attributesTreeValue.tsx | 14 +++++++++++++- .../traceDrawer/details/highlightedAttributes.tsx | 6 +++--- .../details/span/eapSections/aiInput.tsx | 4 ++-- .../details/span/eapSections/attributes.tsx | 4 ++-- .../newTraceDetails/traceDrawer/details/styles.tsx | 4 ++-- .../newTraceDetails/traceDrawer/details/utils.tsx | 4 ++-- 6 files changed, 24 insertions(+), 12 deletions(-) diff --git a/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.tsx b/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.tsx index a78af888e0e916..cbd9a71f1620c6 100644 --- a/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.tsx +++ b/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.tsx @@ -9,7 +9,6 @@ import {isUrl} from 'sentry/utils/string/isUrl'; import {AnnotatedAttributeTooltip} from 'sentry/views/explore/components/annotatedAttributeTooltip'; import {getAttributeItem} from 'sentry/views/explore/components/traceItemAttributes/utils'; import {TraceItemMetaInfo} from 'sentry/views/explore/utils'; -import {tryParseJson} from 'sentry/views/performance/newTraceDetails/traceDrawer/details/utils'; import type { AttributesFieldRender, @@ -17,6 +16,18 @@ 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; + } +} + export function AttributesTreeValue({ config, content, @@ -60,6 +71,7 @@ export function AttributesTreeValue ); } } + const parsedJson = tryParseJson(content.value); if (typeof parsedJson === 'object' && parsedJson !== null) { return ( 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; } From af153411bd0c0062464fe5ff986826e1733ab246 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Fri, 27 Mar 2026 13:20:07 -0400 Subject: [PATCH 4/4] fix: add nested objects check for compact class --- .../traceItemAttributes/attributesTreeValue.spec.tsx | 12 ++++++++++++ .../traceItemAttributes/attributesTreeValue.tsx | 12 +++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.spec.tsx b/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.spec.tsx index 8ff0f64886432f..0e6b030fb87dc1 100644 --- a/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.spec.tsx +++ b/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.spec.tsx @@ -208,6 +208,18 @@ describe('AttributesTreeValue', () => { 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, diff --git a/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.tsx b/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.tsx index cbd9a71f1620c6..aeb856dcbf0ad0 100644 --- a/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.tsx +++ b/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.tsx @@ -28,6 +28,14 @@ function tryParseJson(value: unknown) { } } +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, @@ -79,7 +87,9 @@ export function AttributesTreeValue data={parsedJson} maxDefaultDepth={2} withAnnotatedText={false} - className={value.length <= 48 ? 'compact' : undefined} + className={ + value.length <= 48 && !hasNestedObject(parsedJson) ? 'compact' : undefined + } /> ); }