diff --git a/.changeset/sort-json-viewer-keys.md b/.changeset/sort-json-viewer-keys.md
new file mode 100644
index 0000000000..97bc6b32d3
--- /dev/null
+++ b/.changeset/sort-json-viewer-keys.md
@@ -0,0 +1,5 @@
+---
+'@hyperdx/app': patch
+---
+
+fix: Sort JSON viewer keys alphabetically so wide Map columns are scannable
diff --git a/packages/app/src/components/HyperJson.tsx b/packages/app/src/components/HyperJson.tsx
index f7098eb939..e936f6f3a4 100644
--- a/packages/app/src/components/HyperJson.tsx
+++ b/packages/app/src/components/HyperJson.tsx
@@ -357,11 +357,26 @@ function TreeNode({
const keyPath = React.useMemo(() => _keyPath ?? [], [_keyPath]);
const originalLength = React.useMemo(() => Object.keys(data).length, [data]);
+
+ // ClickHouse hands back `Map(...)` keys in physical storage order, which reads
+ // as random for wide maps like `ProfileEvents`. Sorting here (rather than
+ // upstream) covers every nesting level for free, since TreeNode recurses, and
+ // it puts the sort ahead of the MAX_TREE_NODE_ITEMS slice below so the
+ // truncated view shows a predictable prefix instead of an arbitrary subset.
+ const entries = React.useMemo(() => {
+ const raw = Object.entries(data);
+ // Arrays are index-keyed — reordering them would change the data.
+ if (isArray(data)) {
+ return raw;
+ }
+ return raw.sort(([a], [b]) =>
+ a.localeCompare(b, undefined, { numeric: true }),
+ );
+ }, [data]);
+
const visibleLines = React.useMemo(() => {
- return isExpanded
- ? Object.entries(data)
- : Object.entries(data).slice(0, MAX_TREE_NODE_ITEMS);
- }, [data, isExpanded]);
+ return isExpanded ? entries : entries.slice(0, MAX_TREE_NODE_ITEMS);
+ }, [entries, isExpanded]);
const nestedLevel = keyPath?.length || 0;
return (
diff --git a/packages/app/src/components/__tests__/HyperJson.test.tsx b/packages/app/src/components/__tests__/HyperJson.test.tsx
index 1f7dbf67b2..8710d9d83e 100644
--- a/packages/app/src/components/__tests__/HyperJson.test.tsx
+++ b/packages/app/src/components/__tests__/HyperJson.test.tsx
@@ -36,3 +36,68 @@ describe('HyperJson wrap markers', () => {
expect(key).toHaveTextContent(longKey);
});
});
+
+// ClickHouse returns `Map(...)` keys in physical storage order, which reads as
+// random for wide maps like `ProfileEvents`. Sort at render time so the tree is
+// scannable — and so the MAX_TREE_NODE_ITEMS cap slices a predictable prefix
+// rather than an arbitrary subset.
+describe('HyperJson key ordering', () => {
+ const renderedKeys = (container: HTMLElement) =>
+ Array.from(container.querySelectorAll('.keyContainer > .key')).map(el =>
+ el.textContent?.trim(),
+ );
+
+ const renderedValues = (container: HTMLElement) =>
+ Array.from(container.querySelectorAll('.valueContainer')).map(el =>
+ el.textContent?.trim(),
+ );
+
+ it('sorts top-level keys alphabetically, case-insensitively', () => {
+ const { container } = renderWithMantine(
+ ,
+ );
+
+ expect(renderedKeys(container)).toEqual(['alpha', 'Mango', 'zebra']);
+ });
+
+ it('sorts nested object keys alphabetically', () => {
+ const { container } = renderWithMantine(
+ ,
+ );
+
+ expect(renderedKeys(container)).toEqual([
+ 'ProfileEvents',
+ 'FileOpen',
+ 'Query',
+ 'Seek',
+ ]);
+ });
+
+ it('orders numeric suffixes naturally rather than lexicographically', () => {
+ const { container } = renderWithMantine(
+ ,
+ );
+
+ expect(renderedKeys(container)).toEqual(['key1', 'key2', 'key10']);
+ });
+
+ // Arrays are index-keyed, so sorting their "keys" would reorder the data.
+ // Uses >10 elements so a lexicographic sort (0, 1, 10, 11, 2, ...) would show
+ // up, not just a hypothetical reversal.
+ it('preserves array element order', () => {
+ const list = Array.from({ length: 12 }, (_, i) => `item-${i}`);
+ const { container } = renderWithMantine(
+ ,
+ );
+
+ expect(renderedKeys(container)).toEqual([
+ 'list',
+ ...list.map((_, i) => String(i)),
+ ]);
+ // First value cell belongs to `list` itself ("[] 12 items").
+ expect(renderedValues(container).slice(1)).toEqual(list);
+ });
+});