fix: Fix Services Dashboard bugs (#1484)

Closes HDX-3033

# Summary

This PR fixes three bugs in the Services Dashboard

1. When using CTEs in chart configs, as we do on the HTTP and Databases tabs, there were frequent console errors as we tried to `DESCRIBE` the CTE names, to support the materialized columns optimization. With this PR, we no longer try to DESCRIBE CTEs, by skipping the materialized column optimization for configs without a `from.databaseName`.
2. Previously, the Request Throughput chart would reload whenever switching the Request Error Rate chart from `Overall` to `By Endpoint`. This was because the `displayType` in the Request Throughput chart was based on the toggle state, despite being unrelated. Now, the displayType of the Request Throughput chart is constant, eliminating the extra refetch.
3. Previously, when switching to the Services dashboard with a non-Trace Source ID in the URL params, the Services dashboard would initially be empty, then after toggling to a Trace Source, queries would briefly be issued against the non-Trace source (they would fail and/or be cancelled a moment later). Now, non-Trace sources are filtered out so that a Trace source is chosen as the default, and non-Trace sources are not queried.
4. Previously, we were spreading the entirety of `...source` into each config, which resulted in `metricTables` being in the config under particular circumstances (HDX-3035), which in turn caused errors from renderChartConfig. This has been fixed by `pick`ing only the fields we need from source.
This commit is contained in:
Drew Davis 2025-12-17 07:15:05 -05:00 committed by GitHub
parent 8241ffea89
commit b58c52eb41
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 169 additions and 68 deletions

View file

@ -0,0 +1,7 @@
---
"@hyperdx/common-utils": patch
"@hyperdx/api": patch
"@hyperdx/app": patch
---
fix: Fix bugs in the Services dashboard

View file

@ -145,7 +145,7 @@ describe('renderChartConfig', () => {
{ aggFn: 'sum', valueExpression: 'strVal' },
],
from: {
databaseName: '',
databaseName: DEFAULT_DATABASE,
tableName: `agg_fn_str_test`,
},
where: '',
@ -183,7 +183,7 @@ describe('renderChartConfig', () => {
{ aggFn: 'sum', valueExpression: 'strVal' },
],
from: {
databaseName: '',
databaseName: DEFAULT_DATABASE,
tableName: `agg_fn_default_test`,
},
where: '',

View file

@ -7,7 +7,7 @@ import {
useQueryState,
useQueryStates,
} from 'nuqs';
import { UseControllerProps, useForm } from 'react-hook-form';
import { UseControllerProps, useForm, useWatch } from 'react-hook-form';
import { tcFromSource } from '@hyperdx/common-utils/dist/core/metadata';
import { DEFAULT_AUTO_GRANULARITY_MAX_BUCKETS } from '@hyperdx/common-utils/dist/core/renderChartConfig';
import {
@ -128,7 +128,7 @@ function ServiceSelectControlled({
const { expressions } = useServiceDashboardExpressions({ source });
const queriedConfig = {
...source,
timestampValueExpression: source?.timestampValueExpression || '',
from: {
databaseName: source?.from.databaseName || '',
tableName: source?.from.tableName || '',
@ -240,7 +240,11 @@ export function EndpointLatencyChart({
'avg_duration_ns',
]}
config={{
...source,
...pick(source, [
'timestampValueExpression',
'connection',
'from',
]),
where: appliedConfig.where || '',
whereLanguage: appliedConfig.whereLanguage || 'sql',
select: [
@ -289,7 +293,11 @@ export function EndpointLatencyChart({
) : (
<DBHistogramChart
config={{
...source,
...pick(source, [
'timestampValueExpression',
'connection',
'from',
]),
where: appliedConfig.where || '',
whereLanguage: appliedConfig.whereLanguage || 'sql',
select: [
@ -343,7 +351,7 @@ function HttpTab({
if (!source || !expressions) return null;
if (reqChartType === 'overall') {
return {
...source,
...pick(source, ['timestampValueExpression', 'connection', 'from']),
where: appliedConfig.where || '',
whereLanguage: appliedConfig.whereLanguage || 'sql',
displayType: DisplayType.Line,
@ -539,13 +547,14 @@ function HttpTab({
<DBTimeChart
sourceId={source.id}
config={{
...source,
...pick(source, [
'timestampValueExpression',
'connection',
'from',
]),
where: appliedConfig.where || '',
whereLanguage: appliedConfig.whereLanguage || 'sql',
displayType:
reqChartType === 'overall'
? DisplayType.Line
: DisplayType.StackedBar,
displayType: DisplayType.Line,
select: [
{
aggFn: 'count' as const,
@ -582,7 +591,11 @@ function HttpTab({
'error_requests',
]}
config={{
...source,
...pick(source, [
'timestampValueExpression',
'connection',
'from',
]),
where: appliedConfig.where || '',
whereLanguage: appliedConfig.whereLanguage || 'sql',
select: [
@ -703,7 +716,11 @@ function HttpTab({
'error_count',
]}
config={{
...source,
...pick(source, [
'timestampValueExpression',
'connection',
'from',
]),
where: appliedConfig.where || '',
whereLanguage: appliedConfig.whereLanguage || 'sql',
select: [
@ -1122,7 +1139,11 @@ function DatabaseTab({
'p50_duration_ns',
]}
config={{
...source,
...pick(source, [
'timestampValueExpression',
'connection',
'from',
]),
where: appliedConfig.where || '',
whereLanguage: appliedConfig.whereLanguage || 'sql',
dateRange: searchedTimeRange,
@ -1198,7 +1219,11 @@ function DatabaseTab({
'p50_duration_ns',
]}
config={{
...source,
...pick(source, [
'timestampValueExpression',
'connection',
'from',
]),
where: appliedConfig.where || '',
whereLanguage: appliedConfig.whereLanguage || 'sql',
dateRange: searchedTimeRange,
@ -1292,7 +1317,11 @@ function ErrorsTab({
<DBTimeChart
sourceId={source.id}
config={{
...source,
...pick(source, [
'timestampValueExpression',
'connection',
'from',
]),
where: appliedConfig.where || '',
whereLanguage: appliedConfig.whereLanguage || 'sql',
displayType: DisplayType.StackedBar,
@ -1341,13 +1370,34 @@ function ServicesDashboardPage() {
const { data: sources } = useSources();
const [appliedConfig, setAppliedConfig] = useQueryStates(appliedConfigMap);
const [appliedConfigParams, setAppliedConfigParams] =
useQueryStates(appliedConfigMap);
// Only use the source from the URL params if it is a trace source
const appliedConfig = useMemo(() => {
if (!sources?.length) return appliedConfigParams;
const traceSources = sources?.filter(s => s.kind === SourceKind.Trace);
const paramsSourceIdIsTraceSource = traceSources?.find(
s => s.id === appliedConfigParams.source,
);
const effectiveSourceId = paramsSourceIdIsTraceSource
? appliedConfigParams.source
: traceSources?.[0]?.id || '';
return {
...appliedConfigParams,
source: effectiveSourceId,
};
}, [appliedConfigParams, sources]);
const { control, watch, setValue, handleSubmit } = useForm({
values: {
defaultValues: {
where: '',
whereLanguage: 'sql' as 'sql' | 'lucene',
service: appliedConfig?.service || '',
source: appliedConfig?.source || sources?.[0]?.id,
source: appliedConfig?.source ?? '',
},
});
@ -1357,11 +1407,19 @@ function ServicesDashboardPage() {
id: watch('source'),
});
// Update the `source` query parameter if the appliedConfig source changes
useEffect(() => {
if (sourceId && !appliedConfig.source) {
setAppliedConfig({ source: sourceId });
if (
appliedConfig.source &&
appliedConfig.source !== appliedConfigParams.source
) {
setAppliedConfigParams({ source: appliedConfig.source });
}
}, [appliedConfig.source, setAppliedConfig, sourceId]);
}, [
appliedConfig.source,
appliedConfigParams.source,
setAppliedConfigParams,
]);
const DEFAULT_INTERVAL = 'Past 1h';
const [displayedTimeInputValue, setDisplayedTimeInputValue] =
@ -1374,7 +1432,7 @@ function ServicesDashboardPage() {
});
// For future use if Live button is added
const [isLive, setIsLive] = useState(false);
const [isLive, _setIsLive] = useState(false);
const { manualRefreshCooloff, refresh } = useDashboardRefresh({
searchedTimeRange,
@ -1385,30 +1443,38 @@ function ServicesDashboardPage() {
const onSubmit = useCallback(() => {
onSearch(displayedTimeInputValue);
handleSubmit(values => {
setAppliedConfig(values);
setAppliedConfigParams(values);
})();
}, [handleSubmit, setAppliedConfig, onSearch, displayedTimeInputValue]);
}, [handleSubmit, setAppliedConfigParams, onSearch, displayedTimeInputValue]);
// Auto submit when service or source changes
// Auto-submit when source changes
useEffect(() => {
const normalizedService = service ?? '';
const appliedService = appliedConfig.service ?? '';
const normalizedSource = sourceId ?? '';
const appliedSource = appliedConfig.source ?? '';
const { unsubscribe } = watch((data, { name, type }) => {
if (
name === 'source' &&
type === 'change' &&
data.source &&
data.source !== appliedConfig.source
) {
onSubmit();
}
});
return () => unsubscribe();
}, [appliedConfig.source, onSubmit, watch]);
if (
normalizedService !== appliedService ||
(normalizedSource && normalizedSource !== appliedSource)
) {
onSubmit();
}
}, [
service,
sourceId,
appliedConfig.service,
appliedConfig.source,
onSubmit,
]);
// Auto-submit when service changes
useEffect(() => {
const { unsubscribe } = watch((data, { name, type }) => {
if (
name === 'service' &&
type === 'change' &&
data.service !== appliedConfig.service
) {
onSubmit();
}
});
return () => unsubscribe();
}, [appliedConfig.service, onSubmit, watch]);
return (
<Box p="sm">
@ -1554,7 +1620,7 @@ const ServicesDashboardPageDynamic = dynamic(
},
);
// @ts-ignore
// @ts-expect-error Next.js layout typing
ServicesDashboardPageDynamic.getLayout = withAppNav;
export default ServicesDashboardPageDynamic;

View file

@ -1,4 +1,5 @@
import { useCallback, useMemo } from 'react';
import { pick } from 'lodash';
import { parseAsString, useQueryState } from 'nuqs';
import type { Filter } from '@hyperdx/common-utils/dist/types';
import { Drawer, Grid, Group, Text } from '@mantine/core';
@ -97,7 +98,11 @@ export default function ServiceDashboardDbQuerySidePanel({
sourceId={sourceId}
hiddenSeries={['total_duration_ns']}
config={{
...source,
...pick(source, [
'timestampValueExpression',
'connection',
'from',
]),
where: '',
whereLanguage: 'sql',
select: [
@ -130,7 +135,11 @@ export default function ServiceDashboardDbQuerySidePanel({
<DBTimeChart
sourceId={sourceId}
config={{
...source,
...pick(source, [
'timestampValueExpression',
'connection',
'from',
]),
where: '',
whereLanguage: 'sql',
select: [

View file

@ -1,3 +1,4 @@
import { pick } from 'lodash';
import { TSource } from '@hyperdx/common-utils/dist/types';
import { Group, Text } from '@mantine/core';
@ -95,7 +96,7 @@ export default function ServiceDashboardEndpointPerformanceChart({
groupColumn="group"
valueColumn="Total Time Spent"
config={{
...source,
...pick(source, ['timestampValueExpression', 'connection', 'from']),
where: '',
whereLanguage: 'sql',
select: [

View file

@ -1,4 +1,5 @@
import { useCallback, useMemo } from 'react';
import { pick } from 'lodash';
import { parseAsString, useQueryState } from 'nuqs';
import type { Filter } from '@hyperdx/common-utils/dist/types';
import { Drawer, Grid, Group, Text } from '@mantine/core';
@ -104,7 +105,11 @@ export default function ServiceDashboardEndpointSidePanel({
sourceId={source.id}
hiddenSeries={['total_count', 'error_count']}
config={{
...source,
...pick(source, [
'timestampValueExpression',
'connection',
'from',
]),
where: '',
whereLanguage: 'sql',
select: [
@ -144,7 +149,11 @@ export default function ServiceDashboardEndpointSidePanel({
<DBTimeChart
sourceId={source.id}
config={{
...source,
...pick(source, [
'timestampValueExpression',
'connection',
'from',
]),
where: '',
whereLanguage: 'sql',
select: [

View file

@ -1,3 +1,4 @@
import { pick } from 'lodash';
import { ClickHouseQueryError } from '@hyperdx/common-utils/dist/clickhouse';
import type { Filter, TSource } from '@hyperdx/common-utils/dist/types';
import { Box, Code, Group, Text } from '@mantine/core';
@ -30,7 +31,7 @@ export default function SlowestEventsTile({
const { data, isLoading, isError, error } = useQueriedChartConfig(
{
...source,
...pick(source, ['timestampValueExpression', 'connection', 'from']),
where: '',
whereLanguage: 'sql',
select: [
@ -109,7 +110,11 @@ export default function SlowestEventsTile({
breadcrumbPath={[{ label: 'Endpoint' }]}
sourceId={source.id}
config={{
...source,
...pick(source, [
'timestampValueExpression',
'connection',
'from',
]),
where: '',
whereLanguage: 'sql',
select: [

View file

@ -381,14 +381,16 @@ async function renderSelectList(
// supported for queries using CTEs so skip the metadata fetch if there are CTE objects in the config.
let materializedFields: Map<string, string> | undefined;
try {
// This will likely error for a CTE
materializedFields = chartConfig.with?.length
? undefined
: await metadata.getMaterializedColumnsLookupTable({
connectionId: chartConfig.connection,
databaseName: chartConfig.from.databaseName,
tableName: chartConfig.from.tableName,
});
// This will likely error when referencing a CTE, which is assumed
// to be the case when chartConfig.from.databaseName is not set.
materializedFields =
chartConfig.with?.length || !chartConfig.from.databaseName
? undefined
: await metadata.getMaterializedColumnsLookupTable({
connectionId: chartConfig.connection,
databaseName: chartConfig.from.databaseName,
tableName: chartConfig.from.tableName,
});
} catch {
// ignore
}
@ -689,14 +691,16 @@ async function renderWhereExpression({
let materializedFields: Map<string, string> | undefined;
try {
// This will likely error for a CTE
materializedFields = withClauses?.length
? undefined
: await metadata.getMaterializedColumnsLookupTable({
connectionId,
databaseName: from.databaseName,
tableName: from.tableName,
});
// This will likely error when referencing a CTE, which is assumed
// to be the case when from.databaseName is not set.
materializedFields =
withClauses?.length || !from.databaseName
? undefined
: await metadata.getMaterializedColumnsLookupTable({
connectionId,
databaseName: from.databaseName,
tableName: from.tableName,
});
} catch {
// ignore
}