feat: Add custom trace-level attributes above trace waterfall (#1356)

Ref HDX-2752

# Summary

This PR adds a new Source setting which specifies additional Span-Level attributes which should be displayed above the Trace Waterfall. The attributes may be specified for both Log and Trace kind sources.

These are displayed on the trace panel rather than at the top of the side panel because they are trace level (from any span in the trace), whereas the top of the side panel shows information specific to the span. In a followup PR, we'll add row-level attributes, which will appear at the top of the side panel.

Notes:

1. The attributes are pulled from the log and trace source (if configured for each) despite the search page only being set to query one of those sources. If an attribute value comes from the log source while the trace source is currently configured on the search page, the attribute tag will not offer the option to show the "Add to search" button and instead will just show a "search this value" option, which navigates the search page to search the value in the correct source.

## Demo

First, source is configured with highlighted attributes:
<img width="954" height="288" alt="Screenshot 2025-11-14 at 3 02 25 PM" src="https://github.com/user-attachments/assets/e5eccbfa-3389-4521-83df-d63fcb13232a" />

Values for those attributes within the trace show up on the trace panel above the waterfall:
<img width="1257" height="152" alt="Screenshot 2025-11-14 at 3 02 59 PM" src="https://github.com/user-attachments/assets/7e3e9d87-5f3a-409b-9b08-054d6e460970" />

Values are searchable when clicked, and if a lucene version of the property is provided, the lucene version will be used in the search box
<img width="1334" height="419" alt="Screenshot 2025-11-14 at 3 03 10 PM" src="https://github.com/user-attachments/assets/50d98f99-5056-48ce-acca-4219286a68f7" />
This commit is contained in:
Drew Davis 2025-11-18 14:34:07 -05:00 committed by GitHub
parent 3f29e33852
commit c4915d45a7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 960 additions and 108 deletions

View file

@ -0,0 +1,7 @@
---
"@hyperdx/common-utils": patch
"@hyperdx/api": patch
"@hyperdx/app": patch
---
feat: Add custom trace-level attributes above trace waterfall

View file

@ -66,6 +66,9 @@ export const Source = mongoose.model<ISource>(
statusCodeExpression: String,
statusMessageExpression: String,
spanEventsValueExpression: String,
highlightedTraceAttributeExpressions: {
type: mongoose.Schema.Types.Array,
},
metricTables: {
type: {

View file

@ -34,6 +34,7 @@ import {
DisplayType,
Filter,
SourceKind,
TSource,
} from '@hyperdx/common-utils/dist/types';
import {
ActionIcon,
@ -1091,21 +1092,32 @@ function DBSearchPage() {
({
where,
whereLanguage,
source,
}: {
where: SearchConfig['where'];
whereLanguage: SearchConfig['whereLanguage'];
source?: TSource;
}) => {
const qParams = new URLSearchParams({
where: where || searchedConfig.where || '',
whereLanguage: whereLanguage || 'sql',
from: searchedTimeRange[0].getTime().toString(),
to: searchedTimeRange[1].getTime().toString(),
select: searchedConfig.select || '',
source: searchedSource?.id || '',
filters: JSON.stringify(searchedConfig.filters ?? []),
isLive: 'false',
liveInterval: interval.toString(),
});
// When generating a search based on a different source,
// filters and select for the current source are not preserved.
if (source && source.id !== searchedSource?.id) {
qParams.append('where', where || '');
qParams.append('source', source.id);
} else {
qParams.append('select', searchedConfig.select || '');
qParams.append('where', where || searchedConfig.where || '');
qParams.append('filters', JSON.stringify(searchedConfig.filters ?? []));
qParams.append('source', searchedSource?.id || '');
}
return `/search?${qParams.toString()}`;
},
[
@ -1829,6 +1841,7 @@ function DBSearchPage() {
dbSqlRowTableConfig,
isChildModalOpen: isDrawerChildModalOpen,
setChildModalOpen: setDrawerChildModalOpen,
source: searchedSource,
}}
config={dbSqlRowTableConfig}
sourceId={searchedConfig.source}

View file

@ -0,0 +1,67 @@
import { useContext, useMemo } from 'react';
import { TSource } from '@hyperdx/common-utils/dist/types';
import { Flex } from '@mantine/core';
import { RowSidePanelContext } from './DBRowSidePanel';
import EventTag from './EventTag';
export type HighlightedAttribute = {
source: TSource;
displayedKey: string;
value: string;
sql: string;
lucene?: string;
};
export function DBHighlightedAttributesList({
attributes = [],
}: {
attributes: HighlightedAttribute[];
}) {
const {
onPropertyAddClick,
generateSearchUrl,
source: contextSource,
} = useContext(RowSidePanelContext);
const sortedAttributes = useMemo(() => {
return attributes.sort(
(a, b) =>
a.displayedKey.localeCompare(b.displayedKey) ||
a.value.localeCompare(b.value),
);
}, [attributes]);
return (
<Flex wrap="wrap" gap="2px" mb="md">
{sortedAttributes.map(({ displayedKey, value, sql, lucene, source }) => (
<EventTag
displayedKey={displayedKey}
name={lucene ? lucene : sql}
nameLanguage={lucene ? 'lucene' : 'sql'}
value={value as string}
key={`${displayedKey}-${value}-${source.id}`}
{...(onPropertyAddClick && contextSource?.id === source.id
? {
onPropertyAddClick,
sqlExpression: sql,
}
: {
onPropertyAddClick: undefined,
sqlExpression: undefined,
})}
generateSearchUrl={
generateSearchUrl
? (query, queryLanguage) =>
generateSearchUrl({
where: query || '',
whereLanguage: queryLanguage ?? 'lucene',
source,
})
: undefined
}
/>
))}
</Flex>
);
}

View file

@ -81,11 +81,11 @@ export function RowOverviewPanel({
: {};
const _generateSearchUrl = useCallback(
(query?: string, timeRange?: [Date, Date]) => {
(query?: string, queryLanguage?: 'sql' | 'lucene') => {
return (
generateSearchUrl?.({
where: query,
whereLanguage: 'lucene',
whereLanguage: queryLanguage,
}) ?? '/'
);
},
@ -195,8 +195,6 @@ export function RowOverviewPanel({
<Box px="md">
<NetworkPropertySubpanel
eventAttributes={flattenedEventAttributes}
onPropertyAddClick={onPropertyAddClick}
generateSearchUrl={_generateSearchUrl}
/>
</Box>
</Accordion.Panel>

View file

@ -46,9 +46,11 @@ export type RowSidePanelContextProps = {
generateSearchUrl?: ({
where,
whereLanguage,
source,
}: {
where: SearchConfig['where'];
whereLanguage: SearchConfig['whereLanguage'];
source?: TSource;
}) => string;
generateChartUrl?: (config: {
aggFn: string;
@ -61,6 +63,7 @@ export type RowSidePanelContextProps = {
dbSqlRowTableConfig?: ChartConfigWithDateRange;
isChildModalOpen?: boolean;
setChildModalOpen?: (open: boolean) => void;
source?: TSource;
};
export const RowSidePanelContext = createContext<RowSidePanelContextProps>({});

View file

@ -175,11 +175,11 @@ export default function DBRowSidePanelHeader({
const maxBoxHeight = 120;
const _generateSearchUrl = useCallback(
(query?: string, timeRange?: [Date, Date]) => {
(query?: string, queryLanguage?: 'sql' | 'lucene') => {
return (
generateSearchUrl?.({
where: query,
whereLanguage: 'lucene',
whereLanguage: queryLanguage,
}) ?? '/'
);
},

View file

@ -19,6 +19,12 @@ import {
getSpanEventBody,
} from '@/source';
import TimelineChart from '@/TimelineChart';
import {
getHighlightedAttributesFromData,
getSelectExpressionsForHighlightedAttributes,
} from '@/utils/highlightedAttributes';
import { DBHighlightedAttributesList } from './DBHighlightedAttributesList';
import styles from '@/../styles/LogSidePanel.module.scss';
import resizeStyles from '@/../styles/ResizablePanel.module.scss';
@ -68,7 +74,7 @@ function getTableBody(tableModel: TSource) {
}
function getConfig(source: TSource, traceId: string) {
const alias = {
const alias: Record<string, string> = {
Body: getTableBody(source),
Timestamp: getDisplayedTimestampValueExpression(source),
Duration: source.durationExpression
@ -82,6 +88,17 @@ function getConfig(source: TSource, traceId: string) {
SeverityText: source.severityTextExpression ?? '',
SpanAttributes: source.eventAttributesExpression ?? '',
};
// Aliases for trace attributes must be added here to ensure
// the returned `alias` object includes them and useRowWhere works.
if (source.highlightedTraceAttributeExpressions) {
for (const expr of source.highlightedTraceAttributeExpressions) {
if (expr.alias) {
alias[expr.alias] = expr.sqlExpression;
}
}
}
const select = [
{
valueExpression: alias.Body,
@ -105,6 +122,14 @@ function getConfig(source: TSource, traceId: string) {
: []),
];
if (source.kind === SourceKind.Trace || source.kind === SourceKind.Log) {
select.push(
...getSelectExpressionsForHighlightedAttributes(
source.highlightedTraceAttributeExpressions,
),
);
}
if (source.kind === SourceKind.Trace) {
select.push(
...[
@ -177,7 +202,7 @@ export function useEventsData({
dateRange,
dateRangeStartInclusive,
};
}, [config, dateRange]);
}, [config, dateRange, dateRangeStartInclusive]);
return useOffsetPaginatedQuery(query, { enabled });
}
@ -237,6 +262,7 @@ export function useEventsAroundFocus({
return {
rows,
meta,
isFetching,
};
}
@ -267,28 +293,36 @@ export function DBTraceWaterfallChartContainer({
}) {
const { size, startResize } = useResizable(30, 'bottom');
const { rows: traceRowsData, isFetching: traceIsFetching } =
useEventsAroundFocus({
tableSource: traceTableSource,
focusDate,
dateRange,
traceId,
enabled: true,
});
const { rows: logRowsData, isFetching: logIsFetching } = useEventsAroundFocus(
{
// search data if logTableModel exist
// search invalid date range if no logTableModel(react hook need execute no matter what)
tableSource: logTableSource ? logTableSource : traceTableSource,
focusDate,
dateRange: logTableSource ? dateRange : [dateRange[1], dateRange[0]], // different query to prevent cache
traceId,
enabled: logTableSource ? true : false, // disable fire query if logSource is not exist
},
);
const {
rows: traceRowsData,
isFetching: traceIsFetching,
meta: traceRowsMeta,
} = useEventsAroundFocus({
tableSource: traceTableSource,
focusDate,
dateRange,
traceId,
enabled: true,
});
const {
rows: logRowsData,
isFetching: logIsFetching,
meta: logRowsMeta,
} = useEventsAroundFocus({
// search data if logTableModel exist
// search invalid date range if no logTableModel(react hook need execute no matter what)
tableSource: logTableSource ? logTableSource : traceTableSource,
focusDate,
dateRange: logTableSource ? dateRange : [dateRange[1], dateRange[0]], // different query to prevent cache
traceId,
enabled: logTableSource ? true : false, // disable fire query if logSource is not exist
});
const isFetching = traceIsFetching || logIsFetching;
const rows: any[] = [...traceRowsData, ...logRowsData];
const rows: any[] = useMemo(
() => [...traceRowsData, ...logRowsData],
[traceRowsData, logRowsData],
);
rows.sort((a, b) => {
const aDate = TimestampNano.fromString(a.Timestamp);
@ -301,6 +335,39 @@ export function DBTraceWaterfallChartContainer({
}
});
const highlightedAttributeValues = useMemo(() => {
const attributes = getHighlightedAttributesFromData(
traceTableSource,
traceTableSource.highlightedTraceAttributeExpressions,
traceRowsData,
traceRowsMeta,
);
if (logTableSource && logRowsData && logRowsMeta) {
attributes.push(
...getHighlightedAttributesFromData(
logTableSource,
logTableSource.highlightedTraceAttributeExpressions,
logRowsData,
logRowsMeta,
),
);
}
return attributes.sort(
(a, b) =>
a.displayedKey.localeCompare(b.displayedKey) ||
a.value.localeCompare(b.value),
);
}, [
traceTableSource,
traceRowsData,
traceRowsMeta,
logTableSource,
logRowsData,
logRowsMeta,
]);
useEffect(() => {
if (initialRowHighlightHint && onClick && highlightedRowWhere == null) {
const initialRowHighlightIndex = rows.findIndex(row => {
@ -572,25 +639,30 @@ export function DBTraceWaterfallChartContainer({
An unknown error occurred. <ContactSupportText />
</div>
) : (
<TimelineChart
style={{
overflowY: 'auto',
maxHeight: `${heightPx}px`,
}}
scale={1}
setScale={() => {}}
rowHeight={22}
labelWidth={300}
onClick={ts => {
// onTimeClick(ts + startedAt);
}}
onEventClick={event => {
onClick?.({ id: event.id, type: event.type ?? '' });
}}
cursors={[]}
rows={timelineRows}
initialScrollRowIndex={initialScrollRowIndex}
/>
<>
<DBHighlightedAttributesList
attributes={highlightedAttributeValues}
/>
<TimelineChart
style={{
overflowY: 'auto',
maxHeight: `${heightPx}px`,
}}
scale={1}
setScale={() => {}}
rowHeight={22}
labelWidth={300}
onClick={ts => {
// onTimeClick(ts + startedAt);
}}
onEventClick={event => {
onClick?.({ id: event.id, type: event.type ?? '' });
}}
cursors={[]}
rows={timelineRows}
initialScrollRowIndex={initialScrollRowIndex}
/>
</>
)}
</div>
<Divider

View file

@ -1,19 +1,28 @@
import { useState } from 'react';
import Link from 'next/link';
import SqlString from 'sqlstring';
import { SearchConditionLanguage } from '@hyperdx/common-utils/dist/types';
import { Button, Popover, Stack } from '@mantine/core';
export default function EventTag({
displayedKey,
name,
nameLanguage = 'lucene',
sqlExpression,
value,
onPropertyAddClick,
generateSearchUrl,
}: {
displayedKey?: string;
name: string; // lucene property name ex. col.prop
/** Property name, in lucene or sql syntax (ex. col.prop or col['prop']) */
name: string;
/** The language of the property name, defaults to 'lucene' */
nameLanguage?: SearchConditionLanguage;
value: string;
generateSearchUrl?: (query?: string, timeRange?: [Date, Date]) => string;
generateSearchUrl?: (
query?: string,
queryLanguage?: SearchConditionLanguage,
) => string;
} & (
| {
sqlExpression: undefined;
@ -35,6 +44,11 @@ export default function EventTag({
);
}
const searchCondition =
nameLanguage === 'sql'
? SqlString.format('? = ?', [SqlString.raw(name), value])
: `${name}:${typeof value === 'string' ? `"${value}"` : value}`;
return (
<Popover
position="top"
@ -71,9 +85,7 @@ export default function EventTag({
)}
{generateSearchUrl && (
<Link
href={generateSearchUrl(
`${name}:${typeof value === 'string' ? `"${value}"` : value}`,
)}
href={generateSearchUrl(searchCondition, nameLanguage)}
passHref
legacyBehavior
>

View file

@ -25,8 +25,6 @@ import { CurlGenerator } from '@/utils/curlGenerator';
interface NetworkPropertyPanelProps {
eventAttributes: Record<string, any>;
onPropertyAddClick?: (key: string, value: string) => void;
generateSearchUrl: (query?: string, timeRange?: [Date, Date]) => string;
}
// https://github.com/reduxjs/redux-devtools/blob/f11383d294c1139081f119ef08aa1169bd2ad5ff/packages/react-json-tree/src/createStylingFromTheme.ts
@ -189,8 +187,6 @@ export const NetworkBody = ({
export function NetworkPropertySubpanel({
eventAttributes,
onPropertyAddClick,
generateSearchUrl,
}: NetworkPropertyPanelProps) {
const requestHeaders = useMemo(
() => parseHeaders('http.request.header.', eventAttributes),

View file

@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useState } from 'react';
import {
Control,
Controller,
useFieldArray,
useForm,
UseFormSetValue,
UseFormWatch,
@ -15,11 +16,13 @@ import {
TSourceUnion,
} from '@hyperdx/common-utils/dist/types';
import {
ActionIcon,
Anchor,
Box,
Button,
Divider,
Flex,
Grid,
Group,
Radio,
Slider,
@ -28,10 +31,12 @@ import {
Tooltip,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { IconTrash } from '@tabler/icons-react';
import { SourceSelectControlled } from '@/components/SourceSelect';
import { IS_METRICS_ENABLED, IS_SESSIONS_ENABLED } from '@/config';
import { useConnections } from '@/connection';
import SearchInputV2 from '@/SearchInputV2';
import {
inferTableSourceConfig,
isValidMetricTable,
@ -102,33 +107,35 @@ function FormRow({
}) {
return (
// <Group grow preventGrowOverflow={false}>
<Flex align="center">
<Stack
justify="center"
style={{
maxWidth: 220,
minWidth: 220,
height: '36px',
}}
>
{typeof label === 'string' ? (
<Text tt="capitalize" size="sm">
{label}
</Text>
) : (
label
)}
</Stack>
<Text
me="sm"
style={{
...(!helpText ? { opacity: 0, pointerEvents: 'none' } : {}),
}}
>
<Tooltip label={helpText} color="dark" c="white" multiline maw={600}>
<i className="bi bi-question-circle cursor-pointer" />
</Tooltip>
</Text>
<Flex align="flex-start">
<Flex align="center">
<Stack
justify="center"
style={{
maxWidth: 220,
minWidth: 220,
height: '36px',
}}
>
{typeof label === 'string' ? (
<Text tt="capitalize" size="sm">
{label}
</Text>
) : (
label
)}
</Stack>
<Text
me="sm"
style={{
...(!helpText ? { opacity: 0, pointerEvents: 'none' } : {}),
}}
>
<Tooltip label={helpText} color="dark" c="white" multiline maw={600}>
<i className="bi bi-question-circle cursor-pointer" />
</Tooltip>
</Text>
</Flex>
<Box
w="100%"
style={{
@ -141,6 +148,109 @@ function FormRow({
);
}
function HighlightedAttributeExpressionsFormRow({
control,
watch,
}: TableModelProps) {
const databaseName = watch(`from.databaseName`, DEFAULT_DATABASE);
const tableName = watch(`from.tableName`);
const connectionId = watch(`connection`);
const {
fields: highlightedAttributes,
append: appendHighlightedAttribute,
remove: removeHighlightedAttribute,
} = useFieldArray({
control,
name: 'highlightedTraceAttributeExpressions',
});
return (
<FormRow
label={'Highlighted Attributes'}
helpText="Expressions defining trace-level attributes which are displayed in the search side panel."
>
<Grid columns={5}>
{highlightedAttributes.map((field, index) => (
<React.Fragment key={field.id}>
<Grid.Col span={3} pe={0}>
<SQLInlineEditorControlled
tableConnection={{
databaseName,
tableName,
connectionId,
}}
control={control}
name={`highlightedTraceAttributeExpressions.${index}.sqlExpression`}
disableKeywordAutocomplete
placeholder="ResourceAttributes['http.host']"
/>
</Grid.Col>
<Grid.Col span={2} ps="xs">
<Flex align="center" gap="sm">
<Text c="gray">AS</Text>
<SQLInlineEditorControlled
control={control}
name={`highlightedTraceAttributeExpressions.${index}.alias`}
placeholder="Optional Alias"
disableKeywordAutocomplete
/>
<ActionIcon
size="xs"
variant="subtle"
color="gray"
onClick={() => removeHighlightedAttribute(index)}
>
<IconTrash size={16} />
</ActionIcon>
</Flex>
</Grid.Col>
<Grid.Col span={3} pe={0}>
<InputControlled
control={control}
name={`highlightedTraceAttributeExpressions.${index}.luceneExpression`}
placeholder="ResourceAttributes.http.host (Optional) "
/>
</Grid.Col>
<Grid.Col span={1} pe={0}>
<Text me="sm" mt={6}>
<Tooltip
label={
'An optional, Lucene version of the above expression. If provided, it is used when searching for this attribute value.'
}
color="dark"
c="white"
multiline
maw={600}
>
<i className="bi bi-question-circle cursor-pointer" />
</Tooltip>
</Text>
</Grid.Col>
</React.Fragment>
))}
</Grid>
<Button
variant="default"
size="sm"
color="gray"
className="align-self-start"
mt={highlightedAttributes.length ? 'sm' : 'md'}
onClick={() => {
appendHighlightedAttribute({
sqlExpression: '',
luceneExpression: '',
alias: '',
});
}}
>
<i className="bi bi-plus-circle me-2" />
Add expression
</Button>
</FormRow>
);
}
// traceModel= ...
// logModel=....
// traceModel.logModel = 'custom'
@ -149,7 +259,8 @@ function FormRow({
// OR traceModel.logModel = 'log_id_blah'
// custom always points towards the url param
export function LogTableModelForm({ control, watch }: TableModelProps) {
export function LogTableModelForm(props: TableModelProps) {
const { control, watch } = props;
const databaseName = watch(`from.databaseName`, DEFAULT_DATABASE);
const tableName = watch(`from.tableName`);
const connectionId = watch(`connection`);
@ -377,12 +488,15 @@ export function LogTableModelForm({ control, watch }: TableModelProps) {
placeholder="Body"
/>
</FormRow>
<Divider />
<HighlightedAttributeExpressionsFormRow {...props} />
</Stack>
</>
);
}
export function TraceTableModelForm({ control, watch }: TableModelProps) {
export function TraceTableModelForm(props: TableModelProps) {
const { control, watch } = props;
const databaseName = watch(`from.databaseName`, DEFAULT_DATABASE);
const tableName = watch(`from.tableName`);
const connectionId = watch(`connection`);
@ -643,6 +757,8 @@ export function TraceTableModelForm({ control, watch }: TableModelProps) {
disableKeywordAutocomplete
/>
</FormRow>
<Divider />
<HighlightedAttributeExpressionsFormRow {...props} />
</Stack>
);
}

View file

@ -7,6 +7,7 @@ import useOffsetPaginatedQuery from '@/hooks/useOffsetPaginatedQuery';
import useRowWhere from '@/hooks/useRowWhere';
import TimelineChart from '@/TimelineChart';
import { RowSidePanelContext } from '../DBRowSidePanel';
import {
DBTraceWaterfallChartContainer,
SpanRow,
@ -25,6 +26,9 @@ jest.mock('@/TimelineChart', () => {
jest.mock('@/hooks/useOffsetPaginatedQuery');
jest.mock('@/hooks/useRowWhere');
jest.mock('../DBRowDataPanel', () => ({
getJSONColumnNames: jest.fn().mockReturnValue([]),
}));
const mockUseOffsetPaginatedQuery = useOffsetPaginatedQuery as jest.Mock;
const mockUseRowWhere = useRowWhere as jest.Mock;
@ -112,13 +116,15 @@ describe('DBTraceWaterfallChartContainer', () => {
logTableSource: typeof mockLogTableSource | null = mockLogTableSource,
) => {
return renderWithMantine(
<DBTraceWaterfallChartContainer
traceTableSource={mockTraceTableSource}
logTableSource={logTableSource}
traceId={mockTraceId}
dateRange={mockDateRange}
focusDate={mockFocusDate}
/>,
<RowSidePanelContext.Provider value={{}}>
<DBTraceWaterfallChartContainer
traceTableSource={mockTraceTableSource}
logTableSource={logTableSource}
traceId={mockTraceId}
dateRange={mockDateRange}
focusDate={mockFocusDate}
/>
</RowSidePanelContext.Provider>,
);
};

View file

@ -174,7 +174,7 @@ describe('processRowToWhereClause', () => {
const result = processRowToWhereClause(row, columnMap);
expect(result).toBe(
"toJSONString(dynamic_field) = toJSONString(JSONExtract('\\\"quoted_value\\\"', 'Dynamic'))",
"toJSONString(dynamic_field) = coalesce(toJSONString(JSONExtract('\\\"quoted_value\\\"', 'Dynamic')), toJSONString('\\\"quoted_value\\\"'))",
);
});
@ -194,7 +194,7 @@ describe('processRowToWhereClause', () => {
const row = { dynamic_field: '{\\"took\\":7, not a valid json' };
const result = processRowToWhereClause(row, columnMap);
expect(result).toBe(
"toJSONString(dynamic_field) = toJSONString(JSONExtract('{\\\\\\\"took\\\\\\\":7, not a valid json', 'Dynamic'))",
"toJSONString(dynamic_field) = coalesce(toJSONString(JSONExtract('{\\\\\\\"took\\\\\\\":7, not a valid json', 'Dynamic')), toJSONString('{\\\\\\\"took\\\\\\\":7, not a valid json'))",
);
});
@ -214,7 +214,7 @@ describe('processRowToWhereClause', () => {
const row = { dynamic_field: "{'foo': {'bar': 'baz'}}" };
const result = processRowToWhereClause(row, columnMap);
expect(result).toBe(
"toJSONString(dynamic_field) = toJSONString(JSONExtract('{\\'foo\\': {\\'bar\\': \\'baz\\'}}', 'Dynamic'))",
"toJSONString(dynamic_field) = coalesce(toJSONString(JSONExtract('{\\'foo\\': {\\'bar\\': \\'baz\\'}}', 'Dynamic')), toJSONString('{\\'foo\\': {\\'bar\\': \\'baz\\'}}'))",
);
});
@ -234,7 +234,7 @@ describe('processRowToWhereClause', () => {
const row = { dynamic_field: "['foo', 'bar']" };
const result = processRowToWhereClause(row, columnMap);
expect(result).toBe(
"toJSONString(dynamic_field) = toJSONString(JSONExtract('[\\'foo\\', \\'bar\\']', 'Dynamic'))",
"toJSONString(dynamic_field) = coalesce(toJSONString(JSONExtract('[\\'foo\\', \\'bar\\']', 'Dynamic')), toJSONString('[\\'foo\\', \\'bar\\']'))",
);
});

View file

@ -65,7 +65,7 @@ export function processRowToWhereClause(
// Handle case for json element, ex: json.c
// Currently we can't distinguish null or 'null'
if (value === 'null') {
if (value == null || value === 'null') {
return SqlString.format(`isNull(??)`, [valueExpr]);
}
if (value.length > 1000 || column.length > 1000) {
@ -75,10 +75,11 @@ export function processRowToWhereClause(
// escaped strings needs raw, because sqlString will add another layer of escaping
// data other than array/object will always return with double quote(because of CH)
// remove double quote to search correctly
// remove double quote to search correctly.
// The coalesce is to handle the case when JSONExtract returns null due to the value being a string.
return SqlString.format(
"toJSONString(?) = toJSONString(JSONExtract(?, 'Dynamic'))",
[SqlString.raw(valueExpr), value],
"toJSONString(?) = coalesce(toJSONString(JSONExtract(?, 'Dynamic')), toJSONString(?))",
[SqlString.raw(valueExpr), value, value],
);
default:

View file

@ -127,10 +127,7 @@ export const useSearchPageFilterState = ({
const [filters, setFilters] = React.useState<FilterState>({});
React.useEffect(() => {
if (
!areFiltersEqual(filters, parsedQuery.filters) &&
Object.values(parsedQuery.filters).length > 0
) {
if (!areFiltersEqual(filters, parsedQuery.filters)) {
setFilters(parsedQuery.filters);
}
// only react to changes in parsed query

View file

@ -0,0 +1,480 @@
import { SourceKind, TSource } from '@hyperdx/common-utils/dist/types';
import { getHighlightedAttributesFromData } from '../highlightedAttributes';
describe('getHighlightedAttributesFromData', () => {
const createBasicSource = (
highlightedTraceAttributeExpressions: any[] = [],
): TSource => ({
kind: SourceKind.Trace,
from: {
databaseName: 'default',
tableName: 'otel_traces',
},
timestampValueExpression: 'Timestamp',
connection: 'test-connection',
name: 'Traces',
highlightedTraceAttributeExpressions,
id: 'test-source-id',
});
const basicMeta = [
{ name: 'Body', type: 'String' },
{ name: 'Timestamp', type: 'DateTime64(9)' },
{ name: 'method', type: 'String' },
];
it('extracts attributes from data correctly', () => {
const data: Record<string, string | number | object>[] = [
{
Body: 'POST',
Timestamp: '2025-11-12T21:27:00.053000000Z',
SpanId: 'a51d12055f2058b9',
ServiceName: 'hdx-oss-dev-api',
method: 'POST',
"SpanAttributes['http.host']": 'localhost:8123',
Duration: 0.020954166,
ParentSpanId: '013cca18a6e626a6',
StatusCode: 'Unset',
SpanAttributes: {
'http.flavor': '1.1',
'http.host': 'localhost:8123',
'http.method': 'POST',
},
type: 'trace',
},
{
Body: 'POST',
Timestamp: '2025-11-12T21:27:00.053000000Z',
SpanId: 'a51d12055f2058b9',
ServiceName: 'hdx-oss-dev-api',
method: 'GET',
"SpanAttributes['http.host']": 'localhost:8123',
Duration: 0.020954166,
ParentSpanId: '013cca18a6e626a6',
StatusCode: 'Unset',
SpanAttributes: {
'http.flavor': '1.1',
'http.host': 'localhost:8123',
'http.method': 'POST',
},
type: 'trace',
},
];
const meta = [
{
name: 'Body',
type: 'LowCardinality(String)',
},
{
name: 'Timestamp',
type: 'DateTime64(9)',
},
{
name: 'SpanId',
type: 'String',
},
{
name: 'ServiceName',
type: 'LowCardinality(String)',
},
{
name: 'method',
type: 'String',
},
{
name: "SpanAttributes['http.host']",
type: 'String',
},
{
name: 'Duration',
type: 'Float64',
},
{
name: 'ParentSpanId',
type: 'String',
},
{
name: 'StatusCode',
type: 'LowCardinality(String)',
},
{
name: 'SpanAttributes',
type: 'Map(LowCardinality(String), String)',
},
];
const source: TSource = {
kind: SourceKind.Trace,
from: {
databaseName: 'default',
tableName: 'otel_traces',
},
timestampValueExpression: 'Timestamp',
connection: '68dd82484f54641b08667893',
name: 'Traces',
displayedTimestampValueExpression: 'Timestamp',
implicitColumnExpression: 'SpanName',
serviceNameExpression: 'ServiceName',
bodyExpression: 'SpanName',
eventAttributesExpression: 'SpanAttributes',
resourceAttributesExpression: 'ResourceAttributes',
defaultTableSelectExpression:
'Timestamp,ServiceName,StatusCode,round(Duration/1e6),SpanName',
traceIdExpression: 'TraceId',
spanIdExpression: 'SpanId',
durationExpression: 'Duration',
durationPrecision: 9,
parentSpanIdExpression: 'ParentSpanId',
spanNameExpression: 'SpanName',
spanKindExpression: 'SpanKind',
statusCodeExpression: 'StatusCode',
statusMessageExpression: 'StatusMessage',
sessionSourceId: '68dd82484f54641b0866789e',
logSourceId: '6900eed982d3b3dfeff12a29',
highlightedTraceAttributeExpressions: [
{
sqlExpression: "SpanAttributes['http.method']",
alias: 'method',
},
{
sqlExpression: "SpanAttributes['http.host']",
luceneExpression: 'SpanAttributes.http.host',
alias: '',
},
],
id: '68dd82484f54641b08667899',
};
const attributes = getHighlightedAttributesFromData(
source,
source.highlightedTraceAttributeExpressions,
data,
meta,
);
expect(attributes).toHaveLength(3);
expect(attributes).toContainEqual({
sql: "SpanAttributes['http.method']",
displayedKey: 'method',
value: 'POST',
source,
});
expect(attributes).toContainEqual({
sql: "SpanAttributes['http.method']",
displayedKey: 'method',
value: 'GET',
source,
});
expect(attributes).toContainEqual({
sql: "SpanAttributes['http.host']",
displayedKey: "SpanAttributes['http.host']",
value: 'localhost:8123',
lucene: 'SpanAttributes.http.host',
source,
});
});
it('returns empty array when data is empty', () => {
const source = createBasicSource([
{ sqlExpression: 'method', alias: 'method' },
]);
const attributes = getHighlightedAttributesFromData(
source,
source.highlightedTraceAttributeExpressions,
[],
basicMeta,
);
expect(attributes).toEqual([]);
});
it('returns empty array when highlightedTraceAttributeExpressions is undefined', () => {
const source = createBasicSource();
const data = [{ method: 'POST' }];
const attributes = getHighlightedAttributesFromData(
source,
source.highlightedTraceAttributeExpressions,
data,
basicMeta,
);
expect(attributes).toEqual([]);
});
it('returns empty array when highlightedTraceAttributeExpressions is empty', () => {
const source = createBasicSource([]);
const data = [{ method: 'POST' }];
const attributes = getHighlightedAttributesFromData(
source,
source.highlightedTraceAttributeExpressions,
data,
basicMeta,
);
expect(attributes).toEqual([]);
});
it('filters out non-string values', () => {
const source = createBasicSource([
{ sqlExpression: 'method', alias: 'method' },
{ sqlExpression: 'count', alias: 'count' },
{ sqlExpression: 'isActive', alias: 'isActive' },
]);
const data = [
{
method: 'POST', // string - should be included
count: 123, // number - should be filtered out
isActive: true, // boolean - should be filtered out
},
];
const meta = [
{ name: 'method', type: 'String' },
{ name: 'count', type: 'Int32' },
{ name: 'isActive', type: 'Bool' },
];
const attributes = getHighlightedAttributesFromData(
source,
source.highlightedTraceAttributeExpressions,
data,
meta,
);
expect(attributes).toHaveLength(1);
expect(attributes[0]).toEqual({
displayedKey: 'method',
value: 'POST',
sql: 'method',
lucene: undefined,
source,
});
});
it('deduplicates values from multiple rows', () => {
const source = createBasicSource([
{ sqlExpression: 'method', alias: 'method' },
]);
const data = [
{ method: 'POST' },
{ method: 'POST' }, // duplicate
{ method: 'GET' },
{ method: 'POST' }, // duplicate
];
const attributes = getHighlightedAttributesFromData(
source,
source.highlightedTraceAttributeExpressions,
data,
basicMeta,
);
expect(attributes).toHaveLength(2);
expect(attributes.map(a => a.value).sort()).toEqual(['GET', 'POST']);
});
it('uses sqlExpression as displayedKey when alias is empty string', () => {
const source = createBasicSource([
{ sqlExpression: "SpanAttributes['http.host']", alias: '' },
]);
const data = [{ "SpanAttributes['http.host']": 'localhost:8080' }];
const meta = [
{ name: "SpanAttributes['http.host']", type: 'String' },
{ name: 'SpanAttributes', type: 'JSON' },
];
const attributes = getHighlightedAttributesFromData(
source,
source.highlightedTraceAttributeExpressions,
data,
meta,
);
expect(attributes).toHaveLength(1);
expect(attributes[0].displayedKey).toBe("SpanAttributes['http.host']");
});
it('uses sqlExpression as displayedKey when alias is not provided', () => {
const source = createBasicSource([
{ sqlExpression: 'ServiceName' } as any, // No alias
]);
const data = [{ ServiceName: 'api-service' }];
const meta = [{ name: 'ServiceName', type: 'String' }];
const attributes = getHighlightedAttributesFromData(
source,
source.highlightedTraceAttributeExpressions,
data,
meta,
);
expect(attributes).toHaveLength(1);
expect(attributes[0].displayedKey).toBe('ServiceName');
});
it('includes lucene expression when provided', () => {
const source = createBasicSource([
{
sqlExpression: "SpanAttributes['http.method']",
alias: 'method',
luceneExpression: 'http.method',
},
]);
const data = [{ method: 'POST' }];
const attributes = getHighlightedAttributesFromData(
source,
source.highlightedTraceAttributeExpressions,
data,
basicMeta,
);
expect(attributes).toHaveLength(1);
expect(attributes[0].lucene).toBe('http.method');
});
it('omits lucene when not provided', () => {
const source = createBasicSource([
{ sqlExpression: 'method', alias: 'method' },
]);
const data = [{ method: 'POST' }];
const attributes = getHighlightedAttributesFromData(
source,
source.highlightedTraceAttributeExpressions,
data,
basicMeta,
);
expect(attributes).toHaveLength(1);
expect(attributes[0].lucene).toBeUndefined();
});
it('handles multiple attributes with different values', () => {
const source = createBasicSource([
{ sqlExpression: 'method', alias: 'method' },
{ sqlExpression: 'status', alias: 'status' },
]);
const data = [
{ method: 'POST', status: '200' },
{ method: 'GET', status: '404' },
];
const meta = [
{ name: 'method', type: 'String' },
{ name: 'status', type: 'String' },
];
const attributes = getHighlightedAttributesFromData(
source,
source.highlightedTraceAttributeExpressions,
data,
meta,
);
expect(attributes).toHaveLength(4);
expect(
attributes.filter(a => a.displayedKey === 'method').map(a => a.value),
).toEqual(['POST', 'GET']);
expect(
attributes.filter(a => a.displayedKey === 'status').map(a => a.value),
).toEqual(['200', '404']);
});
it('ignores rows with null attribute values', () => {
const source = createBasicSource([
{ sqlExpression: 'method', alias: 'method' },
]);
const data = [{ method: 'POST' }, { method: null }, { method: 'GET' }];
const attributes = getHighlightedAttributesFromData(
source,
source.highlightedTraceAttributeExpressions,
data,
basicMeta,
);
expect(attributes).toHaveLength(2);
expect(attributes.map(a => a.value).sort()).toEqual(['GET', 'POST']);
});
it('ignores rows with undefined attribute values', () => {
const source = createBasicSource([
{ sqlExpression: 'method', alias: 'method' },
]);
const data = [{ method: 'POST' }, { method: undefined }, { method: 'GET' }];
const attributes = getHighlightedAttributesFromData(
source,
source.highlightedTraceAttributeExpressions,
data,
basicMeta,
);
expect(attributes).toHaveLength(2);
expect(attributes.map(a => a.value).sort()).toEqual(['GET', 'POST']);
});
it('ignores rows with empty string values', () => {
const source = createBasicSource([
{ sqlExpression: 'method', alias: 'method' },
]);
const data = [{ method: 'POST' }, { method: '' }, { method: 'GET' }];
const attributes = getHighlightedAttributesFromData(
source,
source.highlightedTraceAttributeExpressions,
data,
basicMeta,
);
expect(attributes).toHaveLength(2);
expect(attributes.map(a => a.value).sort()).toEqual(['GET', 'POST']);
});
it('handles errors gracefully and returns empty array', () => {
// Create a source that will cause an error during iteration
const source = createBasicSource([
{ sqlExpression: 'method', alias: 'method' },
]);
// Mock console.error to suppress error output during test
const consoleErrorSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => {});
// Create data that throws when accessed
const data = [
Object.create(
{},
{
method: {
get() {
throw new Error('Test error');
},
},
},
),
];
const attributes = getHighlightedAttributesFromData(
source,
source.highlightedTraceAttributeExpressions,
data,
basicMeta,
);
expect(attributes).toEqual([]);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error extracting attributes from data',
expect.any(Error),
);
consoleErrorSpy.mockRestore();
});
it('includes source reference in each attribute', () => {
const source = createBasicSource([
{ sqlExpression: 'method', alias: 'method' },
]);
const data = [{ method: 'POST' }, { method: 'GET' }];
const attributes = getHighlightedAttributesFromData(
source,
source.highlightedTraceAttributeExpressions,
data,
basicMeta,
);
expect(attributes).toHaveLength(2);
attributes.forEach(attr => {
expect(attr.source).toBe(source);
});
});
});

View file

@ -0,0 +1,69 @@
import { ResponseJSON } from '@hyperdx/common-utils/dist/clickhouse';
import { TSource } from '@hyperdx/common-utils/dist/types';
import { getJSONColumnNames } from '@/components/DBRowDataPanel';
export function getSelectExpressionsForHighlightedAttributes(
expressions: TSource['highlightedTraceAttributeExpressions'] = [],
) {
return expressions.map(({ sqlExpression, alias }) => ({
valueExpression: sqlExpression,
alias: alias || sqlExpression,
}));
}
export function getHighlightedAttributesFromData(
source: TSource,
attributes: TSource['highlightedTraceAttributeExpressions'] = [],
data: Record<string, unknown>[],
meta: ResponseJSON['meta'],
) {
const attributeValuesByDisplayKey = new Map<string, Set<string>>();
const sqlExpressionsByDisplayKey = new Map<string, string>();
const luceneExpressionsByDisplayKey = new Map<string, string>();
const jsonColumns = getJSONColumnNames(meta);
try {
for (const row of data) {
for (const { sqlExpression, luceneExpression, alias } of attributes) {
const displayName = alias || sqlExpression;
const isJsonExpression = jsonColumns.includes(
sqlExpression.split('.')[0],
);
const sqlExpressionWithJSONSupport = isJsonExpression
? `toString(${sqlExpression})`
: sqlExpression;
sqlExpressionsByDisplayKey.set(
displayName,
sqlExpressionWithJSONSupport,
);
if (luceneExpression) {
luceneExpressionsByDisplayKey.set(displayName, luceneExpression);
}
const value = row[displayName];
if (value && typeof value === 'string') {
if (!attributeValuesByDisplayKey.has(displayName)) {
attributeValuesByDisplayKey.set(displayName, new Set());
}
attributeValuesByDisplayKey.get(displayName)!.add(value);
}
}
}
} catch (e) {
console.error('Error extracting attributes from data', e);
}
return Array.from(attributeValuesByDisplayKey.entries()).flatMap(
([key, values]) =>
[...values].map(value => ({
displayedKey: key,
value,
sql: sqlExpressionsByDisplayKey.get(key)!,
lucene: luceneExpressionsByDisplayKey.get(key),
source,
})),
);
}

View file

@ -548,6 +548,14 @@ const RequiredTimestampColumnSchema = z
.string()
.min(1, 'Timestamp Column is required');
const HighlightedAttributeExpressionsSchema = z.array(
z.object({
sqlExpression: z.string().min(1, 'Attribute SQL Expression is required'),
luceneExpression: z.string().optional(),
alias: z.string().optional(),
}),
);
// Log source form schema
const LogSourceAugmentation = {
kind: z.literal(SourceKind.Log),
@ -570,6 +578,8 @@ const LogSourceAugmentation = {
implicitColumnExpression: z.string().optional(),
uniqueRowIdExpression: z.string().optional(),
tableFilterExpression: z.string().optional(),
highlightedTraceAttributeExpressions:
HighlightedAttributeExpressionsSchema.optional(),
};
// Trace source form schema
@ -600,6 +610,8 @@ const TraceSourceAugmentation = {
eventAttributesExpression: z.string().optional(),
spanEventsValueExpression: z.string().optional(),
implicitColumnExpression: z.string().optional(),
highlightedTraceAttributeExpressions:
HighlightedAttributeExpressionsSchema.optional(),
};
// Session source form schema