Skip to content

Commit 54d30b9

Browse files
authored
feat: Add support for filter by parsed JSON string (#1213)
Resolves HDX-1983 <img width="646" height="242" alt="image" src="https://github.com/user-attachments/assets/376c0e02-73b9-49e3-96f8-ebb3475a7e82" />
1 parent 6c8efbc commit 54d30b9

File tree

4 files changed

+146
-15
lines changed

4 files changed

+146
-15
lines changed

.changeset/young-tools-sneeze.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@hyperdx/app": minor
3+
---
4+
5+
feat: Add support for filter by parsed JSON string

packages/app/src/components/DBRowJsonViewer.tsx

Lines changed: 87 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,20 @@ import { notifications } from '@mantine/notifications';
2020
import HyperJson, { GetLineActions, LineAction } from '@/components/HyperJson';
2121
import { mergePath } from '@/utils';
2222

23+
function buildJSONExtractStringQuery(
24+
keyPath: string[],
25+
parsedJsonRootPath: string[],
26+
): string | null {
27+
const nestedPath = keyPath.slice(parsedJsonRootPath.length);
28+
if (nestedPath.length === 0) {
29+
return null; // No nested path to extract
30+
}
31+
32+
const baseColumn = parsedJsonRootPath[parsedJsonRootPath.length - 1];
33+
const jsonPathArgs = nestedPath.map(p => `'${p}'`).join(', ');
34+
return `JSONExtractString(${baseColumn}, ${jsonPathArgs})`;
35+
}
36+
2337
import { RowSidePanelContext } from './DBRowSidePanel';
2438

2539
function filterObjectRecursively(obj: any, filter: string): any {
@@ -152,7 +166,7 @@ export function DBRowJsonViewer({
152166
}, [data, debouncedFilter]);
153167

154168
const getLineActions = useCallback<GetLineActions>(
155-
({ keyPath, value }) => {
169+
({ keyPath, value, isInParsedJson, parsedJsonRootPath }) => {
156170
const actions: LineAction[] = [];
157171
const fieldPath = mergePath(keyPath, jsonColumns);
158172
const isJsonColumn =
@@ -177,10 +191,30 @@ export function DBRowJsonViewer({
177191
),
178192
title: 'Add to Filters',
179193
onClick: () => {
180-
onPropertyAddClick(
181-
isJsonColumn ? `toString(${fieldPath})` : fieldPath,
182-
value,
183-
);
194+
let filterFieldPath = fieldPath;
195+
196+
// Handle parsed JSON from string columns using JSONExtractString
197+
if (isInParsedJson && parsedJsonRootPath) {
198+
const jsonQuery = buildJSONExtractStringQuery(
199+
keyPath,
200+
parsedJsonRootPath,
201+
);
202+
if (jsonQuery) {
203+
filterFieldPath = jsonQuery;
204+
} else {
205+
// We're at the root of the parsed JSON, treat as string
206+
filterFieldPath = isJsonColumn
207+
? `toString(${fieldPath})`
208+
: fieldPath;
209+
}
210+
} else {
211+
// Regular JSON column or non-JSON field
212+
filterFieldPath = isJsonColumn
213+
? `toString(${fieldPath})`
214+
: fieldPath;
215+
}
216+
217+
onPropertyAddClick(filterFieldPath, value);
184218
notifications.show({
185219
color: 'green',
186220
message: `Added "${fieldPath} = ${value}" to filters`,
@@ -200,13 +234,29 @@ export function DBRowJsonViewer({
200234
),
201235
title: 'Search for this value only',
202236
onClick: () => {
203-
let defaultWhere = `${fieldPath} = ${
237+
let searchFieldPath = fieldPath;
238+
239+
// Handle parsed JSON from string columns using JSONExtractString
240+
if (isInParsedJson && parsedJsonRootPath) {
241+
const jsonQuery = buildJSONExtractStringQuery(
242+
keyPath,
243+
parsedJsonRootPath,
244+
);
245+
if (jsonQuery) {
246+
searchFieldPath = jsonQuery;
247+
}
248+
}
249+
250+
let defaultWhere = `${searchFieldPath} = ${
204251
typeof value === 'string' ? `'${value}'` : value
205252
}`;
206253

207254
// FIXME: TOTAL HACK
208-
if (fieldPath == 'Timestamp' || fieldPath == 'TimestampTime') {
209-
defaultWhere = `${fieldPath} = parseDateTime64BestEffort('${value}', 9)`;
255+
if (
256+
searchFieldPath == 'Timestamp' ||
257+
searchFieldPath == 'TimestampTime'
258+
) {
259+
defaultWhere = `${searchFieldPath} = parseDateTime64BestEffort('${value}', 9)`;
210260
}
211261
router.push(
212262
generateSearchUrl({
@@ -225,10 +275,23 @@ export function DBRowJsonViewer({
225275
label: <i className="bi bi-graph-up" />,
226276
title: 'Chart',
227277
onClick: () => {
278+
let chartFieldPath = fieldPath;
279+
280+
// Handle parsed JSON from string columns using JSONExtractString
281+
if (isInParsedJson && parsedJsonRootPath) {
282+
const jsonQuery = buildJSONExtractStringQuery(
283+
keyPath,
284+
parsedJsonRootPath,
285+
);
286+
if (jsonQuery) {
287+
chartFieldPath = jsonQuery;
288+
}
289+
}
290+
228291
router.push(
229292
generateChartUrl({
230293
aggFn: 'avg',
231-
field: fieldPath,
294+
field: chartFieldPath,
232295
groupBy: [],
233296
}),
234297
);
@@ -238,7 +301,20 @@ export function DBRowJsonViewer({
238301

239302
// Toggle column action (non-object values)
240303
if (toggleColumn && typeof value !== 'object') {
241-
const isIncluded = displayedColumns?.includes(fieldPath);
304+
let columnFieldPath = fieldPath;
305+
306+
// Handle parsed JSON from string columns using JSONExtractString
307+
if (isInParsedJson && parsedJsonRootPath) {
308+
const jsonQuery = buildJSONExtractStringQuery(
309+
keyPath,
310+
parsedJsonRootPath,
311+
);
312+
if (jsonQuery) {
313+
columnFieldPath = jsonQuery;
314+
}
315+
}
316+
317+
const isIncluded = displayedColumns?.includes(columnFieldPath);
242318
actions.push({
243319
key: 'toggle-column',
244320
label: isIncluded ? (
@@ -256,7 +332,7 @@ export function DBRowJsonViewer({
256332
? `Remove ${fieldPath} column from results table`
257333
: `Add ${fieldPath} column to results table`,
258334
onClick: () => {
259-
toggleColumn(fieldPath);
335+
toggleColumn(columnFieldPath);
260336
notifications.show({
261337
color: 'green',
262338
message: `Column "${fieldPath}" ${

packages/app/src/components/HyperJson.tsx

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ export type GetLineActions = (arg0: {
2525
key: string;
2626
keyPath: string[];
2727
value: any;
28+
isInParsedJson?: boolean;
29+
parsedJsonRootPath?: string[];
2830
}) => LineAction[];
2931

3032
// Store common state in an atom so that it can be shared between components
@@ -90,19 +92,36 @@ const LineMenu = React.memo(
9092
keyName,
9193
keyPath,
9294
value,
95+
isInParsedJson,
96+
parsedJsonRootPath,
9397
}: {
9498
keyName: string;
9599
keyPath: string[];
96100
value: any;
101+
isInParsedJson?: boolean;
102+
parsedJsonRootPath?: string[];
97103
}) => {
98104
const { getLineActions } = useAtomValue(hyperJsonAtom);
99105

100106
const lineActions = React.useMemo(() => {
101107
if (getLineActions) {
102-
return getLineActions({ key: keyName, keyPath, value });
108+
return getLineActions({
109+
key: keyName,
110+
keyPath,
111+
value,
112+
isInParsedJson,
113+
parsedJsonRootPath,
114+
});
103115
}
104116
return [];
105-
}, [getLineActions, keyName, keyPath, value]);
117+
}, [
118+
getLineActions,
119+
keyName,
120+
keyPath,
121+
value,
122+
isInParsedJson,
123+
parsedJsonRootPath,
124+
]);
106125

107126
return (
108127
<div className={styles.lineMenu}>
@@ -130,11 +149,15 @@ const Line = React.memo(
130149
keyPath: parentKeyPath,
131150
value,
132151
disableMenu,
152+
isInParsedJson = false,
153+
parsedJsonRootPath = [],
133154
}: {
134155
keyName: string;
135156
keyPath: string[];
136157
value: any;
137158
disableMenu: boolean;
159+
isInParsedJson?: boolean;
160+
parsedJsonRootPath?: string[];
138161
}) => {
139162
const { normallyExpanded } = useAtomValue(hyperJsonAtom);
140163

@@ -195,6 +218,16 @@ const Line = React.memo(
195218
[keyName, parentKeyPath],
196219
);
197220

221+
// Determine the context for nested parsed JSON
222+
const childIsInParsedJson = isInParsedJson || isStringValueValidJson;
223+
const childParsedJsonRootPath = React.useMemo(() => {
224+
if (isStringValueValidJson) {
225+
// This is the start of a new parsed JSON context
226+
return keyPath;
227+
}
228+
return parsedJsonRootPath;
229+
}, [isStringValueValidJson, keyPath, parsedJsonRootPath]);
230+
198231
// Hide LineMenu when selecting text in the value
199232
const valueRef = React.useRef<HTMLSpanElement>(null);
200233
const [isSelectingValue, setIsSelectingValue] = React.useState(false);
@@ -256,14 +289,22 @@ const Line = React.memo(
256289
)}
257290
</div>
258291
{hovered && !disableMenu && !isSelectingValue && (
259-
<LineMenu keyName={keyName} keyPath={keyPath} value={value} />
292+
<LineMenu
293+
keyName={keyName}
294+
keyPath={keyPath}
295+
value={value}
296+
isInParsedJson={isInParsedJson}
297+
parsedJsonRootPath={parsedJsonRootPath}
298+
/>
260299
)}
261300
</div>
262301
{isExpanded && isExpandable && (
263302
<TreeNode
264303
data={expandedData}
265304
keyPath={keyPath}
266-
disableMenu={isStringValueValidJson}
305+
disableMenu={disableMenu}
306+
isInParsedJson={childIsInParsedJson}
307+
parsedJsonRootPath={childParsedJsonRootPath}
267308
/>
268309
)}
269310
</>
@@ -276,10 +317,14 @@ function TreeNode({
276317
data,
277318
keyPath = [],
278319
disableMenu = false,
320+
isInParsedJson = false,
321+
parsedJsonRootPath = [],
279322
}: {
280323
data: object;
281324
keyPath?: string[];
282325
disableMenu?: boolean;
326+
isInParsedJson?: boolean;
327+
parsedJsonRootPath?: string[];
283328
}) {
284329
const [isExpanded, setIsExpanded] = React.useState(false);
285330

@@ -300,6 +345,8 @@ function TreeNode({
300345
value={value}
301346
keyPath={keyPath}
302347
disableMenu={disableMenu}
348+
isInParsedJson={isInParsedJson}
349+
parsedJsonRootPath={parsedJsonRootPath}
303350
/>
304351
))}
305352
{originalLength > MAX_TREE_NODE_ITEMS && !isExpanded && (

packages/app/tests/e2e/features/traces-workflow.spec.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ test.describe('Advanced Search Workflow - Traces', { tag: '@traces' }, () => {
2525
await expect(searchInput).toBeVisible();
2626
await searchInput.fill('Order');
2727

28+
await page.locator('[data-testid="time-picker-input"]').click();
29+
await page.locator('text=Last 1 days').click();
30+
2831
const searchSubmitButton = page.locator(
2932
'[data-testid="search-submit-button"]',
3033
);

0 commit comments

Comments
 (0)