fix: Add alias WITH clause support to alert WHERE queries (#1851)

## Summary
- When a saved search's SELECT contains aliases (e.g. `toString(Body) AS body`), the WHERE clause in alert queries may reference those aliases. Without WITH clauses defining them, the query fails.
- Added `computeAliasWithClauses` to discover aliases from the saved search's SELECT and inject them as WITH clauses into both the alert check query and the sample logs query.
- Moved the shared `aliasMapToWithClauses` utility from `packages/app` to `packages/common-utils/src/core/utils.ts` so it can be reused across frontend and backend, eliminating duplication with the existing logic in `useRowWhere.tsx`.
- Derived `WithClause` type from `ChartConfig['with']` instead of maintaining a separate type definition.

## Test plan
- [x] Verify alerts with saved searches that use aliased columns (e.g. `toString(Body) AS body`) in both SELECT and WHERE fire correctly
- [x] Verify alert sample logs in notifications render correctly for aliased columns
- [x] Verify existing alerts without aliases continue to work unchanged
- [x] Verify frontend row-click navigation still works (re-exported `aliasMapToWithClauses`)

Ref: HDX-3601
This commit is contained in:
Warren Lee 2026-03-05 20:18:45 +01:00 committed by GitHub
parent b4f0558776
commit 32d45f738a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 491 additions and 39 deletions

View file

@ -4149,6 +4149,317 @@ describe('checkAlerts', () => {
expect(slack.postMessageToWebhook).toHaveBeenCalledTimes(1);
expect((await Alert.findById(details.alert.id))!.state).toBe('ALERT');
});
it('SAVED_SEARCH alert with alias in select and where should trigger', async () => {
const team = await createTeam({ name: 'My Team' });
const webhook = await new Webhook({
team: team._id,
service: 'slack',
url: 'https://hooks.slack.com/services/123',
name: 'My Webhook',
}).save();
const teamWebhooksById = new Map<string, typeof webhook>([
[webhook._id.toString(), webhook],
]);
const connection = await Connection.create({
team: team._id,
name: 'Default',
host: config.CLICKHOUSE_HOST,
username: config.CLICKHOUSE_USER,
password: config.CLICKHOUSE_PASSWORD,
});
const source = await Source.create({
kind: 'log',
team: team._id,
from: {
databaseName: 'default',
tableName: 'otel_logs',
},
timestampValueExpression: 'Timestamp',
connection: connection.id,
name: 'Logs',
});
// Saved search uses an alias in select and references it in where (Lucene).
// Note: Lucene `field:"value"` on alias columns (unknown type) generates
// an exact-match query, so use unquoted syntax for substring matching.
const savedSearch = await new SavedSearch({
team: team._id,
name: 'Aliased Search',
select: 'toString(Body) AS body',
where: 'body:wrong',
whereLanguage: 'lucene',
orderBy: 'Timestamp',
source: source.id,
tags: ['test'],
}).save();
const clickhouseClient = new ClickhouseClient({
host: connection.host,
username: connection.username,
password: connection.password,
});
const now = new Date('2023-11-16T22:12:00.000Z');
const eventMs = new Date('2023-11-16T22:05:00.000Z');
await bulkInsertLogs([
{
ServiceName: 'api',
Timestamp: eventMs,
SeverityText: 'error',
Body: 'Oh no! Something went wrong!',
},
{
ServiceName: 'api',
Timestamp: eventMs,
SeverityText: 'info',
Body: 'Oh no! Something went wrong!',
},
]);
const details = await createAlertDetails(
team,
source,
{
source: AlertSource.SAVED_SEARCH,
channel: {
type: 'webhook',
webhookId: webhook._id.toString(),
},
interval: '5m',
thresholdType: AlertThresholdType.ABOVE,
threshold: 1,
savedSearchId: savedSearch.id,
},
{
taskType: AlertTaskType.SAVED_SEARCH,
savedSearch,
},
);
// Without alias WITH clause support, this would fail because
// the alert query uses count(*) and the WHERE references `body`
// which is only defined by the saved search's SELECT alias
await processAlertAtTime(
now,
details,
clickhouseClient,
connection.id,
alertProvider,
teamWebhooksById,
);
expect((await Alert.findById(details.alert.id))!.state).toBe('ALERT');
expect(slack.postMessageToWebhook).toHaveBeenCalledTimes(1);
});
it('SAVED_SEARCH alert with alias in where should not trigger when no rows match', async () => {
const team = await createTeam({ name: 'My Team' });
const webhook = await new Webhook({
team: team._id,
service: 'slack',
url: 'https://hooks.slack.com/services/123',
name: 'My Webhook',
}).save();
const teamWebhooksById = new Map<string, typeof webhook>([
[webhook._id.toString(), webhook],
]);
const connection = await Connection.create({
team: team._id,
name: 'Default',
host: config.CLICKHOUSE_HOST,
username: config.CLICKHOUSE_USER,
password: config.CLICKHOUSE_PASSWORD,
});
const source = await Source.create({
kind: 'log',
team: team._id,
from: {
databaseName: 'default',
tableName: 'otel_logs',
},
timestampValueExpression: 'Timestamp',
connection: connection.id,
name: 'Logs',
});
// Alias in select, where references alias with a value that won't match
const savedSearch = await new SavedSearch({
team: team._id,
name: 'Aliased Search No Match',
select: 'toString(Body) AS body',
where: 'body:"does not exist anywhere"',
whereLanguage: 'lucene',
orderBy: 'Timestamp',
source: source.id,
tags: ['test'],
}).save();
const clickhouseClient = new ClickhouseClient({
host: connection.host,
username: connection.username,
password: connection.password,
});
const now = new Date('2023-11-16T22:12:00.000Z');
const eventMs = new Date('2023-11-16T22:05:00.000Z');
await bulkInsertLogs([
{
ServiceName: 'api',
Timestamp: eventMs,
SeverityText: 'error',
Body: 'Something went wrong!',
},
]);
const details = await createAlertDetails(
team,
source,
{
source: AlertSource.SAVED_SEARCH,
channel: {
type: 'webhook',
webhookId: webhook._id.toString(),
},
interval: '5m',
thresholdType: AlertThresholdType.ABOVE,
threshold: 1,
savedSearchId: savedSearch.id,
},
{
taskType: AlertTaskType.SAVED_SEARCH,
savedSearch,
},
);
await processAlertAtTime(
now,
details,
clickhouseClient,
connection.id,
alertProvider,
teamWebhooksById,
);
// No matching rows, so alert should remain in OK/INSUFFICIENT_DATA state
const alertState = (await Alert.findById(details.alert.id))!.state;
expect(alertState).not.toBe('ALERT');
expect(slack.postMessageToWebhook).not.toHaveBeenCalled();
});
it('SAVED_SEARCH alert with multiple aliases in select and where should trigger', async () => {
const team = await createTeam({ name: 'My Team' });
const webhook = await new Webhook({
team: team._id,
service: 'slack',
url: 'https://hooks.slack.com/services/123',
name: 'My Webhook',
}).save();
const teamWebhooksById = new Map<string, typeof webhook>([
[webhook._id.toString(), webhook],
]);
const connection = await Connection.create({
team: team._id,
name: 'Default',
host: config.CLICKHOUSE_HOST,
username: config.CLICKHOUSE_USER,
password: config.CLICKHOUSE_PASSWORD,
});
const source = await Source.create({
kind: 'log',
team: team._id,
from: {
databaseName: 'default',
tableName: 'otel_logs',
},
timestampValueExpression: 'Timestamp',
connection: connection.id,
name: 'Logs',
});
// Multiple aliases in select, where references one of them
const savedSearch = await new SavedSearch({
team: team._id,
name: 'Multi Alias Search',
select: 'toString(Body) AS body, ServiceName AS svc',
where: 'svc:"api"',
whereLanguage: 'lucene',
orderBy: 'Timestamp',
source: source.id,
tags: ['test'],
}).save();
const clickhouseClient = new ClickhouseClient({
host: connection.host,
username: connection.username,
password: connection.password,
});
const now = new Date('2023-11-16T22:12:00.000Z');
const eventMs = new Date('2023-11-16T22:05:00.000Z');
await bulkInsertLogs([
{
ServiceName: 'api',
Timestamp: eventMs,
SeverityText: 'error',
Body: 'Error from api service',
},
{
ServiceName: 'web',
Timestamp: eventMs,
SeverityText: 'info',
Body: 'Info from web service',
},
]);
const details = await createAlertDetails(
team,
source,
{
source: AlertSource.SAVED_SEARCH,
channel: {
type: 'webhook',
webhookId: webhook._id.toString(),
},
interval: '5m',
thresholdType: AlertThresholdType.ABOVE,
threshold: 1,
savedSearchId: savedSearch.id,
},
{
taskType: AlertTaskType.SAVED_SEARCH,
savedSearch,
},
);
await processAlertAtTime(
now,
details,
clickhouseClient,
connection.id,
alertProvider,
teamWebhooksById,
);
// Only 1 log matches svc:"api", which meets threshold > 1
expect((await Alert.findById(details.alert.id))!.state).toBe('ALERT');
expect(slack.postMessageToWebhook).toHaveBeenCalledTimes(1);
});
});
describe('processAlert with materialized views', () => {

View file

@ -3,13 +3,18 @@
// --------------------------------------------------------
import PQueue from '@esm2cjs/p-queue';
import * as clickhouse from '@hyperdx/common-utils/dist/clickhouse';
import { ResponseJSON } from '@hyperdx/common-utils/dist/clickhouse';
import {
chSqlToAliasMap,
ResponseJSON,
} from '@hyperdx/common-utils/dist/clickhouse';
import { ClickhouseClient } from '@hyperdx/common-utils/dist/clickhouse/node';
import { tryOptimizeConfigWithMaterializedView } from '@hyperdx/common-utils/dist/core/materializedViews';
import {
getMetadata,
Metadata,
} from '@hyperdx/common-utils/dist/core/metadata';
import { renderChartConfig } from '@hyperdx/common-utils/dist/core/renderChartConfig';
import { aliasMapToWithClauses } from '@hyperdx/common-utils/dist/core/utils';
import { timeBucketByGranularity } from '@hyperdx/common-utils/dist/core/utils';
import {
ChartConfigWithOptDateRange,
@ -70,6 +75,33 @@ export const alertHasGroupBy = (details: AlertDetails): boolean => {
return false;
};
/**
* Render a saved search's SELECT to discover column aliases (e.g. `toString(Body) AS body`)
* and return them as WITH clauses that can be injected into alert/sample-log queries
* whose own SELECT doesn't include those aliases.
*/
export async function computeAliasWithClauses(
savedSearch: Pick<ISavedSearch, 'select' | 'where' | 'whereLanguage'>,
source: ISource,
metadata: Metadata,
): Promise<ChartConfigWithOptDateRange['with']> {
const resolvedSelect =
savedSearch.select || source.defaultTableSelectExpression || '';
const config: ChartConfigWithOptDateRange = {
connection: '',
displayType: DisplayType.Search,
from: source.from,
select: resolvedSelect,
where: savedSearch.where,
whereLanguage: savedSearch.whereLanguage,
implicitColumnExpression: source.implicitColumnExpression,
timestampValueExpression: source.timestampValueExpression,
};
const query = await renderChartConfig(config, metadata, source.querySettings);
const aliasMap = chSqlToAliasMap(query);
return aliasMapToWithClauses(aliasMap);
}
export const doesExceedThreshold = (
thresholdType: AlertThresholdType,
threshold: number,
@ -458,6 +490,29 @@ export const processAlert = async (
const metadata = getMetadata(clickhouseClient);
// For saved search alerts, the WHERE clause may reference aliased columns
// from the saved search's select expression (e.g. `toString(Body) AS body`).
// The alert query itself uses count(*), not the saved search's select,
// so we render the saved search's select separately to discover aliases
// and inject them as WITH clauses into the alert query.
if (details.taskType === AlertTaskType.SAVED_SEARCH) {
try {
const withClauses = await computeAliasWithClauses(
details.savedSearch,
source,
metadata,
);
if (withClauses) {
chartConfig.with = withClauses;
}
} catch (e) {
logger.warn(
{ error: serializeError(e), alertId: alert.id },
'Failed to compute alias WITH clauses for alert check',
);
}
}
// Optimize chart config with materialized views, if available
const optimizedChartConfig = source?.materializedViews?.length
? await tryOptimizeConfigWithMaterializedView(

View file

@ -27,7 +27,10 @@ import { IDashboard } from '@/models/dashboard';
import { ISavedSearch } from '@/models/savedSearch';
import { ISource } from '@/models/source';
import { IWebhook } from '@/models/webhook';
import { doesExceedThreshold } from '@/tasks/checkAlerts';
import {
computeAliasWithClauses,
doesExceedThreshold,
} from '@/tasks/checkAlerts';
import {
AlertProvider,
PopulatedAlertChannel,
@ -578,12 +581,14 @@ ${targetTemplate}`;
}
// TODO: show group + total count for group-by alerts
// fetch sample logs
const resolvedSelect =
savedSearch.select || source.defaultTableSelectExpression || '';
const chartConfig: ChartConfigWithOptDateRange = {
connection: '', // no need for the connection id since clickhouse client is already initialized
displayType: DisplayType.Search,
dateRange: [startTime, endTime],
from: source.from,
select: savedSearch.select || source.defaultTableSelectExpression || '', // remove alert body if there is no select and defaultTableSelectExpression
select: resolvedSelect,
where: savedSearch.where,
whereLanguage: savedSearch.whereLanguage,
implicitColumnExpression: source.implicitColumnExpression,
@ -597,6 +602,14 @@ ${targetTemplate}`;
let truncatedResults = '';
try {
const aliasWith = await computeAliasWithClauses(
savedSearch,
source,
metadata,
);
if (aliasWith) {
chartConfig.with = aliasWith;
}
const query = await renderChartConfig(
chartConfig,
metadata,

View file

@ -27,6 +27,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { ClickHouseQueryError } from '@hyperdx/common-utils/dist/clickhouse';
import { tcFromSource } from '@hyperdx/common-utils/dist/core/metadata';
import {
aliasMapToWithClauses,
isBrowser,
splitAndTrimWithBracket,
} from '@hyperdx/common-utils/dist/core/utils';
@ -93,7 +94,6 @@ import { TimePicker } from '@/components/TimePicker';
import { IS_LOCAL_MODE } from '@/config';
import { useAliasMapFromChartConfig } from '@/hooks/useChartConfig';
import { useExplainQuery } from '@/hooks/useExplainQuery';
import { aliasMapToWithClauses } from '@/hooks/useRowWhere';
import { withAppNav } from '@/layout';
import {
useCreateSavedSearch,

View file

@ -1,4 +1,5 @@
import React from 'react';
import { aliasMapToWithClauses } from '@hyperdx/common-utils/dist/core/utils';
import {
AlertInterval,
SearchCondition,
@ -9,7 +10,6 @@ import { Paper } from '@mantine/core';
import { DBTimeChart } from '@/components/DBTimeChart';
import { useAliasMapFromChartConfig } from '@/hooks/useChartConfig';
import { aliasMapToWithClauses } from '@/hooks/useRowWhere';
import { intervalToDateRange, intervalToGranularity } from '@/utils/alerts';
import { getAlertReferenceLines } from './Alerts';

View file

@ -6,25 +6,20 @@ import {
convertCHDataTypeToJSType,
JSDataType,
} from '@hyperdx/common-utils/dist/clickhouse';
import { aliasMapToWithClauses } from '@hyperdx/common-utils/dist/core/utils';
import { ChartConfig } from '@hyperdx/common-utils/dist/types';
const MAX_STRING_LENGTH = 512;
// Type for WITH clause entries, derived from ChartConfig's with property
export type WithClause = NonNullable<ChartConfig['with']>[number];
// Internal row field names used by the table component for row tracking
export const INTERNAL_ROW_FIELDS = {
ID: '__hyperdx_id',
ALIAS_WITH: '__hyperdx_alias_with',
} as const;
// Type for WITH clause entries, matching ChartConfig's with property
export type WithClause = {
name: string;
sql: {
sql: string;
params: Record<string, unknown>;
};
isSubquery: boolean;
};
// Result type for row WHERE clause with alias support
export type RowWhereResult = {
where: string;
@ -133,29 +128,6 @@ export function processRowToWhereClause(
return res;
}
/**
* Converts an aliasMap to an array of WITH clause entries.
* This allows aliases to be properly defined when querying for a specific row.
*/
export function aliasMapToWithClauses(
aliasMap: Record<string, string | undefined> | undefined,
): WithClause[] {
if (!aliasMap) {
return [];
}
return Object.entries(aliasMap)
.filter(([, value]) => value != null && value.trim() !== '')
.map(([name, value]) => ({
name,
sql: {
sql: value as string,
params: {},
},
isSubquery: false,
}));
}
export default function useRowWhere({
meta,
aliasMap,
@ -186,7 +158,10 @@ export default function useRowWhere({
);
// Memoize the aliasWith array since it only depends on aliasMap
const aliasWith = useMemo(() => aliasMapToWithClauses(aliasMap), [aliasMap]);
const aliasWith = useMemo(
() => aliasMapToWithClauses(aliasMap) ?? [],
[aliasMap],
);
return useCallback(
(row: Record<string, any>): RowWhereResult => {

View file

@ -9,6 +9,7 @@ import {
} from '@/types';
import {
aliasMapToWithClauses,
convertToDashboardTemplate,
extractSettingsClauseFromEnd,
findJsonExpressions,
@ -1726,4 +1727,71 @@ describe('utils', () => {
expect(result).toEqual(expected);
});
});
describe('aliasMapToWithClauses', () => {
it('should return undefined for undefined input', () => {
expect(aliasMapToWithClauses(undefined)).toBeUndefined();
});
it('should return undefined for empty alias map', () => {
expect(aliasMapToWithClauses({})).toBeUndefined();
});
it('should return undefined when all values are undefined', () => {
expect(
aliasMapToWithClauses({ body: undefined, service: undefined }),
).toBeUndefined();
});
it('should return undefined when all values are empty strings', () => {
expect(
aliasMapToWithClauses({ body: '', service: ' ' }),
).toBeUndefined();
});
it('should convert a single alias to a WITH clause', () => {
expect(aliasMapToWithClauses({ body: 'toString(Body)' })).toEqual([
{
name: 'body',
sql: { sql: 'toString(Body)', params: {} },
isSubquery: false,
},
]);
});
it('should convert multiple aliases to WITH clauses', () => {
const result = aliasMapToWithClauses({
body: 'toString(Body)',
service: "ResourceAttributes['service.name']",
});
expect(result).toEqual([
{
name: 'body',
sql: { sql: 'toString(Body)', params: {} },
isSubquery: false,
},
{
name: 'service',
sql: {
sql: "ResourceAttributes['service.name']",
params: {},
},
isSubquery: false,
},
]);
});
it('should skip entries with undefined or empty values', () => {
const result = aliasMapToWithClauses({
body: 'toString(Body)',
empty: '',
blank: ' ',
missing: undefined,
service: "ResourceAttributes['service.name']",
});
expect(result).toHaveLength(2);
expect(result![0].name).toBe('body');
expect(result![1].name).toBe('service');
});
});
});

View file

@ -6,6 +6,7 @@ import { z } from 'zod';
export { default as objectHash } from 'object-hash';
import {
ChartConfig,
ChartConfigWithDateRange,
ChartConfigWithOptTimestamp,
DashboardFilter,
@ -927,3 +928,32 @@ export function parseTokenizerFromTextIndex({
return undefined;
}
}
/**
* Converts an aliasMap (e.g. from chSqlToAliasMap) to an array of WITH clause entries.
* These WITH clauses define aliases as expressions (isSubquery: false),
* making them available in WHERE and other clauses.
*/
export function aliasMapToWithClauses(
aliasMap: Record<string, string | undefined> | undefined,
): ChartConfig['with'] {
if (!aliasMap) {
return undefined;
}
const withClauses = Object.entries(aliasMap)
.filter(
(entry): entry is [string, string] =>
entry[1] != null && entry[1].trim() !== '',
)
.map(([name, value]) => ({
name,
sql: {
sql: value,
params: {},
},
isSubquery: false,
}));
return withClauses.length > 0 ? withClauses : undefined;
}