Skip to content

Commit 5c6da48

Browse files
authored
[HDX-4111] refactor(alerts/search): consolidate saved-search → chart-config builder (#2166)
## Summary Refs [HDX-4111](https://linear.app/clickhouse/issue/HDX-4111/private-instance-alert-system-triggering-false-positive-alerts). Consolidate the saved-search → chart-config builder into a single shared helper. The alert task, the alert preview chart, and the app search page each had their own inline builder, and only the search page applied `source.tableFilterExpression`. On Log sources where that field is set, the alert path could legitimately count rows the search page hides — same false-positive class as HDX-4111, different cause from the underlying ClickHouse bug. Routing all three paths through the shared builder eliminates this drift class. ## Changes ### Shared chart-config builder `packages/common-utils/src/core/searchChartConfig.ts` (new) exports `buildSearchChartConfig(source, input)` — the single source of truth for assembling a `BuilderChartConfig` from a `TSource` plus saved-search-style fields. Three call sites now route through it: | Call site | Before | After | | --- | --- | --- | | `packages/app/src/DBSearchPage.tsx` (`useSearchedConfigToChartConfig`) | inline; applied `tableFilterExpression`; latently dropped it when user filters were non-null | shared builder | | `packages/app/src/components/AlertPreviewChart.tsx` | inline; did NOT apply `tableFilterExpression` | shared builder | | `packages/api/src/tasks/checkAlerts/index.ts` (`SAVED_SEARCH` branch) | inline; did NOT apply `tableFilterExpression` | shared builder | Behavior fixes that fall out of consolidation: - `tableFilterExpression` is now applied uniformly on Log sources by the alert task and the alert preview. - A latent bug in the search-page builder is fixed: a non-null `filters` array no longer silently drops the table filter via spread-overwrite. - The alert preview chart now matches what the alert task will actually count. ### Shared default SELECT for count alerts `ALERT_COUNT_DEFAULT_SELECT` (in the same module) — both the alert task and the alert preview chart use this constant for the `count()` aggregate, so the two paths produce byte-identical SELECT shapes. ### `SearchChartConfig` return type Narrower than `BuilderChartConfigWithOptDateRange` (keeps `timestampValueExpression` required, since the builder always sets it from the source). Lets downstream callers spread-and-add `dateRange` into `BuilderChartConfigWithDateRange` without TS errors. ## Risk / behavior change Alerts on Log sources with `tableFilterExpression` set will now evaluate against a strictly smaller row set — the same rows the search page was already showing. Any alert that was quietly firing only because of rows hidden by `tableFilterExpression` will stop firing. This is a correctness fix. Follow-up structural work (TILE alert chart-config consolidation, `computeAliasWithClauses` extraction) is tracked in [HDX-4113](https://linear.app/clickhouse/issue/HDX-4113/consolidate-alert-chart-config-assembly-into-hyperdxcommon-utils).
1 parent fecbfff commit 5c6da48

9 files changed

Lines changed: 810 additions & 93 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@hyperdx/common-utils': minor
3+
'@hyperdx/api': minor
4+
'@hyperdx/app': minor
5+
---
6+
7+
refactor(alerts/search): consolidate the saved-search → chart-config builder
8+
into a single shared helper, `buildSearchChartConfig`, in
9+
`@hyperdx/common-utils/core/searchChartConfig.ts`. The app search page, the
10+
alert preview chart, and the scheduled alert task's `SAVED_SEARCH` branch now
11+
all route through it, so `tableFilterExpression`, `implicitColumnExpression`,
12+
sample-weight expressions, SELECT precedence, and the `count()` default
13+
SELECT shape are applied identically by construction.
14+
15+
Behavior fixes that fall out of consolidation:
16+
- The alert task and the alert preview now apply `source.tableFilterExpression`
17+
on Log sources, matching what the search page already did.
18+
- A latent bug in the search-page builder is fixed: a non-null `filters`
19+
array no longer silently drops the `tableFilterExpression` SQL filter via
20+
spread-overwrite.

packages/api/src/models/source.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ export const LogSource = Source.discriminator<ILogSource>(
134134
spanIdExpression: String,
135135
implicitColumnExpression: String,
136136
uniqueRowIdExpression: String,
137+
/** @deprecated See LogSourceSchema in @hyperdx/common-utils/types.ts. */
137138
tableFilterExpression: String,
138139
highlightedTraceAttributeExpressions: {
139140
type: mongoose.Schema.Types.Array,

packages/api/src/tasks/checkAlerts/__tests__/checkAlerts.test.ts

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import AlertHistory from '@/models/alertHistory';
3030
import Connection, { IConnection } from '@/models/connection';
3131
import Dashboard, { IDashboard } from '@/models/dashboard';
3232
import { ISavedSearch, SavedSearch } from '@/models/savedSearch';
33-
import { ISource, Source } from '@/models/source';
33+
import { ISource, LogSource, Source } from '@/models/source';
3434
import { ITeam } from '@/models/team';
3535
import Webhook, { IWebhook } from '@/models/webhook';
3636
import * as checkAlert from '@/tasks/checkAlerts';
@@ -2314,6 +2314,110 @@ describe('checkAlerts', () => {
23142314
);
23152315
});
23162316

2317+
// The scheduled alert task and the app search page both go through
2318+
// `buildSearchChartConfig`, which prepends the Log source's
2319+
// `tableFilterExpression` (when set) as a SQL filter. This regression
2320+
// test pins that contract for the alert task: rows excluded by
2321+
// `tableFilterExpression` must not be counted toward the alert
2322+
// threshold.
2323+
it('SAVED_SEARCH alert honors source.tableFilterExpression', async () => {
2324+
const {
2325+
team,
2326+
webhook,
2327+
connection,
2328+
source,
2329+
savedSearch,
2330+
teamWebhooksById,
2331+
clickhouseClient,
2332+
} = await setupSavedSearchAlertTest();
2333+
2334+
// Configure the source to hide the 'excluded' service, matching the
2335+
// semantics of a Log source whose UI view filters it out.
2336+
// `tableFilterExpression` is declared on the Log discriminator schema
2337+
// (not the base Source schema), so we must update through `LogSource`
2338+
// — `Source.updateOne` would silently drop the field under Mongoose's
2339+
// default strict mode.
2340+
await LogSource.updateOne(
2341+
{ _id: source._id },
2342+
{ $set: { tableFilterExpression: "ServiceName != 'excluded'" } },
2343+
);
2344+
const filteredSource = await Source.findById(source._id);
2345+
2346+
const details = await createAlertDetails(
2347+
team,
2348+
filteredSource ?? undefined,
2349+
{
2350+
source: AlertSource.SAVED_SEARCH,
2351+
channel: {
2352+
type: 'webhook',
2353+
webhookId: webhook._id.toString(),
2354+
},
2355+
interval: '5m',
2356+
thresholdType: AlertThresholdType.ABOVE_EXCLUSIVE,
2357+
threshold: 1,
2358+
savedSearchId: savedSearch.id,
2359+
},
2360+
{
2361+
taskType: AlertTaskType.SAVED_SEARCH,
2362+
savedSearch,
2363+
},
2364+
);
2365+
2366+
const now = new Date('2023-11-16T22:12:00.000Z');
2367+
const eventMs = new Date('2023-11-16T22:05:00.000Z');
2368+
2369+
// Insert two log rows in the alert window:
2370+
// 1. 'excluded' service — matches the saved search `where` but is
2371+
// hidden by the source's `tableFilterExpression`.
2372+
// 2. 'api' service — matches the saved search `where` and passes
2373+
// the table filter. Threshold uses ABOVE_EXCLUSIVE (strict `>`),
2374+
// so with the filter applied (count = 1) `1 > 1` is false and
2375+
// the alert stays OK. If the filter were dropped (count = 2),
2376+
// `2 > 1` would fire — the failure mode this test guards.
2377+
await bulkInsertLogs([
2378+
{
2379+
ServiceName: 'excluded',
2380+
Timestamp: eventMs,
2381+
SeverityText: 'error',
2382+
Body: 'This row should be hidden by tableFilterExpression',
2383+
},
2384+
{
2385+
ServiceName: 'api',
2386+
Timestamp: eventMs,
2387+
SeverityText: 'error',
2388+
Body: 'This row should pass the filter',
2389+
},
2390+
]);
2391+
2392+
await processAlertAtTime(
2393+
now,
2394+
details,
2395+
clickhouseClient,
2396+
connection.id,
2397+
alertProvider,
2398+
teamWebhooksById,
2399+
);
2400+
2401+
// Threshold is `> 1`, so with the fix (1 row counted) the alert stays
2402+
// OK. Without the fix (2 rows counted — including the excluded one),
2403+
// the alert would transition to ALERT and fire the webhook.
2404+
const alert = await Alert.findById(details.alert.id);
2405+
expect(alert!.state).toBe('OK');
2406+
2407+
const alertHistories = await AlertHistory.find({
2408+
alert: details.alert.id,
2409+
}).sort({ createdAt: 1 });
2410+
expect(alertHistories.length).toBe(1);
2411+
expect(alertHistories[0].state).toBe('OK');
2412+
// Exactly one row counted — the 'excluded' row is filtered out.
2413+
expect(alertHistories[0].lastValues).toEqual([
2414+
expect.objectContaining({ count: 1 }),
2415+
]);
2416+
2417+
// No webhook should have fired.
2418+
expect(slack.postMessageToWebhook).not.toHaveBeenCalled();
2419+
});
2420+
23172421
it('TILE alert (events) - slack webhook', async () => {
23182422
const {
23192423
team,

packages/api/src/tasks/checkAlerts/index.ts

Lines changed: 16 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ import {
1414
Metadata,
1515
} from '@hyperdx/common-utils/dist/core/metadata';
1616
import { renderChartConfig } from '@hyperdx/common-utils/dist/core/renderChartConfig';
17+
import {
18+
ALERT_COUNT_DEFAULT_SELECT,
19+
buildSearchChartConfig,
20+
} from '@hyperdx/common-utils/dist/core/searchChartConfig';
1721
import {
1822
aliasMapToWithClauses,
1923
displayTypeSupportsRawSqlAlerts,
@@ -512,32 +516,23 @@ const getChartConfigFromAlert = (
512516
if (details.taskType === AlertTaskType.SAVED_SEARCH) {
513517
const { source } = details;
514518
const savedSearch = details.savedSearch;
515-
return {
516-
connection,
519+
// Delegate to the shared builder (in @hyperdx/common-utils) so the alert
520+
// task, the alert preview chart, and the main app search page all
521+
// assemble saved-search chart configs identically — keeping source-level
522+
// fields like `tableFilterExpression` applied uniformly across paths.
523+
return buildSearchChartConfig(source, {
524+
where: savedSearch.where,
525+
whereLanguage: savedSearch.whereLanguage,
526+
filters: savedSearch.filters?.map(f => ({ ...f })),
527+
groupBy: alert.groupBy,
528+
select: ALERT_COUNT_DEFAULT_SELECT,
517529
displayType: DisplayType.Line,
530+
connection,
518531
dateRange,
519532
dateRangeStartInclusive: true,
520533
dateRangeEndInclusive: false,
521-
from: source.from,
522534
granularity: `${windowSizeInMins} minute`,
523-
select: [
524-
{
525-
aggFn: 'count',
526-
aggCondition: '',
527-
valueExpression: '',
528-
},
529-
],
530-
where: savedSearch.where,
531-
whereLanguage: savedSearch.whereLanguage,
532-
filters: savedSearch.filters?.map(f => ({ ...f })),
533-
groupBy: alert.groupBy,
534-
implicitColumnExpression:
535-
source.kind === SourceKind.Log || source.kind === SourceKind.Trace
536-
? source.implicitColumnExpression
537-
: undefined,
538-
...pickSampleWeightExpressionProps(source),
539-
timestampValueExpression: source.timestampValueExpression,
540-
};
535+
});
541536
} else if (details.taskType === AlertTaskType.TILE) {
542537
const tile = details.tile;
543538

packages/app/src/DBSearchPage.tsx

Lines changed: 24 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { z } from 'zod';
2727
import { zodResolver } from '@hookform/resolvers/zod';
2828
import { ClickHouseQueryError } from '@hyperdx/common-utils/dist/clickhouse';
2929
import { tcFromSource } from '@hyperdx/common-utils/dist/core/metadata';
30+
import { buildSearchChartConfig } from '@hyperdx/common-utils/dist/core/searchChartConfig';
3031
import {
3132
aliasMapToWithClauses,
3233
isBrowser,
@@ -37,9 +38,7 @@ import {
3738
ChartConfigWithDateRange,
3839
DisplayType,
3940
Filter,
40-
isLogSource,
4141
isTraceSource,
42-
pickSampleWeightExpressionProps,
4342
SourceKind,
4443
TSource,
4544
} from '@hyperdx/common-utils/dist/types';
@@ -511,7 +510,7 @@ function SaveSearchModalComponent({
511510
<Text size="xs" mb="xs" mt="sm">
512511
ORDER BY
513512
</Text>
514-
<Text size="xs">{chartConfig.orderBy}</Text>
513+
<Text size="xs">{`${chartConfig.orderBy ?? ''}`}</Text>
515514
{searchedConfig.filters && searchedConfig.filters.length > 0 && (
516515
<>
517516
<Text size="xs" mb="xs" mt="sm">
@@ -690,35 +689,20 @@ function useSearchedConfigToChartConfig(
690689

691690
return useMemo(() => {
692691
if (sourceObj != null) {
692+
const resolvedOrderBy =
693+
orderBy || defaultSearchConfig?.orderBy || defaultOrderBy;
694+
695+
const chartConfig = buildSearchChartConfig(sourceObj, {
696+
where,
697+
whereLanguage,
698+
filters,
699+
select: select || defaultSearchConfig?.select || null,
700+
displayType: DisplayType.Search,
701+
...(resolvedOrderBy != null ? { orderBy: resolvedOrderBy } : {}),
702+
});
703+
693704
return {
694-
data: {
695-
select:
696-
select ||
697-
defaultSearchConfig?.select ||
698-
sourceObj.defaultTableSelectExpression,
699-
from: sourceObj.from,
700-
source: sourceObj.id,
701-
...(isLogSource(sourceObj) && sourceObj.tableFilterExpression != null
702-
? {
703-
filters: [
704-
{
705-
type: 'sql' as const,
706-
condition: sourceObj.tableFilterExpression,
707-
},
708-
...(filters ?? []),
709-
],
710-
}
711-
: {}),
712-
...(filters != null ? { filters } : {}),
713-
where: where ?? '',
714-
whereLanguage: whereLanguage ?? 'sql',
715-
timestampValueExpression: sourceObj.timestampValueExpression,
716-
implicitColumnExpression: sourceObj.implicitColumnExpression,
717-
...pickSampleWeightExpressionProps(sourceObj),
718-
connection: sourceObj.connection,
719-
displayType: DisplayType.Search,
720-
orderBy: orderBy || defaultSearchConfig?.orderBy || defaultOrderBy,
721-
},
705+
data: chartConfig,
722706
};
723707
}
724708

@@ -1324,13 +1308,15 @@ export function DBSearchPage() {
13241308
};
13251309
}, [chartConfig, searchedTimeRange]);
13261310

1327-
const displayedColumns = useMemo(
1328-
() =>
1329-
splitAndTrimWithBracket(
1330-
dbSqlRowTableConfig?.select ?? defaultSearchConfig.select ?? '',
1331-
),
1332-
[dbSqlRowTableConfig?.select, defaultSearchConfig.select],
1333-
);
1311+
const displayedColumns = useMemo(() => {
1312+
// `select` is typed as `string | DerivedColumn[]` upstream, but in the
1313+
// search page we always supply a string. Guard for type safety.
1314+
const rawSelect =
1315+
dbSqlRowTableConfig?.select ?? defaultSearchConfig.select ?? '';
1316+
return splitAndTrimWithBracket(
1317+
typeof rawSelect === 'string' ? rawSelect : '',
1318+
);
1319+
}, [dbSqlRowTableConfig?.select, defaultSearchConfig.select]);
13341320

13351321
const toggleColumn = useCallback(
13361322
(column: string) => {

0 commit comments

Comments
 (0)