Skip to content

Commit 6b00c83

Browse files
feat(logs): Add JSON pretty-printing for log attributes (#111077)
Add pretty-printing for JSON attribute values in the logs attributes tree. String attribute values that contain valid JSON (`{` or `[` that pass `JSON.parse`) are now rendered using `StructuredEventData` instead of plain text, providing a collapsible, syntax-highlighted tree view. Simple JSON where all values are primitives (e.g. `{"boop": "bop"}`) renders compactly inline via CSS overrides, while nested structures get the full expandable tree with toggle buttons. The styled wrapper also resets `<pre>` default margins to keep the tree row grid layout properly aligned. <img width="833" height="425" alt="Screenshot of logs with object and array attribute values syntax-highlighted" src="https://github.com/user-attachments/assets/8ad56817-da1f-44cb-83e8-829a63c1d4dc" /> Fixes LOGS-400 Made with [Cursor](https://cursor.com)
1 parent a7cd4d8 commit 6b00c83

File tree

7 files changed

+182
-15
lines changed

7 files changed

+182
-15
lines changed

static/app/views/explore/components/traceItemAttributes/attributesTreeValue.spec.tsx

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,4 +129,108 @@ describe('AttributesTreeValue', () => {
129129

130130
expect(screen.getByText('null')).toBeInTheDocument();
131131
});
132+
133+
it('renders JSON object values as structured data', () => {
134+
const jsonContent = {
135+
...defaultProps.content,
136+
value: '{"key": "value", "number": 42}',
137+
};
138+
139+
render(<AttributesTreeValue {...defaultProps} content={jsonContent} />);
140+
141+
expect(screen.getByText('key')).toBeInTheDocument();
142+
expect(screen.getByText('value')).toBeInTheDocument();
143+
expect(screen.getByText('42')).toBeInTheDocument();
144+
});
145+
146+
it('renders JSON array values as structured data', () => {
147+
const jsonContent = {
148+
...defaultProps.content,
149+
value: '[1, 2, 3]',
150+
};
151+
152+
render(<AttributesTreeValue {...defaultProps} content={jsonContent} />);
153+
154+
expect(screen.getByText('1')).toBeInTheDocument();
155+
expect(screen.getByText('2')).toBeInTheDocument();
156+
expect(screen.getByText('3')).toBeInTheDocument();
157+
});
158+
159+
it('renders invalid JSON containing braces as plain text', () => {
160+
const invalidJsonContent = {
161+
...defaultProps.content,
162+
value: 'not {json',
163+
};
164+
165+
render(<AttributesTreeValue {...defaultProps} content={invalidJsonContent} />);
166+
167+
expect(screen.getByText('not {json')).toBeInTheDocument();
168+
});
169+
170+
it('renders plain strings without braces as plain text', () => {
171+
const plainContent = {
172+
...defaultProps.content,
173+
value: 'hello world',
174+
};
175+
176+
render(<AttributesTreeValue {...defaultProps} content={plainContent} />);
177+
178+
expect(screen.getByText('hello world')).toBeInTheDocument();
179+
});
180+
181+
it('does not render JSON as structured data when disableRichValue is true', () => {
182+
const jsonContent = {
183+
...defaultProps.content,
184+
value: '{"key": "value"}',
185+
};
186+
187+
render(
188+
<AttributesTreeValue
189+
{...defaultProps}
190+
content={jsonContent}
191+
config={{disableRichValue: true}}
192+
/>
193+
);
194+
195+
expect(screen.getByText('{"key": "value"}')).toBeInTheDocument();
196+
expect(screen.queryByRole('list')).not.toBeInTheDocument();
197+
});
198+
199+
it('renders simple JSON with compact class', () => {
200+
const jsonContent = {
201+
...defaultProps.content,
202+
value: '{"boop": "bop"}',
203+
};
204+
205+
render(<AttributesTreeValue {...defaultProps} content={jsonContent} />);
206+
207+
const pre = screen.getByText('bop').closest('pre');
208+
expect(pre).toHaveClass('compact');
209+
});
210+
211+
it('renders short JSON with nested objects without compact class', () => {
212+
const jsonContent = {
213+
...defaultProps.content,
214+
value: '{"a":{"b":{"c":{"d":1}}}}',
215+
};
216+
217+
render(<AttributesTreeValue {...defaultProps} content={jsonContent} />);
218+
219+
const pre = screen.getByText('a').closest('pre');
220+
expect(pre).not.toHaveClass('compact');
221+
});
222+
223+
it('renders long JSON without compact class', () => {
224+
const jsonContent = {
225+
...defaultProps.content,
226+
value: '{"k": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}',
227+
};
228+
229+
render(<AttributesTreeValue {...defaultProps} content={jsonContent} />);
230+
231+
const pre = screen
232+
.getByText('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa')
233+
.closest('pre');
234+
expect(pre).not.toHaveClass('compact');
235+
});
132236
});

static/app/views/explore/components/traceItemAttributes/attributesTreeValue.tsx

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import styled from '@emotion/styled';
33

44
import {openNavigateToExternalLinkModal} from 'sentry/actionCreators/modal';
55
import {ExternalLink} from 'sentry/components/links/externalLink';
6+
import {StructuredEventData} from 'sentry/components/structuredEventData';
67
import {type RenderFunctionBaggage} from 'sentry/utils/discover/fieldRenderers';
78
import {isUrl} from 'sentry/utils/string/isUrl';
89
import {AnnotatedAttributeTooltip} from 'sentry/views/explore/components/annotatedAttributeTooltip';
@@ -15,6 +16,26 @@ import type {
1516
AttributesTreeRowConfig,
1617
} from './attributesTree';
1718

19+
function tryParseJson(value: unknown) {
20+
if (typeof value !== 'string') {
21+
return undefined;
22+
}
23+
24+
try {
25+
return JSON.parse(value) as unknown;
26+
} catch {
27+
return undefined;
28+
}
29+
}
30+
31+
function hasNestedObject(value: unknown) {
32+
if (typeof value !== 'object' || value === null) {
33+
return false;
34+
}
35+
const values = Array.isArray(value) ? value : Object.values(value);
36+
return values.some(v => typeof v === 'object' && v !== null);
37+
}
38+
1839
export function AttributesTreeValue<RendererExtra extends RenderFunctionBaggage>({
1940
config,
2041
content,
@@ -32,11 +53,12 @@ export function AttributesTreeValue<RendererExtra extends RenderFunctionBaggage>
3253
// Check if we have a custom renderer for this attribute
3354
const attributeKey = originalAttribute.original_attribute_key;
3455
const renderer = renderers[attributeKey];
56+
const value = String(content.value);
3557

36-
const defaultValue = <span>{String(content.value)}</span>;
58+
const defaultValue = <span>{value}</span>;
3759

3860
if (config?.disableRichValue) {
39-
return String(content.value);
61+
return value;
4062
}
4163

4264
if (renderer) {
@@ -57,12 +79,27 @@ export function AttributesTreeValue<RendererExtra extends RenderFunctionBaggage>
5779
);
5880
}
5981
}
60-
return isUrl(String(content.value)) ? (
82+
83+
const parsedJson = tryParseJson(content.value);
84+
if (typeof parsedJson === 'object' && parsedJson !== null) {
85+
return (
86+
<AttributeStructuredData
87+
data={parsedJson}
88+
maxDefaultDepth={2}
89+
withAnnotatedText={false}
90+
className={
91+
value.length <= 48 && !hasNestedObject(parsedJson) ? 'compact' : undefined
92+
}
93+
/>
94+
);
95+
}
96+
97+
return isUrl(value) ? (
6198
<AttributeLinkText>
6299
<ExternalLink
63100
onClick={e => {
64101
e.preventDefault();
65-
openNavigateToExternalLinkModal({linkText: String(content.value)});
102+
openNavigateToExternalLinkModal({linkText: value});
66103
}}
67104
>
68105
{defaultValue}
@@ -86,3 +123,29 @@ const AttributeLinkText = styled('span')`
86123
white-space: normal;
87124
}
88125
`;
126+
127+
const AttributeStructuredData = styled(StructuredEventData)`
128+
margin: 0;
129+
padding: 0;
130+
background: transparent;
131+
white-space: pre-wrap;
132+
word-break: break-word;
133+
134+
&.compact {
135+
display: inline;
136+
137+
span[data-base-with-toggle='true'] {
138+
display: inline;
139+
padding-left: 0;
140+
}
141+
142+
button {
143+
display: none;
144+
}
145+
146+
div {
147+
display: inline;
148+
padding-left: 0;
149+
}
150+
}
151+
`;

static/app/views/performance/newTraceDetails/traceDrawer/details/highlightedAttributes.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {
2121
} from 'sentry/views/insights/pages/agents/utils/query';
2222
import {Referrer} from 'sentry/views/insights/pages/agents/utils/referrers';
2323
import {SpanFields} from 'sentry/views/insights/types';
24-
import {tryParseJson} from 'sentry/views/performance/newTraceDetails/traceDrawer/details/utils';
24+
import {tryParseJsonRecursive} from 'sentry/views/performance/newTraceDetails/traceDrawer/details/utils';
2525

2626
type HighlightedAttribute = {
2727
name: string;
@@ -42,15 +42,15 @@ function getAIToolDefinitions(
4242
): any[] | null {
4343
const toolDefinitions = attributes['gen_ai.tool.definitions'];
4444
if (toolDefinitions) {
45-
const parsed = tryParseJson(toolDefinitions.toString());
45+
const parsed = tryParseJsonRecursive(toolDefinitions.toString());
4646
if (Array.isArray(parsed)) {
4747
return parsed;
4848
}
4949
}
5050

5151
const availableTools = attributes['gen_ai.request.available_tools'];
5252
if (availableTools) {
53-
const parsed = tryParseJson(availableTools.toString());
53+
const parsed = tryParseJsonRecursive(availableTools.toString());
5454
if (Array.isArray(parsed)) {
5555
return parsed;
5656
}

static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/aiInput.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {AIContentRenderer} from 'sentry/views/performance/newTraceDetails/traceD
2222
import {TraceDrawerComponents} from 'sentry/views/performance/newTraceDetails/traceDrawer/details/styles';
2323
import {
2424
parseJsonWithFix,
25-
tryParseJson,
25+
tryParseJsonRecursive,
2626
} from 'sentry/views/performance/newTraceDetails/traceDrawer/details/utils';
2727
import type {EapSpanNode} from 'sentry/views/performance/newTraceDetails/traceModels/traceTreeNode/eapSpanNode';
2828
import type {SpanNode} from 'sentry/views/performance/newTraceDetails/traceModels/traceTreeNode/spanNode';
@@ -106,7 +106,7 @@ function parseAIMessages(messages: string): AIMessage[] | string {
106106
if (!message.role || !message.content) {
107107
return null;
108108
}
109-
const parsedContent = tryParseJson(message.content);
109+
const parsedContent = tryParseJsonRecursive(message.content);
110110
return {
111111
role: message.role,
112112
content:

static/app/views/performance/newTraceDetails/traceDrawer/details/span/eapSections/attributes.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ import {
3535
findSpanAttributeValue,
3636
getTraceAttributesTreeActions,
3737
sortAttributes,
38-
tryParseJson,
38+
tryParseJsonRecursive,
3939
} from 'sentry/views/performance/newTraceDetails/traceDrawer/details/utils';
4040
import type {EapSpanNode} from 'sentry/views/performance/newTraceDetails/traceModels/traceTreeNode/eapSpanNode';
4141
import type {UptimeCheckNode} from 'sentry/views/performance/newTraceDetails/traceModels/traceTreeNode/uptimeCheckNode';
@@ -49,7 +49,7 @@ const HIDDEN_ATTRIBUTES = ['is_segment', 'project_id', 'received'];
4949
const TRUNCATED_TEXT_ATTRIBUTES = ['gen_ai.response.text', 'gen_ai.embeddings.input'];
5050

5151
const jsonRenderer = (props: CustomRenderersProps) => {
52-
const value = tryParseJson(props.item.value);
52+
const value = tryParseJsonRecursive(props.item.value);
5353
return <StructuredData value={value} withAnnotatedText maxDefaultDepth={0} />;
5454
};
5555

static/app/views/performance/newTraceDetails/traceDrawer/details/styles.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ import {getIsAiNode} from 'sentry/views/insights/pages/agents/utils/aiTraceNodes
5959
import {getIsMCPNode} from 'sentry/views/insights/pages/mcp/utils/mcpTraceNodes';
6060
import {traceAnalytics} from 'sentry/views/performance/newTraceDetails/traceAnalytics';
6161
import {useDrawerContainerRef} from 'sentry/views/performance/newTraceDetails/traceDrawer/details/drawerContainerRefContext';
62-
import {tryParseJson} from 'sentry/views/performance/newTraceDetails/traceDrawer/details/utils';
62+
import {tryParseJsonRecursive} from 'sentry/views/performance/newTraceDetails/traceDrawer/details/utils';
6363
import {
6464
makeTraceContinuousProfilingLink,
6565
makeTransactionProfilingLink,
@@ -1319,7 +1319,7 @@ function MultilineJSON({
13191319
const {hoverProps, isHovered} = useHover({});
13201320
const theme = useTheme();
13211321

1322-
const json = useMemo(() => tryParseJson(value), [value]);
1322+
const json = useMemo(() => tryParseJsonRecursive(value), [value]);
13231323

13241324
// Ensure root ('$') is always expanded, while children follow maxDefaultDepth rules
13251325
const computedExpandedPaths = useMemo(() => {

static/app/views/performance/newTraceDetails/traceDrawer/details/utils.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@ export function getTraceAttributesTreeActions(
247247
/**
248248
* Attempts to parse a JSON string, recursively unwrapping double-stringified arrays.
249249
*/
250-
export function tryParseJson(value: unknown): unknown {
250+
export function tryParseJsonRecursive(value: unknown): unknown {
251251
if (typeof value !== 'string') {
252252
return value;
253253
}
@@ -256,7 +256,7 @@ export function tryParseJson(value: unknown): unknown {
256256
if (!Array.isArray(parsedValue)) {
257257
return parsedValue;
258258
}
259-
return parsedValue.map((item: unknown): unknown => tryParseJson(item));
259+
return parsedValue.map((item: unknown): unknown => tryParseJsonRecursive(item));
260260
} catch {
261261
return value;
262262
}

0 commit comments

Comments
 (0)