Skip to content

Commit bd344b3

Browse files
committed
(fix): add support for JSON columns in the service dashboard
1 parent c98057d commit bd344b3

9 files changed

+231
-28
lines changed

packages/app/src/ServicesDashboardPage.tsx

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import { SQLInlineEditorControlled } from '@/components/SQLInlineEditor';
4343
import { TimePicker } from '@/components/TimePicker';
4444
import WhereLanguageControlled from '@/components/WhereLanguageControlled';
4545
import { useQueriedChartConfig } from '@/hooks/useChartConfig';
46+
import { useJsonColumns } from '@/hooks/useMetadata';
4647
import { withAppNav } from '@/layout';
4748
import SearchInputV2 from '@/SearchInputV2';
4849
import { getExpressions } from '@/serviceDashboard';
@@ -90,7 +91,12 @@ function ServiceSelectControlled({
9091
onCreate?: () => void;
9192
} & UseControllerProps<any>) {
9293
const { data: source } = useSource({ id: sourceId });
93-
const expressions = getExpressions(source);
94+
const { data: jsonColumns = [] } = useJsonColumns({
95+
databaseName: source?.from?.databaseName || '',
96+
tableName: source?.from?.tableName || '',
97+
connectionId: source?.connection || '',
98+
});
99+
const expressions = getExpressions(source, jsonColumns);
94100

95101
const queriedConfig = {
96102
...source,
@@ -153,7 +159,12 @@ export function EndpointLatencyChart({
153159
appliedConfig?: AppliedConfig;
154160
extraFilters?: Filter[];
155161
}) {
156-
const expressions = getExpressions(source);
162+
const { data: jsonColumns = [] } = useJsonColumns({
163+
databaseName: source?.from?.databaseName || '',
164+
tableName: source?.from?.tableName || '',
165+
connectionId: source?.connection || '',
166+
});
167+
const expressions = getExpressions(source, jsonColumns);
157168
const [latencyChartType, setLatencyChartType] = useState<
158169
'line' | 'histogram'
159170
>('line');
@@ -259,7 +270,12 @@ function HttpTab({
259270
appliedConfig: AppliedConfig;
260271
}) {
261272
const { data: source } = useSource({ id: appliedConfig.source });
262-
const expressions = getExpressions(source);
273+
const { data: jsonColumns = [] } = useJsonColumns({
274+
databaseName: source?.from?.databaseName || '',
275+
tableName: source?.from?.tableName || '',
276+
connectionId: source?.connection || '',
277+
});
278+
const expressions = getExpressions(source, jsonColumns);
263279

264280
const [reqChartType, setReqChartType] = useQueryState(
265281
'reqChartType',
@@ -529,7 +545,12 @@ function DatabaseTab({
529545
appliedConfig: AppliedConfig;
530546
}) {
531547
const { data: source } = useSource({ id: appliedConfig.source });
532-
const expressions = getExpressions(source);
548+
const { data: jsonColumns = [] } = useJsonColumns({
549+
databaseName: source?.from?.databaseName || '',
550+
tableName: source?.from?.tableName || '',
551+
connectionId: source?.connection || '',
552+
});
553+
const expressions = getExpressions(source, jsonColumns);
533554

534555
const [chartType, setChartType] = useState<'table' | 'list'>('list');
535556

@@ -776,7 +797,12 @@ function ErrorsTab({
776797
appliedConfig: AppliedConfig;
777798
}) {
778799
const { data: source } = useSource({ id: appliedConfig.source });
779-
const expressions = getExpressions(source);
800+
const { data: jsonColumns = [] } = useJsonColumns({
801+
databaseName: source?.from?.databaseName || '',
802+
tableName: source?.from?.tableName || '',
803+
connectionId: source?.connection || '',
804+
});
805+
const expressions = getExpressions(source, jsonColumns);
780806

781807
return (
782808
<Grid mt="md" grow={false} w="100%" maw="100%" overflow="hidden">
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import type { TSource } from '@hyperdx/common-utils/dist/types';
2+
import { SourceKind } from '@hyperdx/common-utils/dist/types';
3+
4+
import { getExpressions } from '../serviceDashboard';
5+
6+
describe('Service Dashboard', () => {
7+
const mockSource: TSource = {
8+
id: 'test-source',
9+
name: 'Test Source',
10+
kind: SourceKind.Trace,
11+
from: {
12+
databaseName: 'test_db',
13+
tableName: 'otel_traces_json',
14+
},
15+
connection: 'test-connection',
16+
timestampValueExpression: 'Timestamp',
17+
durationExpression: 'Duration',
18+
durationPrecision: 9,
19+
traceIdExpression: 'TraceId',
20+
serviceNameExpression: 'ServiceName',
21+
spanNameExpression: 'SpanName',
22+
spanKindExpression: 'SpanKind',
23+
severityTextExpression: 'StatusCode',
24+
};
25+
26+
describe('getExpressions', () => {
27+
it('should use map syntax for non-JSON columns by default', () => {
28+
const expressions = getExpressions(mockSource, []);
29+
30+
expect(expressions.k8sResourceName).toBe(
31+
"SpanAttributes['k8s.resource.name']",
32+
);
33+
expect(expressions.k8sPodName).toBe("SpanAttributes['k8s.pod.name']");
34+
expect(expressions.httpScheme).toBe("SpanAttributes['http.scheme']");
35+
expect(expressions.serverAddress).toBe(
36+
"SpanAttributes['server.address']",
37+
);
38+
expect(expressions.httpHost).toBe("SpanAttributes['http.host']");
39+
expect(expressions.dbStatement).toBe(
40+
"coalesce(nullif(SpanAttributes['db.query.text'], ''), nullif(SpanAttributes['db.statement'], ''))",
41+
);
42+
});
43+
44+
it('should use backtick syntax when SpanAttributes is a JSON column', () => {
45+
const expressions = getExpressions(mockSource, ['SpanAttributes']);
46+
47+
expect(expressions.k8sResourceName).toBe(
48+
'SpanAttributes.`k8s.resource.name`',
49+
);
50+
expect(expressions.k8sPodName).toBe('SpanAttributes.`k8s.pod.name`');
51+
expect(expressions.httpScheme).toBe('SpanAttributes.`http.scheme`');
52+
expect(expressions.serverAddress).toBe('SpanAttributes.`server.address`');
53+
expect(expressions.httpHost).toBe('SpanAttributes.`http.host`');
54+
expect(expressions.dbStatement).toBe(
55+
"coalesce(nullif(SpanAttributes.`db.query.text`, ''), nullif(SpanAttributes.`db.statement`, ''))",
56+
);
57+
});
58+
59+
it('should work with empty jsonColumns array', () => {
60+
const expressions = getExpressions(mockSource);
61+
62+
// Should default to map syntax
63+
expect(expressions.k8sResourceName).toBe(
64+
"SpanAttributes['k8s.resource.name']",
65+
);
66+
});
67+
});
68+
});

packages/app/src/components/ServiceDashboardDbQuerySidePanel.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { ChartBox } from '@/components/ChartBox';
99
import { DBTimeChart } from '@/components/DBTimeChart';
1010
import { DrawerBody, DrawerHeader } from '@/components/DrawerUtils';
1111
import SlowestEventsTile from '@/components/ServiceDashboardSlowestEventsTile';
12+
import { useJsonColumns } from '@/hooks/useMetadata';
1213
import { getExpressions } from '@/serviceDashboard';
1314
import { useSource } from '@/source';
1415
import { useZIndex, ZIndexContext } from '@/zIndex';
@@ -26,7 +27,12 @@ export default function ServiceDashboardDbQuerySidePanel({
2627
searchedTimeRange: [Date, Date];
2728
}) {
2829
const { data: source } = useSource({ id: sourceId });
29-
const expressions = getExpressions(source);
30+
const { data: jsonColumns = [] } = useJsonColumns({
31+
databaseName: source?.from?.databaseName || '',
32+
tableName: source?.from?.tableName || '',
33+
connectionId: source?.connection || '',
34+
});
35+
const expressions = getExpressions(source, jsonColumns);
3036

3137
const [dbQuery, setDbQuery] = useQueryState('dbquery', parseAsString);
3238
const onClose = useCallback(() => {

packages/app/src/components/ServiceDashboardEndpointPerformanceChart.tsx

Lines changed: 45 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Group, Text } from '@mantine/core';
44
import { MS_NUMBER_FORMAT } from '@/ChartUtils';
55
import { ChartBox } from '@/components/ChartBox';
66
import DBListBarChart from '@/components/DBListBarChart';
7+
import { useJsonColumns } from '@/hooks/useMetadata';
78
import { getExpressions } from '@/serviceDashboard';
89

910
const MAX_NUM_GROUPS = 200;
@@ -19,7 +20,12 @@ export default function ServiceDashboardEndpointPerformanceChart({
1920
service?: string;
2021
endpoint?: string;
2122
}) {
22-
const expressions = getExpressions(source);
23+
const { data: jsonColumns = [] } = useJsonColumns({
24+
databaseName: source?.from?.databaseName || '',
25+
tableName: source?.from?.tableName || '',
26+
connectionId: source?.connection || '',
27+
});
28+
const expressions = getExpressions(source, jsonColumns);
2329

2430
if (!source) {
2531
return null;
@@ -42,6 +48,43 @@ export default function ServiceDashboardEndpointPerformanceChart({
4248
WHERE ${parentSpanWhereCondition}
4349
`;
4450

51+
let spanNameColSql = `
52+
concat(
53+
${expressions.spanName}, ' ',
54+
if(
55+
has(['HTTP DELETE', 'DELETE', 'HTTP GET', 'GET', 'HTTP HEAD', 'HEAD', 'HTTP OPTIONS', 'OPTIONS', 'HTTP PATCH', 'PATCH', 'HTTP POST', 'POST', 'HTTP PUT', 'PUT'], ${expressions.spanName}),
56+
COALESCE(
57+
NULLIF(${expressions.serverAddress}, ''),
58+
NULLIF(${expressions.httpHost}, '')
59+
),
60+
''
61+
));`;
62+
63+
const spanAttributesExpression =
64+
source.eventAttributesExpression || 'SpanAttributes';
65+
66+
// ClickHouse does not support NULLIF(some_dynamic_column)
67+
// so we instead use toString() and an empty string check to check for
68+
// existence of the serverAddress/httpHost to build the span name
69+
if (jsonColumns.includes(spanAttributesExpression)) {
70+
spanNameColSql = `
71+
concat(
72+
${expressions.spanName}, ' ',
73+
if(
74+
has(['HTTP DELETE', 'DELETE', 'HTTP GET', 'GET', 'HTTP HEAD', 'HEAD', 'HTTP OPTIONS', 'OPTIONS', 'HTTP PATCH', 'PATCH', 'HTTP POST', 'POST', 'HTTP PUT', 'PUT'], ${expressions.spanName}),
75+
if(
76+
toString(${expressions.serverAddress}) != '',
77+
toString(${expressions.serverAddress}),
78+
if(
79+
toString(${expressions.httpHost}) != '',
80+
toString(${expressions.httpHost}),
81+
''
82+
)
83+
),
84+
''
85+
))`;
86+
}
87+
4588
return (
4689
<ChartBox style={{ height: 350, overflow: 'auto' }}>
4790
<Group justify="space-between" align="center" mb="sm">
@@ -60,16 +103,7 @@ export default function ServiceDashboardEndpointPerformanceChart({
60103
select: [
61104
{
62105
alias: 'group',
63-
valueExpression: `concat(
64-
${expressions.spanName}, ' ',
65-
if(
66-
has(['HTTP DELETE', 'DELETE', 'HTTP GET', 'GET', 'HTTP HEAD', 'HEAD', 'HTTP OPTIONS', 'OPTIONS', 'HTTP PATCH', 'PATCH', 'HTTP POST', 'POST', 'HTTP PUT', 'PUT'], ${expressions.spanName}),
67-
COALESCE(
68-
NULLIF(${expressions.serverAddress}, ''),
69-
NULLIF(${expressions.httpHost}, '')
70-
),
71-
''
72-
))`,
106+
valueExpression: spanNameColSql,
73107
},
74108
{
75109
alias: 'Total Time Spent',

packages/app/src/components/ServiceDashboardEndpointSidePanel.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { DBTimeChart } from '@/components/DBTimeChart';
1313
import { DrawerBody, DrawerHeader } from '@/components/DrawerUtils';
1414
import ServiceDashboardEndpointPerformanceChart from '@/components/ServiceDashboardEndpointPerformanceChart';
1515
import SlowestEventsTile from '@/components/ServiceDashboardSlowestEventsTile';
16+
import { useJsonColumns } from '@/hooks/useMetadata';
1617
import { getExpressions } from '@/serviceDashboard';
1718
import { EndpointLatencyChart } from '@/ServicesDashboardPage';
1819
import { useSource } from '@/source';
@@ -31,7 +32,12 @@ export default function ServiceDashboardEndpointSidePanel({
3132
searchedTimeRange: [Date, Date];
3233
}) {
3334
const { data: source } = useSource({ id: sourceId });
34-
const expressions = getExpressions(source);
35+
const { data: jsonColumns = [] } = useJsonColumns({
36+
databaseName: source?.from?.databaseName || '',
37+
tableName: source?.from?.tableName || '',
38+
connectionId: source?.connection || '',
39+
});
40+
const expressions = getExpressions(source, jsonColumns);
3541

3642
const [endpoint, setEndpoint] = useQueryState('endpoint', parseAsString);
3743
const onClose = useCallback(() => {

packages/app/src/components/ServiceDashboardSlowestEventsTile.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { ChartBox } from '@/components/ChartBox';
88
import DBRowSidePanel from '@/components/DBRowSidePanel';
99
import { DBSqlRowTable } from '@/components/DBRowTable';
1010
import { useQueriedChartConfig } from '@/hooks/useChartConfig';
11+
import { useJsonColumns } from '@/hooks/useMetadata';
1112
import { getExpressions } from '@/serviceDashboard';
1213
import { useSource } from '@/source';
1314

@@ -30,7 +31,12 @@ export default function SlowestEventsTile({
3031
enabled?: boolean;
3132
extraFilters?: Filter[];
3233
}) {
33-
const expressions = getExpressions(source);
34+
const { data: jsonColumns = [] } = useJsonColumns({
35+
databaseName: source?.from?.databaseName || '',
36+
tableName: source?.from?.tableName || '',
37+
connectionId: source?.connection || '',
38+
});
39+
const expressions = getExpressions(source, jsonColumns);
3440

3541
const [rowId, setRowId] = useQueryState('rowId', parseAsString);
3642
const [rowSource, setRowSource] = useQueryState('rowSource', parseAsString);

packages/app/src/hooks/useMetadata.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,33 @@ export function useColumns(
4242
});
4343
}
4444

45+
export function useJsonColumns(
46+
{
47+
databaseName,
48+
tableName,
49+
connectionId,
50+
}: {
51+
databaseName: string;
52+
tableName: string;
53+
connectionId: string;
54+
},
55+
options?: Partial<UseQueryOptions<string[]>>,
56+
) {
57+
return useQuery<string[]>({
58+
queryKey: ['useMetadata.useJsonColumns', { databaseName, tableName }],
59+
queryFn: async () => {
60+
const metadata = getMetadata();
61+
return metadata.getJsonColumns({
62+
databaseName,
63+
tableName,
64+
connectionId,
65+
});
66+
},
67+
enabled: !!databaseName && !!tableName && !!connectionId,
68+
...options,
69+
});
70+
}
71+
4572
export function useAllFields(
4673
_tableConnections: TableConnection | TableConnection[],
4774
options?: Partial<UseQueryOptions<Field[]>>,

packages/app/src/serviceDashboard.ts

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
11
import { TSource } from '@hyperdx/common-utils/dist/types';
22

3-
function getDefaults() {
3+
function getDefaults(jsonColumns: string[] = []) {
44
const spanAttributeField = 'SpanAttributes';
5+
const isJsonColumn = jsonColumns.includes(spanAttributeField);
6+
7+
// Helper function to format field access based on column type
8+
const formatFieldAccess = (field: string, key: string) => {
9+
if (isJsonColumn) {
10+
return `${field}.\`${key}\``;
11+
} else {
12+
return `${field}['${key}']`;
13+
}
14+
};
515

616
return {
717
duration: 'Duration',
@@ -11,17 +21,17 @@ function getDefaults() {
1121
spanName: 'SpanName',
1222
spanKind: 'SpanKind',
1323
severityText: 'StatusCode',
14-
k8sResourceName: `${spanAttributeField}['k8s.resource.name']`,
15-
k8sPodName: `${spanAttributeField}['k8s.pod.name']`,
16-
httpScheme: `${spanAttributeField}['http.scheme']`,
17-
serverAddress: `${spanAttributeField}['server.address']`,
18-
httpHost: `${spanAttributeField}['http.host']`,
19-
dbStatement: `coalesce(nullif(${spanAttributeField}['db.query.text'], ''), nullif(${spanAttributeField}['db.statement'], ''))`,
24+
k8sResourceName: formatFieldAccess(spanAttributeField, 'k8s.resource.name'),
25+
k8sPodName: formatFieldAccess(spanAttributeField, 'k8s.pod.name'),
26+
httpScheme: formatFieldAccess(spanAttributeField, 'http.scheme'),
27+
serverAddress: formatFieldAccess(spanAttributeField, 'server.address'),
28+
httpHost: formatFieldAccess(spanAttributeField, 'http.host'),
29+
dbStatement: `coalesce(nullif(${formatFieldAccess(spanAttributeField, 'db.query.text')}, ''), nullif(${formatFieldAccess(spanAttributeField, 'db.statement')}, ''))`,
2030
};
2131
}
2232

23-
export function getExpressions(source?: TSource) {
24-
const defaults = getDefaults();
33+
export function getExpressions(source?: TSource, jsonColumns: string[] = []) {
34+
const defaults = getDefaults(jsonColumns);
2535

2636
const fieldExpressions = {
2737
// General

0 commit comments

Comments
 (0)