mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
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:
parent
6c8efbcb56
commit
54d30b92f5
4 changed files with 146 additions and 15 deletions
5
.changeset/young-tools-sneeze.md
Normal file
5
.changeset/young-tools-sneeze.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@hyperdx/app": minor
|
||||
---
|
||||
|
||||
feat: Add support for filter by parsed JSON string
|
||||
|
|
@ -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}" ${
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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"]',
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue