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;
}