Skip to content

Commit 2dd46b3

Browse files
feat(logs): Add JSON pretty-printing for log attributes
Detect string attribute values that contain valid JSON and render them using StructuredEventData instead of plain text. This provides a collapsible, syntax-highlighted tree view for JSON objects and arrays. Simple JSON (all primitive values) renders compactly inline, while nested structures get the full expandable tree treatment. Refs LOGS-400 Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com> Made-with: Cursor
1 parent fc42a0c commit 2dd46b3

File tree

2 files changed

+155
-0
lines changed

2 files changed

+155
-0
lines changed

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

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,4 +129,94 @@ 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 nested JSON without compact class', () => {
212+
const jsonContent = {
213+
...defaultProps.content,
214+
value: '{"nested": {"inner": "deep"}}',
215+
};
216+
217+
render(<AttributesTreeValue {...defaultProps} content={jsonContent} />);
218+
219+
const pre = screen.getByText('inner').closest('pre');
220+
expect(pre).not.toHaveClass('compact');
221+
});
132222
});

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

Lines changed: 65 additions & 0 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,32 @@ import type {
1516
AttributesTreeRowConfig,
1617
} from './attributesTree';
1718

19+
function tryParseJson(
20+
value: unknown
21+
): Record<PropertyKey, unknown> | unknown[] | undefined {
22+
const str = String(value);
23+
if (!str.includes('{') && !str.includes('[')) {
24+
return undefined;
25+
}
26+
try {
27+
const parsed = JSON.parse(str);
28+
if (typeof parsed === 'object' && parsed !== null) {
29+
return parsed;
30+
}
31+
return undefined;
32+
} catch {
33+
return undefined;
34+
}
35+
}
36+
37+
/**
38+
* Returns true when all values in the object/array are primitives (no nesting).
39+
*/
40+
function isSimpleJson(value: Record<PropertyKey, unknown> | unknown[]): boolean {
41+
const values = Array.isArray(value) ? value : Object.values(value);
42+
return values.every(v => typeof v !== 'object' || v === null);
43+
}
44+
1845
export function AttributesTreeValue<RendererExtra extends RenderFunctionBaggage>({
1946
config,
2047
content,
@@ -57,6 +84,18 @@ export function AttributesTreeValue<RendererExtra extends RenderFunctionBaggage>
5784
);
5885
}
5986
}
87+
const parsedJson = tryParseJson(content.value);
88+
if (parsedJson !== undefined) {
89+
return (
90+
<AttributeStructuredData
91+
data={parsedJson}
92+
maxDefaultDepth={2}
93+
withAnnotatedText={false}
94+
className={isSimpleJson(parsedJson) ? 'compact' : undefined}
95+
/>
96+
);
97+
}
98+
6099
return isUrl(String(content.value)) ? (
61100
<AttributeLinkText>
62101
<ExternalLink
@@ -86,3 +125,29 @@ const AttributeLinkText = styled('span')`
86125
white-space: normal;
87126
}
88127
`;
128+
129+
const AttributeStructuredData = styled(StructuredEventData)`
130+
margin: 0;
131+
padding: 0;
132+
background: transparent;
133+
white-space: pre-wrap;
134+
word-break: break-word;
135+
136+
&.compact {
137+
display: inline;
138+
139+
span[data-base-with-toggle='true'] {
140+
display: inline;
141+
padding-left: 0;
142+
}
143+
144+
button {
145+
display: none;
146+
}
147+
148+
div {
149+
display: inline;
150+
padding-left: 0;
151+
}
152+
}
153+
`;

0 commit comments

Comments
 (0)