Compare commits

..

1 Commits

Author SHA1 Message Date
Srikanth Chekuri
4cb022dd06 fix(telemetrystore): return invalid input with a fix for three ClickHouse SQL mistakes (#13328)
#### Description

- ClickHouse errors 170 (`BAD_GET`), 288
(`DISTRIBUTED_IN_JOIN_SUBQUERY_DENIED`) and 407 (`DECIMAL_OVERFLOW`)
from raw SQL came back as 500s, though each is a fixable query mistake.
They now return invalid input with the fix as a suggestion.

#### Additional Information

- 170 and 407 and their fixes were verified on ClickHouse 25.12. 288
needs a multi-shard cluster, so its suggestion follows the
`distributed_product_mode` docs.
- Like the existing entries, these also apply to SQL SigNoz generates.
- Part of SigNoz/nerve-pod#465.
2026-11-04 05:12:37 +00:00
18 changed files with 72 additions and 130 deletions

View File

@@ -12,7 +12,7 @@ import { Button } from 'antd';
import logEvent from 'api/common/logEvent';
import cx from 'classnames';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { DateTimeRangeType } from 'container/TopNav/CustomDateTimeModal';
import { RelativeDurationSuggestionOptions } from 'container/TopNav/DateTimeSelectionV2/constants';
@@ -24,7 +24,6 @@ import dayjs from 'dayjs';
import { Clock, PenLine, TriangleAlert } from '@signozhq/icons';
import { useTimezone } from 'providers/Timezone';
import { getCustomTimeRanges } from 'utils/customTimeRangeUtils';
import { readPanelTypeFromUrl } from 'utils/readPanelTypeFromUrl';
import { TimeRangeValidationResult } from 'utils/timeUtils';
import CalendarContainer from './CalendarContainer';
@@ -122,14 +121,21 @@ function CustomTimePickerPopoverContent({
[pathname],
);
const url = new URLSearchParams(window.location.search);
const { timezone } = useTimezone();
const activeTimezoneOffset = timezone.offset;
const panelTypeFromURL = readPanelTypeFromUrl();
let panelTypeFromURL = url.get(QueryParams.panelTypes);
try {
panelTypeFromURL = JSON.parse(panelTypeFromURL as string);
} catch {
// fallback → leave as-is
}
const isLogsListView =
panelTypeFromURL !== PANEL_TYPES.TABLE &&
panelTypeFromURL !== PANEL_TYPES.TIME_SERIES; // we do not select list view in the url
panelTypeFromURL !== 'table' && panelTypeFromURL !== 'graph'; // we do not select list view in the url
const [dateRange, setDateRange] = useState<DateRange>(() =>
getDateRange(minTime, maxTime, timezone.value),

View File

@@ -140,6 +140,11 @@ export default function AlertRules({
encodeURIComponent(JSON.stringify(compositeQuery)),
);
const panelType = record.condition.compositeQuery.panelType;
if (panelType) {
params.set(QueryParams.panelTypes, panelType);
}
params.set(QueryParams.ruleId, record.id);
navigate(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`);

View File

@@ -26,7 +26,7 @@ describe('ListAlertRules — row click navigation', () => {
const [url] = safeNavigateMock.mock.calls[0];
expect(url).toContain('/alerts/overview?');
expect(url).toContain('ruleId=rule-1');
expect(url).not.toContain('panelTypes');
expect(url).toContain('panelTypes=graph');
expect(url).toContain('compositeQuery=');
});

View File

@@ -36,6 +36,11 @@ export function useAlertRulesHandlers(
encodeURIComponent(JSON.stringify(compositeQuery)),
);
const panelType = rule.condition.compositeQuery.panelType;
if (panelType) {
params.set(QueryParams.panelTypes, panelType);
}
params.set(QueryParams.ruleId, rule.id);
return `${ROUTES.ALERT_OVERVIEW}?${params.toString()}`;

View File

@@ -141,9 +141,6 @@ describe('useCreateAlerts', () => {
const composite = getCompositeQueryFromLastOpen();
expect(composite.queryType).toBe(EQueryType.PROM);
const [url] = (window.open as jest.Mock).mock.calls[0];
expect(url).not.toContain('panelTypes');
});
it('falls back to the mapped queryType when widget has no queryType', () => {

View File

@@ -83,6 +83,7 @@ const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(updatedQuery)),
);
params.set(QueryParams.panelTypes, widget.panelTypes);
params.set(QueryParams.version, ENTITY_VERSION_V5);
params.set(QueryParams.source, YAxisSource.DASHBOARDS);

View File

@@ -1,39 +0,0 @@
import { renderHook } from '@testing-library/react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { TestRouter } from 'tests/router';
import { useGetPanelTypesQueryParam } from './useGetPanelTypesQueryParam';
const renderWithSearch = (
search: string,
defaultPanelType?: PANEL_TYPES,
): PANEL_TYPES | null => {
const { result } = renderHook(
() => useGetPanelTypesQueryParam(defaultPanelType),
{
wrapper: ({ children }) => (
<TestRouter initialRoute={`/logs/logs-explorer${search}`}>
{children}
</TestRouter>
),
},
);
return result.current;
};
describe('useGetPanelTypesQueryParam', () => {
it.each([
['a JSON encoded panel type', '?panelTypes=%22table%22', PANEL_TYPES.TABLE],
['a plain string panel type', '?panelTypes=graph', PANEL_TYPES.TIME_SERIES],
['an unparseable panel type', '?panelTypes=%7Bfoo', PANEL_TYPES.LIST],
[
'a value that is not a panel type',
'?panelTypes=%22nope%22',
PANEL_TYPES.LIST,
],
['an absent param', '', PANEL_TYPES.LIST],
])('reads %s', (_, search, expected) => {
expect(renderWithSearch(search, PANEL_TYPES.LIST)).toBe(expected);
});
});

View File

@@ -2,7 +2,6 @@ import { useMemo } from 'react';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import useUrlQuery from 'hooks/useUrlQuery';
import { parsePanelType } from 'utils/parsePanelType';
export const useGetPanelTypesQueryParam = <T extends PANEL_TYPES | undefined>(
defaultPanelType?: T,
@@ -12,10 +11,6 @@ export const useGetPanelTypesQueryParam = <T extends PANEL_TYPES | undefined>(
return useMemo(() => {
const panelTypeQuery = urlQuery.get(QueryParams.panelTypes);
return (
(panelTypeQuery ? parsePanelType(panelTypeQuery) : null) ?? defaultPanelType
);
}, [urlQuery, defaultPanelType]) as T extends undefined
? PANEL_TYPES | null
: PANEL_TYPES;
return panelTypeQuery ? JSON.parse(panelTypeQuery) : defaultPanelType;
}, [urlQuery, defaultPanelType]);
};

View File

@@ -184,6 +184,7 @@ describe('useCreateAlertFromPanel', () => {
// The resolved query is seeded with the panel-derived alert prefill.
expect(mockBuildAlertUrl).toHaveBeenCalledWith(
{ resolved: 'query' },
PANEL_TYPES.TIME_SERIES,
undefined,
mockPrefill,
);

View File

@@ -88,6 +88,7 @@ export function useCreateAlertFromPanel(): (
const unit = readPanelUnit(panel.spec.plugin);
const url = buildAlertUrl(
query,
panelType,
unit,
deriveAlertPrefill(panel, query, unit),
);

View File

@@ -65,19 +65,14 @@ describe('buildCreateAlertUrl', () => {
);
});
it('tags the URL with the v5 version and the dashboards source', () => {
it('tags the URL with panel type, v5 version, and the dashboards source', () => {
const params = parse(buildCreateAlertUrl(makePanel()));
expect(params.get(QueryParams.panelTypes)).toBe(PANEL_TYPES.TIME_SERIES);
expect(params.get(QueryParams.version)).toBe(ENTITY_VERSION_V5);
expect(params.get(QueryParams.source)).toBe('dashboards');
});
it('does not tag the URL with a panel type, which the alert page ignores', () => {
const params = parse(buildCreateAlertUrl(makePanel()));
expect(params.get(QueryParams.panelTypes)).toBeNull();
});
it('encodes the translated query as the compositeQuery param', () => {
const params = parse(buildCreateAlertUrl(makePanel()));

View File

@@ -5,6 +5,7 @@ import type {
import { YAxisSource } from 'components/YAxisUnitSelector/types';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { toPanelType } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import {
@@ -33,6 +34,7 @@ export function readPanelUnit(
*/
export function buildAlertUrl(
query: Query,
panelType: PANEL_TYPES,
unit?: string,
prefill?: PanelAlertPrefill,
): string {
@@ -46,6 +48,7 @@ export function buildAlertUrl(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(query)),
);
params.set(QueryParams.panelTypes, panelType);
params.set(QueryParams.version, ENTITY_VERSION_V5);
params.set(QueryParams.source, YAxisSource.DASHBOARDS);
@@ -73,5 +76,10 @@ export function buildCreateAlertUrl(panel: DashboardtypesPanelDTO): string {
const panelType = toPanelType(panel.spec.plugin.kind);
const query = fromPerses(panel.spec.queries, panelType);
const unit = readPanelUnit(panel.spec.plugin);
return buildAlertUrl(query, unit, deriveAlertPrefill(panel, query, unit));
return buildAlertUrl(
query,
panelType,
unit,
deriveAlertPrefill(panel, query, unit),
);
}

View File

@@ -118,8 +118,12 @@ export function QueryBuilderProvider({
null,
);
const [panelType, setPanelType] = useState<PANEL_TYPES | null>(() =>
readPanelTypeFromUrl(),
const panelTypeQueryParams = getUnstableCurrentSearchParams().get(
QueryParams.panelTypes,
) as PANEL_TYPES | null;
const [panelType, setPanelType] = useState<PANEL_TYPES | null>(
panelTypeQueryParams,
);
const [currentQuery, setCurrentQuery] = useState<QueryState>(queryState);

View File

@@ -1,37 +0,0 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { AllTheProviders, renderHook } from 'tests/test-utils';
import {
__resetSearchParamsGetter,
__setSearchParamsGetterForTest,
} from 'utils/getUnstableCurrentSearchParams';
const renderWithPanelTypes = (value: string): PANEL_TYPES | null => {
__setSearchParamsGetterForTest(
() => new URLSearchParams({ panelTypes: value }),
);
const { result } = renderHook(() => useQueryBuilder(), {
wrapper: AllTheProviders,
});
return result.current.panelType;
};
describe('QueryBuilderProvider panel type seed', () => {
afterEach(() => {
__resetSearchParamsGetter();
});
it.each([
[
'a JSON encoded value',
JSON.stringify(PANEL_TYPES.TABLE),
PANEL_TYPES.TABLE,
],
['a plain string value', PANEL_TYPES.TABLE, PANEL_TYPES.TABLE],
['a malformed value', '{foo', null],
])('seeds from %s', (_, value, expected) => {
expect(renderWithPanelTypes(value)).toBe(expected);
});
});

View File

@@ -30,15 +30,11 @@ describe('readPanelTypeFromUrl', () => {
expect(readPanelTypeFromUrl()).toBe(PANEL_TYPES.LIST);
});
it.each(['{list', JSON.stringify('nope')])(
'returns the fallback for %s, which is not a panel type',
(value) => {
withParam(value);
it('returns the raw value instead of throwing on a malformed one', () => {
withParam('{list');
expect(readPanelTypeFromUrl(PANEL_TYPES.LIST)).toBe(PANEL_TYPES.LIST);
expect(readPanelTypeFromUrl()).toBeNull();
},
);
expect(readPanelTypeFromUrl()).toBe('{list');
});
it('returns the fallback when the param is absent', () => {
withParam();

View File

@@ -1,18 +0,0 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
const PANEL_TYPE_VALUES = new Set<string>(Object.values(PANEL_TYPES));
// The param is JSON encoded, but older alert links and bookmarks carry it as a plain string.
export const parsePanelType = (value: string): PANEL_TYPES | null => {
let parsed: unknown = value;
try {
parsed = JSON.parse(value);
} catch {
parsed = value;
}
return typeof parsed === 'string' && PANEL_TYPE_VALUES.has(parsed)
? (parsed as PANEL_TYPES)
: null;
};

View File

@@ -2,7 +2,6 @@ import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getUnstableCurrentSearchParams } from './getUnstableCurrentSearchParams';
import { parsePanelType } from './parsePanelType';
// The param is JSON-encoded, but a hand-written link can carry it unencoded.
export const readPanelTypeFromUrl = (
@@ -10,5 +9,13 @@ export const readPanelTypeFromUrl = (
): PANEL_TYPES | null => {
const raw = getUnstableCurrentSearchParams().get(QueryParams.panelTypes);
return (raw ? parsePanelType(raw) : null) ?? fallback;
if (!raw) {
return fallback;
}
try {
return JSON.parse(raw) as PANEL_TYPES;
} catch {
return raw as PANEL_TYPES;
}
};

View File

@@ -20,6 +20,9 @@ var (
ErrCodeUnknownIdentifier = errors.MustNewCode("unknown_identifier")
ErrCodeIllegalArgument = errors.MustNewCode("illegal_argument")
ErrCodeDistributedSubqueryDenied = errors.MustNewCode("distributed_subquery_denied")
ErrCodeDecimalOverflow = errors.MustNewCode("decimal_overflow")
ErrCodeQueryCanceled = errors.MustNewCode("query_canceled")
ErrCodeQueryTimeout = errors.MustNewCode("query_timeout")
ErrCodeExecutionFailed = errors.MustNewCode("execution_failed")
@@ -72,6 +75,18 @@ var clickHouseExceptionWrappers = map[chproto.Error]func(cause error, ex *clickh
chproto.ErrTooLessArgumentsForFunction: func(cause error, ex *clickhouse.Exception) error {
return errors.WrapInvalidInputf(cause, ErrCodeIllegalArgument, "too few arguments to function: %s", ex.Message)
},
chproto.ErrBadGet: func(cause error, ex *clickhouse.Exception) error {
return errors.WrapInvalidInputf(cause, ErrCodeIllegalArgument, "parameter of the wrong type: %s", ex.Message).
WithSuggestions("write numeric function parameters in the type the function expects, for example 1.0 rather than 1 where it takes a Float64")
},
chproto.ErrDistributedInJoinSubqueryDenied: func(cause error, ex *clickhouse.Exception) error {
return errors.WrapInvalidInputf(cause, ErrCodeDistributedSubqueryDenied, "IN or JOIN subquery over a distributed table is not allowed: %s", ex.Message).
WithSuggestions("use GLOBAL IN or GLOBAL JOIN for the subquery")
},
chproto.ErrDecimalOverflow: func(cause error, ex *clickhouse.Exception) error {
return errors.WrapInvalidInputf(cause, ErrCodeDecimalOverflow, "decimal overflow: %s", ex.Message).
WithSuggestions("cast the Decimal values to Float64, or to a wider Decimal such as Decimal128, before the arithmetic")
},
}
type provider struct {