From 32270cd7fc2fabd0447618215bf99d31a9acb820 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Fri, 27 Mar 2026 14:04:00 -0400 Subject: [PATCH] feat(logs): Add inline JSON syntax highlighting for log attributes When a log attribute value contains embedded JSON within surrounding text (e.g. "This is my JSON: {"key": "value"}"), the JSON portions are now syntax-highlighted inline using Prism tokenization while the non-JSON text renders as-is. This builds on the existing full-JSON pretty-printing from LOGS-400. The JSON extraction logic is in a standalone, dependency-free utility (extractJsonFromText) with string-aware bracket matching that correctly handles braces inside quoted strings and escape sequences. Refs LINEAR-LOGS-636 Co-Authored-By: Claude Sonnet 4 Made-with: Cursor --- static/app/utils/extractJsonFromText.spec.ts | 483 ++++++++++++++++++ static/app/utils/extractJsonFromText.ts | 136 +++++ .../attributesTreeValue.spec.tsx | 48 ++ .../attributesTreeValue.tsx | 35 +- .../inlineJsonHighlight.spec.tsx | 33 ++ .../inlineJsonHighlight.tsx | 57 +++ 6 files changed, 778 insertions(+), 14 deletions(-) create mode 100644 static/app/utils/extractJsonFromText.spec.ts create mode 100644 static/app/utils/extractJsonFromText.ts create mode 100644 static/app/views/explore/components/traceItemAttributes/inlineJsonHighlight.spec.tsx create mode 100644 static/app/views/explore/components/traceItemAttributes/inlineJsonHighlight.tsx diff --git a/static/app/utils/extractJsonFromText.spec.ts b/static/app/utils/extractJsonFromText.spec.ts new file mode 100644 index 00000000000000..7d618fb6c15c58 --- /dev/null +++ b/static/app/utils/extractJsonFromText.spec.ts @@ -0,0 +1,483 @@ +import {extractJsonFromText, findMatchingBracket} from './extractJsonFromText'; + +describe('findMatchingBracket', () => { + it('finds matching curly brace', () => { + expect(findMatchingBracket('{}', 0)).toBe(1); + }); + + it('finds matching square bracket', () => { + expect(findMatchingBracket('[]', 0)).toBe(1); + }); + + it('handles nested braces', () => { + expect(findMatchingBracket('{{{}}}', 0)).toBe(5); + }); + + it('handles mixed bracket types', () => { + expect(findMatchingBracket('{[{}]}', 0)).toBe(5); + }); + + it('returns -1 for unmatched opening brace', () => { + expect(findMatchingBracket('{', 0)).toBe(-1); + }); + + it('returns -1 for unmatched opening bracket', () => { + expect(findMatchingBracket('[', 0)).toBe(-1); + }); + + it('starts from the given position', () => { + expect(findMatchingBracket('xx{yy}zz', 2)).toBe(5); + }); + + it('ignores braces inside double-quoted strings', () => { + expect(findMatchingBracket('{"key": "}"}', 0)).toBe(11); + }); + + it('ignores brackets inside double-quoted strings', () => { + expect(findMatchingBracket('{"key": "]"}', 0)).toBe(11); + }); + + it('handles escaped quotes inside strings', () => { + expect(findMatchingBracket('{"key": "val\\"ue"}', 0)).toBe(17); + }); + + it('handles escaped backslash before closing quote', () => { + // The value is a string ending with a literal backslash: "val\\" + // In the JSON: {"k": "val\\"} — the \\\\ is an escaped backslash, + // so the quote after it closes the string. + expect(findMatchingBracket('{"k": "val\\\\"}', 0)).toBe(13); + }); + + it('handles multiple escaped characters in a string', () => { + expect(findMatchingBracket('{"k": "a\\nb\\tc"}', 0)).toBe(15); + }); + + it('handles empty object', () => { + expect(findMatchingBracket('{}', 0)).toBe(1); + }); + + it('handles empty array', () => { + expect(findMatchingBracket('[]', 0)).toBe(1); + }); + + it('handles deeply nested structure', () => { + expect(findMatchingBracket('[[[[[]]]]]', 0)).toBe(9); + }); + + it('handles string containing opening braces', () => { + expect(findMatchingBracket('{"braces": "{{{"}', 0)).toBe(16); + }); + + it('handles string containing brackets and braces mixed', () => { + expect(findMatchingBracket('{"val": "[{]"}', 0)).toBe(13); + }); + + it('returns -1 when string has unbalanced quotes disrupting matching', () => { + // An unclosed string means the closing brace is "inside" the string + expect(findMatchingBracket('{"key: }', 0)).toBe(-1); + }); +}); + +describe('extractJsonFromText', () => { + describe('basic extraction', () => { + it('returns empty array for empty string', () => { + expect(extractJsonFromText('')).toEqual([]); + }); + + it('returns a single text segment for plain text', () => { + expect(extractJsonFromText('hello world')).toEqual([ + {type: 'text', value: 'hello world'}, + ]); + }); + + it('extracts a standalone JSON object', () => { + expect(extractJsonFromText('{"key": "value"}')).toEqual([ + {type: 'json', value: '{"key": "value"}'}, + ]); + }); + + it('extracts a standalone JSON array', () => { + expect(extractJsonFromText('[1, 2, 3]')).toEqual([ + {type: 'json', value: '[1, 2, 3]'}, + ]); + }); + + it('extracts JSON object with surrounding text', () => { + expect(extractJsonFromText('prefix {"key": "value"} suffix')).toEqual([ + {type: 'text', value: 'prefix '}, + {type: 'json', value: '{"key": "value"}'}, + {type: 'text', value: ' suffix'}, + ]); + }); + + it('extracts JSON array with surrounding text', () => { + expect(extractJsonFromText('data: [1, 2, 3] end')).toEqual([ + {type: 'text', value: 'data: '}, + {type: 'json', value: '[1, 2, 3]'}, + {type: 'text', value: ' end'}, + ]); + }); + + it('extracts JSON at the very start', () => { + expect(extractJsonFromText('{"key": "value"} trailing')).toEqual([ + {type: 'json', value: '{"key": "value"}'}, + {type: 'text', value: ' trailing'}, + ]); + }); + + it('extracts JSON at the very end', () => { + expect(extractJsonFromText('leading {"key": "value"}')).toEqual([ + {type: 'text', value: 'leading '}, + {type: 'json', value: '{"key": "value"}'}, + ]); + }); + }); + + describe('multiple JSON values', () => { + it('extracts multiple JSON objects', () => { + expect(extractJsonFromText('a {"x": 1} b {"y": 2} c')).toEqual([ + {type: 'text', value: 'a '}, + {type: 'json', value: '{"x": 1}'}, + {type: 'text', value: ' b '}, + {type: 'json', value: '{"y": 2}'}, + {type: 'text', value: ' c'}, + ]); + }); + + it('extracts adjacent JSON objects without separator', () => { + expect(extractJsonFromText('{"a": 1}{"b": 2}')).toEqual([ + {type: 'json', value: '{"a": 1}'}, + {type: 'json', value: '{"b": 2}'}, + ]); + }); + + it('extracts mixed objects and arrays', () => { + expect(extractJsonFromText('obj: {"a": 1} arr: [2, 3]')).toEqual([ + {type: 'text', value: 'obj: '}, + {type: 'json', value: '{"a": 1}'}, + {type: 'text', value: ' arr: '}, + {type: 'json', value: '[2, 3]'}, + ]); + }); + }); + + describe('nested structures', () => { + it('handles nested objects', () => { + expect(extractJsonFromText('r: {"a": {"b": {"c": 1}}}')).toEqual([ + {type: 'text', value: 'r: '}, + {type: 'json', value: '{"a": {"b": {"c": 1}}}'}, + ]); + }); + + it('handles nested arrays', () => { + expect(extractJsonFromText('r: [[1, [2]], [3]]')).toEqual([ + {type: 'text', value: 'r: '}, + {type: 'json', value: '[[1, [2]], [3]]'}, + ]); + }); + + it('handles objects containing arrays', () => { + expect(extractJsonFromText('r: {"a": [1, 2, 3]}')).toEqual([ + {type: 'text', value: 'r: '}, + {type: 'json', value: '{"a": [1, 2, 3]}'}, + ]); + }); + + it('handles arrays containing objects', () => { + expect(extractJsonFromText('r: [{"a": 1}, {"b": 2}]')).toEqual([ + {type: 'text', value: 'r: '}, + {type: 'json', value: '[{"a": 1}, {"b": 2}]'}, + ]); + }); + }); + + describe('string literal handling (where naive packages fail)', () => { + it('handles braces inside JSON string values', () => { + expect(extractJsonFromText('log: {"pattern": "{user}"}')).toEqual([ + {type: 'text', value: 'log: '}, + {type: 'json', value: '{"pattern": "{user}"}'}, + ]); + }); + + it('handles brackets inside JSON string values', () => { + expect(extractJsonFromText('log: {"pattern": "[item]"}')).toEqual([ + {type: 'text', value: 'log: '}, + {type: 'json', value: '{"pattern": "[item]"}'}, + ]); + }); + + it('handles closing brace inside a string value', () => { + // This is the case that breaks balanced-match and extract-json-from-string + expect(extractJsonFromText('x {"key": "}"} y')).toEqual([ + {type: 'text', value: 'x '}, + {type: 'json', value: '{"key": "}"}'}, + {type: 'text', value: ' y'}, + ]); + }); + + it('handles closing bracket inside a string value', () => { + expect(extractJsonFromText('x {"key": "]"} y')).toEqual([ + {type: 'text', value: 'x '}, + {type: 'json', value: '{"key": "]"}'}, + {type: 'text', value: ' y'}, + ]); + }); + + it('handles escaped quotes in string values', () => { + expect(extractJsonFromText('d: {"msg": "say \\"hello\\""}')).toEqual([ + {type: 'text', value: 'd: '}, + {type: 'json', value: '{"msg": "say \\"hello\\""}'}, + ]); + }); + + it('handles escaped backslash before closing quote', () => { + // Value is literally: val\ (backslash at end) + // JSON encoding: "val\\" — the \\\\ is an escaped backslash + expect(extractJsonFromText('d: {"k": "val\\\\"}')).toEqual([ + {type: 'text', value: 'd: '}, + {type: 'json', value: '{"k": "val\\\\"}'}, + ]); + }); + + it('handles newlines and tabs in JSON strings', () => { + expect(extractJsonFromText('d: {"k": "line1\\nline2"}')).toEqual([ + {type: 'text', value: 'd: '}, + {type: 'json', value: '{"k": "line1\\nline2"}'}, + ]); + }); + + it('handles unicode escapes in JSON strings', () => { + expect(extractJsonFromText('d: {"k": "caf\\u00e9"}')).toEqual([ + {type: 'text', value: 'd: '}, + {type: 'json', value: '{"k": "caf\\u00e9"}'}, + ]); + }); + }); + + describe('non-JSON braces treated as text', () => { + it('treats template-style braces as text', () => { + expect(extractJsonFromText('hello {name} world')).toEqual([ + {type: 'text', value: 'hello {name} world'}, + ]); + }); + + it('treats unmatched opening brace as text', () => { + expect(extractJsonFromText('not {json')).toEqual([ + {type: 'text', value: 'not {json'}, + ]); + }); + + it('treats unmatched opening bracket as text', () => { + expect(extractJsonFromText('not [json')).toEqual([ + {type: 'text', value: 'not [json'}, + ]); + }); + + it('treats matched but syntactically invalid JSON as text', () => { + expect(extractJsonFromText('{invalid json content}')).toEqual([ + {type: 'text', value: '{invalid json content}'}, + ]); + }); + + it('treats Python-style dicts as text', () => { + expect(extractJsonFromText("data: {'key': 'value'}")).toEqual([ + {type: 'text', value: "data: {'key': 'value'}"}, + ]); + }); + + it('treats braces in code snippets as text', () => { + expect(extractJsonFromText('function() { return 1; }')).toEqual([ + {type: 'text', value: 'function() { return 1; }'}, + ]); + }); + + it('treats CSS-like braces as text', () => { + expect(extractJsonFromText('.class { color: red; }')).toEqual([ + {type: 'text', value: '.class { color: red; }'}, + ]); + }); + + it('treats multiple template vars as text', () => { + expect(extractJsonFromText('{user} logged in from {ip}')).toEqual([ + {type: 'text', value: '{user} logged in from {ip}'}, + ]); + }); + }); + + describe('JSON value types', () => { + it('does not treat bare primitives as JSON segments', () => { + expect(extractJsonFromText('value: 123')).toEqual([ + {type: 'text', value: 'value: 123'}, + ]); + }); + + it('extracts arrays of primitives', () => { + expect(extractJsonFromText('[true, false, null]')).toEqual([ + {type: 'json', value: '[true, false, null]'}, + ]); + }); + + it('extracts objects with various value types', () => { + const json = '{"s": "str", "n": 42, "b": true, "x": null}'; + expect(extractJsonFromText(`d: ${json}`)).toEqual([ + {type: 'text', value: 'd: '}, + {type: 'json', value: json}, + ]); + }); + + it('extracts empty object', () => { + expect(extractJsonFromText('empty: {}')).toEqual([ + {type: 'text', value: 'empty: '}, + {type: 'json', value: '{}'}, + ]); + }); + + it('extracts empty array', () => { + expect(extractJsonFromText('empty: []')).toEqual([ + {type: 'text', value: 'empty: '}, + {type: 'json', value: '[]'}, + ]); + }); + + it('extracts array of strings', () => { + expect(extractJsonFromText('tags: ["a", "b", "c"]')).toEqual([ + {type: 'text', value: 'tags: '}, + {type: 'json', value: '["a", "b", "c"]'}, + ]); + }); + }); + + describe('text preservation invariant', () => { + const cases = [ + 'hello world', + 'prefix {"key": "value"} suffix', + 'a {"x": 1} b {"y": 2} c', + '{"a": 1}{"b": 2}', + 'no json here {invalid} at all', + 'mixed {"valid": true} and {invalid} stuff', + '', + '{"only": "json"}', + 'trailing text after {"json": true}', + '{"json": true} leading text before', + 'braces } without { matching [ pairs ]', + 'log: {"pattern": "{user}"}', + ]; + + it.each(cases)('concatenating segments reproduces the original: %s', input => { + const segments = extractJsonFromText(input); + const reconstructed = segments.map(s => s.value).join(''); + expect(reconstructed).toBe(input); + }); + }); + + describe('mixed valid and invalid JSON', () => { + it('extracts valid JSON surrounded by invalid braces', () => { + expect(extractJsonFromText('{bad} {"good": true} {bad}')).toEqual([ + {type: 'text', value: '{bad} '}, + {type: 'json', value: '{"good": true}'}, + {type: 'text', value: ' {bad}'}, + ]); + }); + + it('handles valid JSON after several invalid brace pairs', () => { + expect(extractJsonFromText('{a} {b} {c} {"d": 1}')).toEqual([ + {type: 'text', value: '{a} {b} {c} '}, + {type: 'json', value: '{"d": 1}'}, + ]); + }); + + it('handles invalid brace pair after valid JSON', () => { + expect(extractJsonFromText('{"a": 1} {b} done')).toEqual([ + {type: 'json', value: '{"a": 1}'}, + {type: 'text', value: ' {b} done'}, + ]); + }); + }); + + describe('whitespace handling', () => { + it('preserves whitespace in text segments', () => { + expect(extractJsonFromText(' {"a": 1} ')).toEqual([ + {type: 'text', value: ' '}, + {type: 'json', value: '{"a": 1}'}, + {type: 'text', value: ' '}, + ]); + }); + + it('handles JSON with internal whitespace', () => { + expect(extractJsonFromText('d: { "key" : "value" }')).toEqual([ + {type: 'text', value: 'd: '}, + {type: 'json', value: '{ "key" : "value" }'}, + ]); + }); + + it('handles multiline JSON', () => { + const json = '{\n "key": "value"\n}'; + expect(extractJsonFromText(`d: ${json} end`)).toEqual([ + {type: 'text', value: 'd: '}, + {type: 'json', value: json}, + {type: 'text', value: ' end'}, + ]); + }); + }); + + describe('real-world log patterns', () => { + it('extracts JSON from a log line', () => { + expect( + extractJsonFromText( + '2024-01-15 10:30:00 INFO {"event": "login", "user": "alice"}' + ) + ).toEqual([ + {type: 'text', value: '2024-01-15 10:30:00 INFO '}, + {type: 'json', value: '{"event": "login", "user": "alice"}'}, + ]); + }); + + it('extracts JSON from a message with context', () => { + expect( + extractJsonFromText( + 'This is my JSON: { "it": "would be", "nice": ["to", "highlight"], "this": true }' + ) + ).toEqual([ + {type: 'text', value: 'This is my JSON: '}, + { + type: 'json', + value: '{ "it": "would be", "nice": ["to", "highlight"], "this": true }', + }, + ]); + }); + + it('extracts JSON from an error message', () => { + expect( + extractJsonFromText( + 'Failed to process request: {"error": "timeout", "code": 504} - retrying' + ) + ).toEqual([ + {type: 'text', value: 'Failed to process request: '}, + {type: 'json', value: '{"error": "timeout", "code": 504}'}, + {type: 'text', value: ' - retrying'}, + ]); + }); + + it('handles a log line with no JSON', () => { + expect( + extractJsonFromText('2024-01-15 10:30:00 INFO User logged in successfully') + ).toEqual([ + { + type: 'text', + value: '2024-01-15 10:30:00 INFO User logged in successfully', + }, + ]); + }); + + it('handles a stack trace style message with braces', () => { + expect( + extractJsonFromText('Error at MyClass.method(file.java:42) caused by {unknown}') + ).toEqual([ + { + type: 'text', + value: 'Error at MyClass.method(file.java:42) caused by {unknown}', + }, + ]); + }); + }); +}); diff --git a/static/app/utils/extractJsonFromText.ts b/static/app/utils/extractJsonFromText.ts new file mode 100644 index 00000000000000..e056b5b474cdbf --- /dev/null +++ b/static/app/utils/extractJsonFromText.ts @@ -0,0 +1,136 @@ +type TextSegment = {type: 'text'; value: string}; +type JsonSegment = {type: 'json'; value: string}; + +export type ExtractedSegment = TextSegment | JsonSegment; + +/** + * Finds the position of the matching closing bracket for a `{` or `[` + * at position `start`. Correctly handles JSON string literals — bracket + * characters inside double-quoted strings are ignored, and backslash + * escapes within strings are respected. + * + * Returns the index of the matching closing bracket, or -1 if the + * brackets are unbalanced. + */ +export function findMatchingBracket(text: string, start: number): number { + let depth = 0; + let inString = false; + let escaped = false; + + for (let i = start; i < text.length; i++) { + const ch = text[i]; + + if (escaped) { + escaped = false; + continue; + } + + if (ch === '\\' && inString) { + escaped = true; + continue; + } + + if (ch === '"') { + inString = !inString; + continue; + } + + if (inString) { + continue; + } + + if (ch === '{' || ch === '[') { + depth++; + } + if (ch === '}' || ch === ']') { + depth--; + } + + if (depth === 0) { + return i; + } + } + + return -1; +} + +/** + * Extracts JSON object and array substrings from arbitrary text. + * + * Scans `text` for `{` / `[` characters, uses string-aware bracket + * matching to find the candidate closing bracket, then validates the + * candidate with `JSON.parse`. Returns an array of segments preserving + * the full original text — every character appears in exactly one + * segment, and concatenating all segment values reproduces the input. + * + * Only objects and arrays are recognized as JSON segments; bare + * primitives like `"hello"`, `42`, or `true` are left as text. + * + * @example + * extractJsonFromText('msg: {"level":"info"} ok') + * // [ + * // { type: 'text', value: 'msg: ' }, + * // { type: 'json', value: '{"level":"info"}' }, + * // { type: 'text', value: ' ok' }, + * // ] + */ +export function extractJsonFromText(text: string): ExtractedSegment[] { + const segments: ExtractedSegment[] = []; + let i = 0; + + while (i < text.length) { + let nextStart = -1; + for (let j = i; j < text.length; j++) { + if (text[j] === '{' || text[j] === '[') { + nextStart = j; + break; + } + } + + if (nextStart === -1) { + if (i < text.length) { + segments.push({type: 'text', value: text.slice(i)}); + } + break; + } + + if (nextStart > i) { + segments.push({type: 'text', value: text.slice(i, nextStart)}); + } + + const matchEnd = findMatchingBracket(text, nextStart); + if (matchEnd === -1) { + segments.push({type: 'text', value: text.slice(nextStart)}); + break; + } + + const candidate = text.slice(nextStart, matchEnd + 1); + try { + const parsed = JSON.parse(candidate); + if (typeof parsed === 'object' && parsed !== null) { + segments.push({type: 'json', value: candidate}); + i = matchEnd + 1; + } else { + segments.push({type: 'text', value: text[nextStart]!}); + i = nextStart + 1; + } + } catch { + segments.push({type: 'text', value: text[nextStart]!}); + i = nextStart + 1; + } + } + + // Merge consecutive text segments produced when invalid candidates + // cause the scanner to advance one character at a time. + const merged: ExtractedSegment[] = []; + for (const segment of segments) { + const last = merged[merged.length - 1]; + if (segment.type === 'text' && last?.type === 'text') { + last.value += segment.value; + } else { + merged.push(segment); + } + } + + return merged; +} diff --git a/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.spec.tsx b/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.spec.tsx index 0e6b030fb87dc1..a0d4b3c51f00de 100644 --- a/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.spec.tsx +++ b/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.spec.tsx @@ -233,4 +233,52 @@ describe('AttributesTreeValue', () => { .closest('pre'); expect(pre).not.toHaveClass('compact'); }); + + it('renders inline JSON highlighting for text containing a JSON object', () => { + const content = { + ...defaultProps.content, + value: 'msg: {"level": "info"}', + }; + + render(); + + const wrapper = screen.getByText(/msg/); + expect(wrapper.querySelector('code.language-json')).toBeInTheDocument(); + }); + + it('renders inline JSON highlighting for text containing a JSON array', () => { + const content = { + ...defaultProps.content, + value: 'tags: [1, 2, 3]', + }; + + render(); + + const wrapper = screen.getByText(/tags/); + expect(wrapper.querySelector('code.language-json')).toBeInTheDocument(); + }); + + it('renders URL with brackets as a link, not inline JSON', () => { + const content = { + ...defaultProps.content, + value: 'https://example.com/api?filter=[1,2]', + }; + + render(); + + const link = screen.getByText('https://example.com/api?filter=[1,2]').closest('a'); + expect(link).toBeInTheDocument(); + }); + + it('renders URL with braces as a link, not inline JSON', () => { + const content = { + ...defaultProps.content, + value: 'https://example.com/api/{id}', + }; + + render(); + + const link = screen.getByText('https://example.com/api/{id}').closest('a'); + expect(link).toBeInTheDocument(); + }); }); diff --git a/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.tsx b/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.tsx index aeb856dcbf0ad0..861ffef99dd363 100644 --- a/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.tsx +++ b/static/app/views/explore/components/traceItemAttributes/attributesTreeValue.tsx @@ -7,6 +7,7 @@ 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 {InlineJsonHighlight} from 'sentry/views/explore/components/traceItemAttributes/inlineJsonHighlight'; import {getAttributeItem} from 'sentry/views/explore/components/traceItemAttributes/utils'; import {TraceItemMetaInfo} from 'sentry/views/explore/utils'; @@ -94,20 +95,26 @@ export function AttributesTreeValue ); } - return isUrl(value) ? ( - - { - e.preventDefault(); - openNavigateToExternalLinkModal({linkText: value}); - }} - > - {defaultValue} - - - ) : ( - defaultValue - ); + if (isUrl(value)) { + return ( + + { + e.preventDefault(); + openNavigateToExternalLinkModal({linkText: value}); + }} + > + {defaultValue} + + + ); + } + + if (value.includes('{') || value.includes('[')) { + return ; + } + + return defaultValue; } const AttributeLinkText = styled('span')` diff --git a/static/app/views/explore/components/traceItemAttributes/inlineJsonHighlight.spec.tsx b/static/app/views/explore/components/traceItemAttributes/inlineJsonHighlight.spec.tsx new file mode 100644 index 00000000000000..190b8243642238 --- /dev/null +++ b/static/app/views/explore/components/traceItemAttributes/inlineJsonHighlight.spec.tsx @@ -0,0 +1,33 @@ +import {render, screen} from 'sentry-test/reactTestingLibrary'; + +import {InlineJsonHighlight} from './inlineJsonHighlight'; + +describe('InlineJsonHighlight', () => { + it('renders plain text without highlighting', () => { + render(); + expect(screen.getByText('hello world')).toBeInTheDocument(); + }); + + it('renders text with embedded JSON and highlights JSON portion', () => { + render(); + const wrapper = screen.getByText(/prefix/); + expect(wrapper).toHaveTextContent('prefix {"key": "value"} suffix'); + expect(wrapper.querySelector('code.language-json')).toBeInTheDocument(); + }); + + it('renders invalid braces as plain text', () => { + render(); + expect(screen.getByText('not {json')).toBeInTheDocument(); + }); + + it('renders template-style braces as plain text', () => { + render(); + expect(screen.getByText('hello {name} world')).toBeInTheDocument(); + }); + + it('uses code element for JSON segments', () => { + render(); + const wrapper = screen.getByText(/data/); + expect(wrapper.querySelector('code.language-json')).toBeInTheDocument(); + }); +}); diff --git a/static/app/views/explore/components/traceItemAttributes/inlineJsonHighlight.tsx b/static/app/views/explore/components/traceItemAttributes/inlineJsonHighlight.tsx new file mode 100644 index 00000000000000..527cd003aa0659 --- /dev/null +++ b/static/app/views/explore/components/traceItemAttributes/inlineJsonHighlight.tsx @@ -0,0 +1,57 @@ +import {Fragment, useMemo} from 'react'; +import styled from '@emotion/styled'; + +import {extractJsonFromText} from 'sentry/utils/extractJsonFromText'; +import {usePrismTokens} from 'sentry/utils/usePrismTokens'; + +function JsonSegment({json}: {json: string}) { + const lines = usePrismTokens({code: json, language: 'json'}); + + return ( + + {lines.map((line, lineIdx) => ( + + {line.map((token, tokenIdx) => ( + + {token.children} + + ))} + + ))} + + ); +} + +/** + * Renders a string with inline syntax highlighting for embedded JSON. + * JSON objects and arrays within the text are colorized using Prism's JSON grammar. + * Non-JSON text is rendered as-is. + */ +export function InlineJsonHighlight({value}: {value: string}) { + const segments = useMemo(() => extractJsonFromText(value), [value]); + + if (segments.length === 1 && segments[0]!.type === 'text') { + return {value}; + } + + return ( + + {segments.map((segment, idx) => + segment.type === 'json' ? ( + + ) : ( + {segment.value} + ) + )} + + ); +} + +const InlineCode = styled('code')` + && { + background: transparent; + padding: 0; + white-space: pre-wrap; + font-size: inherit; + } +`;