Skip to content
Merged
6 changes: 6 additions & 0 deletions .changeset/silent-pandas-search.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@hyperdx/api': patch
---

Apply saved-search pinned filters to alert notification sample queries so sample
log lines come from the same row set the alert counted.
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,20 @@ const sampleLogsCsv = [
'"2023-03-17 22:12:30","error","Retry limit exceeded"',
].join('\n');

const sampleLogsResultSet = () => ({
json: jest.fn().mockResolvedValue({ data: [] }),
text: jest.fn().mockResolvedValue(sampleLogsCsv),
});

// Typed separately from the client so tests can swap the implementation
// without spying through the `any` cast below, which erases the arg types.
const defaultClickhouseQuery = async (_props: { query: string }) =>
sampleLogsResultSet();
const mockClickhouseQuery = jest.fn(defaultClickhouseQuery);

// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const mockClickhouseClient = {
query: jest.fn().mockResolvedValue({
json: jest.fn().mockResolvedValue({ data: [] }),
text: jest.fn().mockResolvedValue(sampleLogsCsv),
}),
query: mockClickhouseQuery,
} as any;

const startTime = new Date('2023-03-17T22:10:00.000Z');
Expand Down Expand Up @@ -713,6 +721,35 @@ describe('enriched message fields', () => {
});
});

it('applies the saved search pinned filters to the sample log query', async () => {
const seenQueries: string[] = [];
mockClickhouseQuery.mockImplementation(async ({ query }) => {
seenQueries.push(query);
return sampleLogsResultSet();
});

try {
await renderAlertTemplate({
alertProvider,
clickhouseClient: mockClickhouseClient,
metadata: mockMetadata,
state: AlertState.ALERT,
template: null,
title: 'Test Alert Title',
view: makeSearchView({
filters: [{ type: 'sql', condition: "ServiceName = 'checkout'" }],
}),
teamId: TEST_TEAM_ID,
teamWebhooksById: new Map(),
});

const fullQuery = seenQueries.join('\n');
expect(fullQuery).toContain("ServiceName = 'checkout'");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — New test mocks the ClickHouse client, so it only pins SQL text — it never checks the filtered sample query runs or actually narrows the rows

The mock at line 726 swallows the query and returns a fixed sampleLogsCsv regardless of what SQL was generated, so the only thing asserted is that the literal ServiceName = 'checkout' appears somewhere in the string. Because fetchSampleLines catches every failure and returns '' (template.ts:583-593), a sample query that the new filters/tableFilterExpression path renders into invalid SQL degrades silently to an empty fenced block — and nothing catches it, since the real-ClickHouse saved-search cases in checkAlerts.int.test.ts assert the notification body as expect.any(Object) (lines 2459 and 2472). Extend the existing saved-search-with-filter case that already runs against ClickHouse (checkAlerts.int.test.ts:2616-2687, whose saved search carries filters: [{ type: 'sql', condition: "service-name IN ('svc-a')" }] over one matching and one non-matching row) to assert the rendered Slack body's fenced block contains matches the saved filter and not excluded by the saved filter — that pins execution and narrowing, which is the actual risk of this change.

} finally {
mockClickhouseQuery.mockImplementation(defaultClickhouseQuery);
}
});

it('reports an empty sourceQuery when the chart carries no condition', async () => {
const { dispatched } = await renderWithWebhook(
AlertState.ALERT,
Expand Down
37 changes: 13 additions & 24 deletions packages/api/src/tasks/checkAlerts/template.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ClickhouseClient } from '@hyperdx/common-utils/dist/clickhouse/node';
import { Metadata } from '@hyperdx/common-utils/dist/core/metadata';
import { renderChartConfig } from '@hyperdx/common-utils/dist/core/renderChartConfig';
import { buildSearchChartConfig } from '@hyperdx/common-utils/dist/core/searchChartConfig';
import { formatDate, objectHash } from '@hyperdx/common-utils/dist/core/utils';
import {
isPromqlSavedChartConfig,
Expand All @@ -11,10 +12,8 @@ import {
AlertThresholdType,
BuilderChartConfigWithOptDateRange,
ChartConfigWithOptDateRange,
DisplayType,
Filter,
isRangeThresholdType,
pickSampleWeightExpressionProps,
SavedChartConfig,
SourceKind,
zAlertChannelType,
Expand Down Expand Up @@ -531,33 +530,23 @@ export const fetchSampleLines = async ({
metadata: Metadata;
savedSearch: Pick<
ISavedSearch,
'id' | 'select' | 'where' | 'whereLanguage' | 'orderBy'
'id' | 'select' | 'where' | 'whereLanguage' | 'orderBy' | 'filters'
>;
source: ISource;
startTime: Date;
}): Promise<string> => {
const isEventSource =
source.kind === SourceKind.Log || source.kind === SourceKind.Trace;
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 ||
(isEventSource && source.defaultTableSelectExpression) ||
'',
where: savedSearch.where,
whereLanguage: savedSearch.whereLanguage,
implicitColumnExpression: isEventSource
? source.implicitColumnExpression
: undefined,
useTextIndexForImplicitColumn: isEventSource
? source.useTextIndexForImplicitColumn
: undefined,
...pickSampleWeightExpressionProps(source),
timestampValueExpression: source.timestampValueExpression,
orderBy: savedSearch.orderBy,
...buildSearchChartConfig(source, {
connection: '', // no need for the connection id since clickhouse client is already initialized
dateRange: [startTime, endTime],
select: savedSearch.select,
where: savedSearch.where,
whereLanguage: savedSearch.whereLanguage,
filters: savedSearch.filters,
orderBy: savedSearch.orderBy,
dateRangeStartInclusive: true,
dateRangeEndInclusive: false,
}),
limit: {
limit: 5,
offset: 0,
Expand Down
Loading