Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/formula-tile-alerts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hyperdx/api': patch
---

Tile alerts on metric charts with formulas now evaluate the formula value instead of the last raw operand series: the alert task previously dropped `formulas`/`showOperandSeries` when rebuilding the tile's chart config, so an alert on a formula tile compared the threshold against a raw operand (e.g. bytes) rather than the derived value. Grouped ratio tile alerts also now honor `ratioMode` (`share_of_total` previously evaluated as `per_group`).
5 changes: 5 additions & 0 deletions .changeset/metric-formula-editor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hyperdx/app': minor
---

Add metric formula editing to the chart editor. Metric-source charts (time series, table, number) gain an "Add Formula" row: a letter-ref arithmetic expression over the chart's series (`A` = series 1, `B` = series 2, ...) such as `A / (A + B) * 100`, with inline structured validation (malformed expressions, unknown series references), per-formula alias and number format, and a "Show input series" toggle to render only the formula column(s) or the formula alongside its operand series. Series rows now carry their reference letter as a badge. Formulas and the "As Ratio" toggle are mutually exclusive, and formulas persist on dashboard tiles and standalone charts.
268 changes: 265 additions & 3 deletions packages/api/src/tasks/checkAlerts/__tests__/checkAlerts.int.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7049,21 +7049,33 @@ describe('checkAlerts', () => {
metricName: string,
value: number,
timestampMs: number,
serviceName = 'api',
) => ({
MetricName: metricName,
ServiceName: 'api',
ResourceAttributes: { host: 'host1' },
ServiceName: serviceName,
// Include the service in ResourceAttributes so grouped tests get
// distinct AttributesHash values per service (see the groupBy test
// above); harmless for ungrouped tests.
ResourceAttributes: { host: 'host1', 'service.name': serviceName },
Value: value,
TimeUnix: new Date(timestampMs),
});

const setupMetricTileAlert = async ({
select,
seriesReturnType,
ratioMode,
formulas,
showOperandSeries,
groupBy,
threshold,
}: {
select: Array<Record<string, string>>;
seriesReturnType?: 'ratio' | 'column';
ratioMode?: 'per_group' | 'share_of_total';
formulas?: Array<{ expression: string; alias?: string }>;
showOperandSeries?: boolean;
groupBy?: string;
threshold: number;
}) => {
const { team, webhook, connection, teamWebhooksById, clickhouseClient } =
Expand Down Expand Up @@ -7106,10 +7118,13 @@ describe('checkAlerts', () => {
name: 'Errors vs Requests',
select,
...(seriesReturnType ? { seriesReturnType } : {}),
...(ratioMode ? { ratioMode } : {}),
...(formulas ? { formulas } : {}),
...(showOperandSeries !== undefined ? { showOperandSeries } : {}),
where: '',
displayType: 'line',
source: source.id,
groupBy: '',
groupBy: groupBy ?? '',
},
},
],
Expand Down Expand Up @@ -7318,6 +7333,253 @@ describe('checkAlerts', () => {
expect(slack.postMessageToWebhook).not.toHaveBeenCalled();
});

// ── Metric formula tiles (HDX-5080) ──
// A tile with `formulas` derives its displayed series from the operand
// series (letter refs: A = select[0], ...). The alert must evaluate the
// FORMULA value — never a raw operand — regardless of the tile's
// "Show input series" display toggle (getChartConfigFromAlert always
// drops the operand columns from the alert query).

it('TILE alert (metrics, formula) - threshold compares the formula value, not an operand', async () => {
const now = new Date('2023-11-16T22:12:00.000Z');
// Alert window is [22:05, 22:10)
const eventMs = now.getTime() - ms('7m');

await bulkInsertMetricsGauge([
// Both operands are far from the formula result (10 / 200 * 100 = 5),
// so a regression back to evaluating either operand fails loudly.
gaugePoint('test.requests', 200, eventMs),
gaugePoint('test.errors', 10, eventMs),
]);

const { details, connection, teamWebhooksById, clickhouseClient } =
await setupMetricTileAlert({
select: [
{
aggFn: 'max',
valueExpression: 'Value',
metricType: 'gauge',
metricName: 'test.requests',
},
{
aggFn: 'max',
valueExpression: 'Value',
metricType: 'gauge',
metricName: 'test.errors',
},
],
formulas: [{ expression: 'B / A * 100', alias: 'Error rate' }],
// Mirrors the editor default of hiding operands; the alert result
// must be identical either way (see the next test).
showOperandSeries: false,
threshold: 4,
});

await processAlertAtTime(
now,
details,
clickhouseClient,
connection.id,
alertProvider,
teamWebhooksById,
);

expect((await Alert.findById(details.alert.id))!.state).toBe('ALERT');

const [history] = await AlertHistory.find({
alert: details.alert.id,
}).sort({ createdAt: 1 });
expect(history.state).toBe('ALERT');
expect(history.lastValues.length).toBe(1);
// The formula value (5) — not operand A (200) or operand B (10).
expect(history.lastValues[0].count).toBe(5);

expect(slack.postMessageToWebhook).toHaveBeenCalledTimes(1);
expect(
jest.mocked(slack.postMessageToWebhook).mock.calls[0][1].text,
).toContain('5 meets or exceeds 4');
});

it('TILE alert (metrics, formula) - evaluates the formula even when the tile shows its input series', async () => {
const now = new Date('2023-11-16T22:12:00.000Z');
// Alert window is [22:05, 22:10)
const eventMs = now.getTime() - ms('7m');

await bulkInsertMetricsGauge([
gaugePoint('test.requests', 200, eventMs),
gaugePoint('test.errors', 10, eventMs),
]);

const { details, connection, teamWebhooksById, clickhouseClient } =
await setupMetricTileAlert({
select: [
{
aggFn: 'max',
valueExpression: 'Value',
metricType: 'gauge',
metricName: 'test.requests',
},
{
aggFn: 'max',
valueExpression: 'Value',
metricType: 'gauge',
metricName: 'test.errors',
},
],
formulas: [{ expression: 'B / A * 100' }],
// showOperandSeries left unset: the dashboard tile renders the raw
// operand series alongside the formula, but the alert still
// evaluates the formula value only.
threshold: 4,
});

await processAlertAtTime(
now,
details,
clickhouseClient,
connection.id,
alertProvider,
teamWebhooksById,
);

expect((await Alert.findById(details.alert.id))!.state).toBe('ALERT');

const [history] = await AlertHistory.find({
alert: details.alert.id,
}).sort({ createdAt: 1 });
expect(history.lastValues.length).toBe(1);
expect(history.lastValues[0].count).toBe(5);

expect(slack.postMessageToWebhook).toHaveBeenCalledTimes(1);
expect(
jest.mocked(slack.postMessageToWebhook).mock.calls[0][1].text,
).toContain('5 meets or exceeds 4');
});

it('TILE alert (metrics, formula) - a zero denominator yields NULL and is skipped without NaN history', async () => {
const now = new Date('2023-11-16T22:12:00.000Z');
// Alert window is [22:05, 22:10)
const eventMs = now.getTime() - ms('7m');

await bulkInsertMetricsGauge([
// Numerator present, denominator zero: A / nullif(B, 0) is NULL.
gaugePoint('test.errors', 5, eventMs),
gaugePoint('test.requests', 0, eventMs),
]);

const { details, connection, teamWebhooksById, clickhouseClient } =
await setupMetricTileAlert({
select: [
{
aggFn: 'max',
valueExpression: 'Value',
metricType: 'gauge',
metricName: 'test.errors',
},
{
aggFn: 'max',
valueExpression: 'Value',
metricType: 'gauge',
metricName: 'test.requests',
},
],
formulas: [{ expression: 'A / B' }],
showOperandSeries: false,
threshold: 0.1,
});

await processAlertAtTime(
now,
details,
clickhouseClient,
connection.id,
alertProvider,
teamWebhooksById,
);

expect((await Alert.findById(details.alert.id))!.state).toBe('OK');

const histories = await AlertHistory.find({ alert: details.alert.id });
expect(histories.length).toBe(1);
expect(histories[0].state).toBe('OK');
expect(histories[0].counts).toBe(0);
expect(histories[0].lastValues.length).toBe(0);

expect(slack.postMessageToWebhook).not.toHaveBeenCalled();
});

it('TILE alert (metrics, grouped ratio) - honors ratioMode share_of_total', async () => {
const now = new Date('2023-11-16T22:12:00.000Z');
// Alert window is [22:05, 22:10)
const eventMs = now.getTime() - ms('7m');

await bulkInsertMetricsGauge([
// share_of_total divides each group's numerator by the denominator
// total across ALL groups in the bucket:
// service-a: 5 / (10 + 30) = 0.125
// service-b: 3 / (10 + 30) = 0.075
// per_group (the default this config must NOT fall back to) would be
// 0.5 and 0.1 instead.
gaugePoint('test.errors', 5, eventMs, 'service-a'),
gaugePoint('test.requests', 10, eventMs, 'service-a'),
gaugePoint('test.errors', 3, eventMs, 'service-b'),
gaugePoint('test.requests', 30, eventMs, 'service-b'),
]);

const { details, connection, teamWebhooksById, clickhouseClient } =
await setupMetricTileAlert({
select: [
{
aggFn: 'max',
valueExpression: 'Value',
metricType: 'gauge',
metricName: 'test.errors',
},
{
aggFn: 'max',
valueExpression: 'Value',
metricType: 'gauge',
metricName: 'test.requests',
},
],
seriesReturnType: 'ratio',
ratioMode: 'share_of_total',
groupBy: 'ServiceName',
// Only service-a's share (0.125) crosses; under per_group both
// values (0.5, 0.1) would differ and service-a would fire at 0.5.
threshold: 0.12,
});

await processAlertAtTime(
now,
details,
clickhouseClient,
connection.id,
alertProvider,
teamWebhooksById,
);

expect((await Alert.findById(details.alert.id))!.state).toBe('ALERT');

const alertingHistories = await AlertHistory.find({
alert: details.alert.id,
state: 'ALERT',
});
expect(alertingHistories.length).toBe(1);
expect(alertingHistories[0].group).toContain('service-a');
expect(alertingHistories[0].lastValues.length).toBe(1);
// The share-of-total value — not service-a's per-group ratio (0.5).
expect(alertingHistories[0].lastValues[0].count).toBe(0.125);

expect(slack.postMessageToWebhook).toHaveBeenCalledTimes(1);
// The message value is rounded to the threshold's decimal places
// (0.125 -> "0.13"); the exact-value assertion above is the source of
// truth for which ratio mode was evaluated.
expect(
jest.mocked(slack.postMessageToWebhook).mock.calls[0][1].text,
).toContain('meets or exceeds 0.12');
});

// The auto-resolve logic ensures that if a subsequent bucket within the same tick drops below the threshold,
// the alert state resets to OK, even though an alert notification might have already fired for the earlier bucket.
it('should check 3 time buckets [1 error, 3 errors, 1 error] with threshold 2 and auto-resolve to OK state with 3 lastValues entries', async () => {
Expand Down
11 changes: 11 additions & 0 deletions packages/api/src/tasks/checkAlerts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -716,6 +716,17 @@ const getChartConfigFromAlert = (
where: tile.config.where,
whereLanguage: tile.config.whereLanguage,
seriesReturnType: tile.config.seriesReturnType,
// Grouped ratios can divide per-group or share-of-total; without this
// the alert would silently evaluate the default (per-group) mode.
ratioMode: tile.config.ratioMode,
// Metric formulas (HDX-5080): the alert must evaluate the derived
// formula column, not a raw operand series. Operand columns are
// always dropped from the alert query — regardless of the tile's
// "Show input series" display toggle — so the formula is the value
// column parseAlertData picks (the last one wins, consistent with
// the multi-series "last series drives the alert" semantics).
formulas: tile.config.formulas,
...(tile.config.formulas?.length ? { showOperandSeries: false } : {}),
variables,
};
}
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/__tests__/SessionSidePanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ const mockNuqs: {

jest.mock('nuqs', () => {
const actual = jest.requireActual('nuqs');
// eslint-disable-next-line @typescript-eslint/no-empty-function

const noop = () => {};
return {
...actual,
Expand Down
Loading
Loading