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" />
This commit is contained in:
Mike Shi 2025-09-27 09:14:03 -07:00 committed by GitHub
parent 6c8efbcb56
commit 54d30b92f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 146 additions and 15 deletions

View file

@ -0,0 +1,5 @@
---
"@hyperdx/app": minor
---
feat: Add support for filter by parsed JSON string

View file

@ -20,6 +20,20 @@ import { notifications } from '@mantine/notifications';
import HyperJson, { GetLineActions, LineAction } from '@/components/HyperJson';
import { mergePath } from '@/utils';
function buildJSONExtractStringQuery(
keyPath: string[],
parsedJsonRootPath: string[],
): string | null {
const nestedPath = keyPath.slice(parsedJsonRootPath.length);
if (nestedPath.length === 0) {
return null; // No nested path to extract
}
const baseColumn = parsedJsonRootPath[parsedJsonRootPath.length - 1];
const jsonPathArgs = nestedPath.map(p => `'${p}'`).join(', ');
return `JSONExtractString(${baseColumn}, ${jsonPathArgs})`;
}
import { RowSidePanelContext } from './DBRowSidePanel';
function filterObjectRecursively(obj: any, filter: string): any {
@ -152,7 +166,7 @@ export function DBRowJsonViewer({
}, [data, debouncedFilter]);
const getLineActions = useCallback<GetLineActions>(
({ keyPath, value }) => {
({ keyPath, value, isInParsedJson, parsedJsonRootPath }) => {
const actions: LineAction[] = [];
const fieldPath = mergePath(keyPath, jsonColumns);
const isJsonColumn =
@ -177,10 +191,30 @@ export function DBRowJsonViewer({
),
title: 'Add to Filters',
onClick: () => {
onPropertyAddClick(
isJsonColumn ? `toString(${fieldPath})` : fieldPath,
value,
);
let filterFieldPath = fieldPath;
// Handle parsed JSON from string columns using JSONExtractString
if (isInParsedJson && parsedJsonRootPath) {
const jsonQuery = buildJSONExtractStringQuery(
keyPath,
parsedJsonRootPath,
);
if (jsonQuery) {
filterFieldPath = jsonQuery;
} else {
// We're at the root of the parsed JSON, treat as string
filterFieldPath = isJsonColumn
? `toString(${fieldPath})`
: fieldPath;
}
} else {
// Regular JSON column or non-JSON field
filterFieldPath = isJsonColumn
? `toString(${fieldPath})`
: fieldPath;
}
onPropertyAddClick(filterFieldPath, value);
notifications.show({
color: 'green',
message: `Added "${fieldPath} = ${value}" to filters`,
@ -200,13 +234,29 @@ export function DBRowJsonViewer({
),
title: 'Search for this value only',
onClick: () => {
let defaultWhere = `${fieldPath} = ${
let searchFieldPath = fieldPath;
// Handle parsed JSON from string columns using JSONExtractString
if (isInParsedJson && parsedJsonRootPath) {
const jsonQuery = buildJSONExtractStringQuery(
keyPath,
parsedJsonRootPath,
);
if (jsonQuery) {
searchFieldPath = jsonQuery;
}
}
let defaultWhere = `${searchFieldPath} = ${
typeof value === 'string' ? `'${value}'` : value
}`;
// FIXME: TOTAL HACK
if (fieldPath == 'Timestamp' || fieldPath == 'TimestampTime') {
defaultWhere = `${fieldPath} = parseDateTime64BestEffort('${value}', 9)`;
if (
searchFieldPath == 'Timestamp' ||
searchFieldPath == 'TimestampTime'
) {
defaultWhere = `${searchFieldPath} = parseDateTime64BestEffort('${value}', 9)`;
}
router.push(
generateSearchUrl({
@ -225,10 +275,23 @@ export function DBRowJsonViewer({
label: <i className="bi bi-graph-up" />,
title: 'Chart',
onClick: () => {
let chartFieldPath = fieldPath;
// Handle parsed JSON from string columns using JSONExtractString
if (isInParsedJson && parsedJsonRootPath) {
const jsonQuery = buildJSONExtractStringQuery(
keyPath,
parsedJsonRootPath,
);
if (jsonQuery) {
chartFieldPath = jsonQuery;
}
}
router.push(
generateChartUrl({
aggFn: 'avg',
field: fieldPath,
field: chartFieldPath,
groupBy: [],
}),
);
@ -238,7 +301,20 @@ export function DBRowJsonViewer({
// Toggle column action (non-object values)
if (toggleColumn && typeof value !== 'object') {
const isIncluded = displayedColumns?.includes(fieldPath);
let columnFieldPath = fieldPath;
// Handle parsed JSON from string columns using JSONExtractString
if (isInParsedJson && parsedJsonRootPath) {
const jsonQuery = buildJSONExtractStringQuery(
keyPath,
parsedJsonRootPath,
);
if (jsonQuery) {
columnFieldPath = jsonQuery;
}
}
const isIncluded = displayedColumns?.includes(columnFieldPath);
actions.push({
key: 'toggle-column',
label: isIncluded ? (
@ -256,7 +332,7 @@ export function DBRowJsonViewer({
? `Remove ${fieldPath} column from results table`
: `Add ${fieldPath} column to results table`,
onClick: () => {
toggleColumn(fieldPath);
toggleColumn(columnFieldPath);
notifications.show({
color: 'green',
message: `Column "${fieldPath}" ${

View file

@ -25,6 +25,8 @@ export type GetLineActions = (arg0: {
key: string;
keyPath: string[];
value: any;
isInParsedJson?: boolean;
parsedJsonRootPath?: string[];
}) => LineAction[];
// Store common state in an atom so that it can be shared between components
@ -90,19 +92,36 @@ const LineMenu = React.memo(
keyName,
keyPath,
value,
isInParsedJson,
parsedJsonRootPath,
}: {
keyName: string;
keyPath: string[];
value: any;
isInParsedJson?: boolean;
parsedJsonRootPath?: string[];
}) => {
const { getLineActions } = useAtomValue(hyperJsonAtom);
const lineActions = React.useMemo(() => {
if (getLineActions) {
return getLineActions({ key: keyName, keyPath, value });
return getLineActions({
key: keyName,
keyPath,
value,
isInParsedJson,
parsedJsonRootPath,
});
}
return [];
}, [getLineActions, keyName, keyPath, value]);
}, [
getLineActions,
keyName,
keyPath,
value,
isInParsedJson,
parsedJsonRootPath,
]);
return (
<div className={styles.lineMenu}>
@ -130,11 +149,15 @@ const Line = React.memo(
keyPath: parentKeyPath,
value,
disableMenu,
isInParsedJson = false,
parsedJsonRootPath = [],
}: {
keyName: string;
keyPath: string[];
value: any;
disableMenu: boolean;
isInParsedJson?: boolean;
parsedJsonRootPath?: string[];
}) => {
const { normallyExpanded } = useAtomValue(hyperJsonAtom);
@ -195,6 +218,16 @@ const Line = React.memo(
[keyName, parentKeyPath],
);
// Determine the context for nested parsed JSON
const childIsInParsedJson = isInParsedJson || isStringValueValidJson;
const childParsedJsonRootPath = React.useMemo(() => {
if (isStringValueValidJson) {
// This is the start of a new parsed JSON context
return keyPath;
}
return parsedJsonRootPath;
}, [isStringValueValidJson, keyPath, parsedJsonRootPath]);
// Hide LineMenu when selecting text in the value
const valueRef = React.useRef<HTMLSpanElement>(null);
const [isSelectingValue, setIsSelectingValue] = React.useState(false);
@ -256,14 +289,22 @@ const Line = React.memo(
)}
</div>
{hovered && !disableMenu && !isSelectingValue && (
<LineMenu keyName={keyName} keyPath={keyPath} value={value} />
<LineMenu
keyName={keyName}
keyPath={keyPath}
value={value}
isInParsedJson={isInParsedJson}
parsedJsonRootPath={parsedJsonRootPath}
/>
)}
</div>
{isExpanded && isExpandable && (
<TreeNode
data={expandedData}
keyPath={keyPath}
disableMenu={isStringValueValidJson}
disableMenu={disableMenu}
isInParsedJson={childIsInParsedJson}
parsedJsonRootPath={childParsedJsonRootPath}
/>
)}
</>
@ -276,10 +317,14 @@ function TreeNode({
data,
keyPath = [],
disableMenu = false,
isInParsedJson = false,
parsedJsonRootPath = [],
}: {
data: object;
keyPath?: string[];
disableMenu?: boolean;
isInParsedJson?: boolean;
parsedJsonRootPath?: string[];
}) {
const [isExpanded, setIsExpanded] = React.useState(false);
@ -300,6 +345,8 @@ function TreeNode({
value={value}
keyPath={keyPath}
disableMenu={disableMenu}
isInParsedJson={isInParsedJson}
parsedJsonRootPath={parsedJsonRootPath}
/>
))}
{originalLength > MAX_TREE_NODE_ITEMS && !isExpanded && (

View file

@ -25,6 +25,9 @@ test.describe('Advanced Search Workflow - Traces', { tag: '@traces' }, () => {
await expect(searchInput).toBeVisible();
await searchInput.fill('Order');
await page.locator('[data-testid="time-picker-input"]').click();
await page.locator('text=Last 1 days').click();
const searchSubmitButton = page.locator(
'[data-testid="search-submit-button"]',
);