Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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(<AttributesTreeValue {...defaultProps} content={jsonContent} />);

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(<AttributesTreeValue {...defaultProps} content={jsonContent} />);

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(<AttributesTreeValue {...defaultProps} content={invalidJsonContent} />);

expect(screen.getByText('not {json')).toBeInTheDocument();
});

it('renders plain strings without braces as plain text', () => {
const plainContent = {
...defaultProps.content,
value: 'hello world',
};

render(<AttributesTreeValue {...defaultProps} content={plainContent} />);

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(
<AttributesTreeValue
{...defaultProps}
content={jsonContent}
config={{disableRichValue: true}}
/>
);

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(<AttributesTreeValue {...defaultProps} content={jsonContent} />);

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(<AttributesTreeValue {...defaultProps} content={jsonContent} />);

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(<AttributesTreeValue {...defaultProps} content={jsonContent} />);

const pre = screen
.getByText('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa')
.closest('pre');
expect(pre).not.toHaveClass('compact');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<RendererExtra extends RenderFunctionBaggage>({
config,
content,
Expand All @@ -32,11 +53,12 @@ export function AttributesTreeValue<RendererExtra extends RenderFunctionBaggage>
// 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 = <span>{String(content.value)}</span>;
const defaultValue = <span>{value}</span>;

if (config?.disableRichValue) {
return String(content.value);
return value;
}

if (renderer) {
Expand All @@ -57,12 +79,27 @@ export function AttributesTreeValue<RendererExtra extends RenderFunctionBaggage>
);
}
}
return isUrl(String(content.value)) ? (

const parsedJson = tryParseJson(content.value);
Comment thread
cursor[bot] marked this conversation as resolved.
if (typeof parsedJson === 'object' && parsedJson !== null) {
return (
<AttributeStructuredData
data={parsedJson}
maxDefaultDepth={2}
withAnnotatedText={false}
className={
value.length <= 48 && !hasNestedObject(parsedJson) ? 'compact' : undefined
}
/>
);
Comment thread
sentry[bot] marked this conversation as resolved.
}

return isUrl(value) ? (
<AttributeLinkText>
<ExternalLink
onClick={e => {
e.preventDefault();
openNavigateToExternalLinkModal({linkText: String(content.value)});
openNavigateToExternalLinkModal({linkText: value});
}}
>
{defaultValue}
Expand All @@ -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'] {
Comment thread
JoshuaKGoldberg marked this conversation as resolved.
display: inline;
padding-left: 0;
}

button {
display: none;
}

div {
display: inline;
padding-left: 0;
}
}
`;
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -41,15 +41,15 @@ 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;
}
}

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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 <StructuredData value={value} withAnnotatedText maxDefaultDepth={0} />;
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See #111077 (comment): renaming this to disambiguate. Both in general and from the tryParseJson I added to attributesTreeValue.tsx.

if (typeof value !== 'string') {
return value;
}
Expand All @@ -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;
}
Expand Down
Loading