diff --git a/packages/api/src/tasks/checkAlerts/__tests__/checkAlerts.test.ts b/packages/api/src/tasks/checkAlerts/__tests__/checkAlerts.test.ts index 8cc19f32..9862fa2e 100644 --- a/packages/api/src/tasks/checkAlerts/__tests__/checkAlerts.test.ts +++ b/packages/api/src/tasks/checkAlerts/__tests__/checkAlerts.test.ts @@ -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([ + [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([ + [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([ + [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', () => { diff --git a/packages/api/src/tasks/checkAlerts/index.ts b/packages/api/src/tasks/checkAlerts/index.ts index ed7e254d..86f639c0 100644 --- a/packages/api/src/tasks/checkAlerts/index.ts +++ b/packages/api/src/tasks/checkAlerts/index.ts @@ -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, + source: ISource, + metadata: Metadata, +): Promise { + 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( diff --git a/packages/api/src/tasks/checkAlerts/template.ts b/packages/api/src/tasks/checkAlerts/template.ts index c1fb3670..56c4909b 100644 --- a/packages/api/src/tasks/checkAlerts/template.ts +++ b/packages/api/src/tasks/checkAlerts/template.ts @@ -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, diff --git a/packages/app/src/DBSearchPage.tsx b/packages/app/src/DBSearchPage.tsx index 73449c34..9bf77774 100644 --- a/packages/app/src/DBSearchPage.tsx +++ b/packages/app/src/DBSearchPage.tsx @@ -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, diff --git a/packages/app/src/components/AlertPreviewChart.tsx b/packages/app/src/components/AlertPreviewChart.tsx index b55d7986..4633389e 100644 --- a/packages/app/src/components/AlertPreviewChart.tsx +++ b/packages/app/src/components/AlertPreviewChart.tsx @@ -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'; diff --git a/packages/app/src/hooks/useRowWhere.tsx b/packages/app/src/hooks/useRowWhere.tsx index 12fd89bb..a2bf2aba 100644 --- a/packages/app/src/hooks/useRowWhere.tsx +++ b/packages/app/src/hooks/useRowWhere.tsx @@ -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[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; - }; - 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 | 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): RowWhereResult => { diff --git a/packages/common-utils/src/__tests__/utils.test.ts b/packages/common-utils/src/__tests__/utils.test.ts index c09ddb3b..16925394 100644 --- a/packages/common-utils/src/__tests__/utils.test.ts +++ b/packages/common-utils/src/__tests__/utils.test.ts @@ -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'); + }); + }); }); diff --git a/packages/common-utils/src/core/utils.ts b/packages/common-utils/src/core/utils.ts index a03638de..906bdc69 100644 --- a/packages/common-utils/src/core/utils.ts +++ b/packages/common-utils/src/core/utils.ts @@ -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 | 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; +}