Skip to content
6 changes: 6 additions & 0 deletions .changeset/detached-inline-alerts-backend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@hyperdx/common-utils': minor
'@hyperdx/api': minor
---

Add a new `inline` alert source that persists its own chart config directly on the alert, so alerts no longer require a saved search (logs) or a dashboard tile (metrics). The config is the same shape a dashboard tile stores — builder configs on log/trace/metric sources plus raw SQL (Line/Stacked Bar/Number display types); PromQL is rejected. The internal alerts API accepts and returns the new source, and the check-alerts task evaluates inline alerts through the same code path as tile alerts (including group-by and multi-window behavior). Notifications for inline alerts link to the chart explorer seeded with the alert's config over the alerting window, and default their title to the config's name. Backend only — the creation/edit UI and external API v2 support land separately.
57 changes: 56 additions & 1 deletion packages/api/src/controllers/__tests__/alerts.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import {
AlertChartConfig,
DisplayType,
} from '@hyperdx/common-utils/dist/types';

import { makeAlert } from '@/controllers/alerts';
import { AlertChannel, AlertThresholdType } from '@/models/alert';
import { AlertChannel, AlertSource, AlertThresholdType } from '@/models/alert';

// A channel type this repo doesn't define -- see
// models/__tests__/alert.test.ts for why. `value: any` (rather than an `as`
Expand Down Expand Up @@ -28,4 +33,54 @@ describe('makeAlert', () => {
expect(result.channel).toEqual(exotic);
expect(result.channels).toEqual([exotic]);
});

const chartConfig: AlertChartConfig = {
name: 'Errors',
source: 'source-id',
displayType: DisplayType.Line,
select: [
{
aggFn: 'count',
aggCondition: '',
aggConditionLanguage: 'lucene',
valueExpression: '',
},
],
where: '',
whereLanguage: 'lucene',
};

it('persists chartConfig for inline alerts and clears the other source references', () => {
const result = makeAlert({
interval: '5m',
threshold: 1,
thresholdType: AlertThresholdType.ABOVE,
channels: [{ type: 'webhook', webhookId: 'webhook-id' }],
source: AlertSource.INLINE,
chartConfig,
});

expect(result.chartConfig).toEqual(chartConfig);
expect(result.savedSearch).toBeNull();
expect(result.groupBy).toBeNull();
expect(result.dashboard).toBeNull();
expect(result.tileId).toBeNull();
});

it('clears chartConfig when the alert source is not chart', () => {
// Converting an inline alert to another source must not leave the old
// config behind (mirrors how savedSearch/dashboard references clear).
const result = makeAlert({
interval: '5m',
threshold: 1,
thresholdType: AlertThresholdType.ABOVE,
channels: [{ type: 'webhook', webhookId: 'webhook-id' }],
source: AlertSource.SAVED_SEARCH,
savedSearchId: 'saved-search-id',
chartConfig,
});

expect(result.chartConfig).toBeNull();
expect(result.savedSearch).toBe('saved-search-id');
});
});
94 changes: 91 additions & 3 deletions packages/api/src/controllers/alerts.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import {
displayTypeSupportsBuilderAlerts,
displayTypeSupportsRawSqlAlerts,
validateRawSqlForAlert,
} from '@hyperdx/common-utils/dist/core/utils';
import { isRawSqlSavedChartConfig } from '@hyperdx/common-utils/dist/guards';
import { groupBy } from 'lodash';
import { Types } from 'mongoose';
import { z } from 'zod';

import type { ObjectId } from '@/models';
Expand All @@ -13,12 +15,14 @@ import Alert, {
getAlertChannels,
IAlert,
} from '@/models/alert';
import Connection from '@/models/connection';
import Dashboard, { IDashboard } from '@/models/dashboard';
import { ISavedSearch, SavedSearch } from '@/models/savedSearch';
import { Source } from '@/models/source';
import { IUser } from '@/models/user';
import Webhook from '@/models/webhook';
import { Api400Error } from '@/utils/errors';
import { alertSchema, objectIdSchema } from '@/utils/zod';
import { internalAlertSchema, objectIdSchema } from '@/utils/zod';

export type AlertInput = Omit<
IAlert,
Expand Down Expand Up @@ -57,6 +61,7 @@ export const validateAlertInput = async (
| 'dashboardId'
| 'tileId'
| 'savedSearchId'
| 'chartConfig'
| 'channel'
| 'channels'
>,
Expand Down Expand Up @@ -108,6 +113,85 @@ export const validateAlertInput = async (
}
}

if (alertInput.source === AlertSource.INLINE) {
const chartConfig = alertInput.chartConfig;
if (chartConfig == null) {
throw new Api400Error('Chart config is required');
}

if (isRawSqlSavedChartConfig(chartConfig)) {
if (!displayTypeSupportsRawSqlAlerts(chartConfig.displayType)) {
throw new Api400Error(
'Alerts on Raw SQL charts are only supported for Line, Stacked Bar, or Number display types',
);
}

const { errors } = validateRawSqlForAlert(chartConfig);
if (errors.length > 0) {
throw new Api400Error(
`Raw SQL alert query is invalid: ${errors.join(', ')}`,
);
}

// Raw SQL configs carry the connection directly; the source reference
// is optional metadata for macro expansion ($__sourceTable).
validateObjectId(chartConfig.connection, 'Invalid connection ID');
const connection = await Connection.findOne({
_id: chartConfig.connection,
team: teamId,
});
if (connection == null) {
throw new Api400Error('Connection not found');
}
if (chartConfig.source) {
validateObjectId(chartConfig.source, 'Invalid source ID');
const source = await Source.findOne({
_id: chartConfig.source,
team: teamId,
});
if (source == null) {
throw new Api400Error('Source not found');
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
// The worker executes the query through chartConfig.connection while
// expanding $__sourceTable/metricTables from this source. Accepting a
// source on a different (even team-owned) connection would query the
// wrong database — silently wrong values when the table also exists
// there, repeated query failures when it does not.
//
// Compare as ObjectIds, not strings: objectIdSchema admits every
// representation ObjectId.isValid does (uppercase hex, 12-byte
// strings), and the Mongo lookups above cast them — a lexical
// comparison would reject an equivalent non-canonical ID.
if (
!new Types.ObjectId(String(source.connection)).equals(
chartConfig.connection,
)
) {
Comment thread
wrn14897 marked this conversation as resolved.
Resolved
Comment on lines +165 to +169

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.

P1 Uppercase ObjectIds remain rejected

When a raw-SQL inline alert supplies its connection as an equivalent uppercase 24-character ObjectId, ObjectId.equals receives the original uppercase string and compares it against the lowercase hexadecimal representation, causing the API to reject a valid source/connection pair. The same comparison in the worker can discard valid source metadata from an already persisted noncanonical config.

Knowledge Base Used: API services and background tasks

Fix in Claude Code Fix in Conductor Fix in Cursor Fix in Codex

throw new Api400Error(
'Source does not belong to the specified connection',
);
}
}
} else {
// Builder configs: same display types the alert task can evaluate as a
// time series (mirrors the tile-alert rules).
if (!displayTypeSupportsBuilderAlerts(chartConfig.displayType)) {
throw new Api400Error(
'Inline chart alerts are only supported for Line, Stacked Bar, or Number display types',
);
}

validateObjectId(chartConfig.source, 'Invalid source ID');
const source = await Source.findOne({
_id: chartConfig.source,
team: teamId,
});
if (source == null) {
throw new Api400Error('Source not found');
}
}
}

const channels = getAlertChannels(alertInput);
if (channels.length === 0) {
throw new Api400Error('At least one notification channel is required');
Expand Down Expand Up @@ -150,6 +234,7 @@ export const makeAlert = (
: alert.scheduleOffsetMinutes;
const isSavedSearch = alert.source === AlertSource.SAVED_SEARCH;
const isTile = alert.source === AlertSource.TILE;
const isInline = alert.source === AlertSource.INLINE;
const channels = getAlertChannels(alert);

return {
Expand Down Expand Up @@ -184,20 +269,23 @@ export const makeAlert = (
? ((alert.savedSearchId ?? null) as unknown as ObjectId)
: null,
groupBy: isSavedSearch ? (alert.groupBy ?? null) : null,
// Chart alerts
// Tile alerts
dashboard: isTile
? ((alert.dashboardId ?? null) as unknown as ObjectId)
: null,
tileId: isTile ? (alert.tileId ?? null) : null,

// Inline alerts
chartConfig: isInline ? (alert.chartConfig ?? null) : null,

// Multi-window alerting
numConsecutiveWindows: alert.numConsecutiveWindows ?? null,
};
};

export const createAlert = async (
teamId: ObjectId,
alertInput: z.infer<typeof alertSchema>,
alertInput: z.infer<typeof internalAlertSchema>,
userId: ObjectId,
) => {
return new Alert({
Expand Down
46 changes: 46 additions & 0 deletions packages/api/src/fixtures.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createNativeClient } from '@hyperdx/common-utils/dist/clickhouse/node';
import {
AlertChartConfig,
AlertThresholdType,
BuilderSavedChartConfig,
DisplayType,
Expand Down Expand Up @@ -780,3 +781,48 @@ export const makeSavedSearchAlertInput = ({
source: AlertSource.SAVED_SEARCH,
savedSearchId,
});

export const makeAlertChartConfig = (opts: {
sourceId: string;
name?: string;
displayType?: DisplayType;
aggCondition?: string;
groupBy?: string;
}): AlertChartConfig => ({
name: opts.name ?? 'Chart Alert Query',
source: opts.sourceId,
displayType: opts.displayType ?? DisplayType.Line,
select: [
{
aggFn: 'count',
aggCondition: opts.aggCondition ?? '',
aggConditionLanguage: 'lucene',
valueExpression: '',
},
],
where: '',
whereLanguage: 'lucene',
...(opts.groupBy != null && { groupBy: opts.groupBy }),
});

export const makeInlineAlertInput = ({
chartConfig,
interval = '15m',
threshold = 8,
webhookId = 'test-webhook-id',
}: {
chartConfig: AlertChartConfig;
interval?: AlertInterval;
threshold?: number;
webhookId?: string;
}): Partial<AlertInput> => ({
channel: {
type: 'webhook',
webhookId,
},
interval,
threshold,
thresholdType: AlertThresholdType.ABOVE,
source: AlertSource.INLINE,
chartConfig,
});
13 changes: 13 additions & 0 deletions packages/api/src/models/alert.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
ALERT_INTERVAL_TO_MINUTES,
AlertChartConfig,
AlertErrorType,
AlertThresholdType,
} from '@hyperdx/common-utils/dist/types';
Expand Down Expand Up @@ -71,6 +72,8 @@ export const getAlertChannels = (alert: {
export enum AlertSource {
SAVED_SEARCH = 'saved_search',
TILE = 'tile',
/** Detached alert whose chart config lives inline on the alert (no saved search/tile). */
INLINE = 'inline',
}

export interface IAlert {
Expand Down Expand Up @@ -104,6 +107,10 @@ export interface IAlert {
dashboard?: ObjectId | null;
tileId?: string | null;

// Inline alerts: the persisted chart config (same shape as a dashboard
// tile's config, minus the embedded alert field)
chartConfig?: AlertChartConfig | null;

// Silenced
silenced?: {
by?: ObjectId;
Expand Down Expand Up @@ -224,6 +231,12 @@ const AlertSchema = new Schema<IAlert>(
type: String,
required: false,
},

// Inline alerts
chartConfig: {
type: Schema.Types.Mixed,
required: false,
},
numConsecutiveWindows: {
type: Number,
required: false,
Expand Down
Loading
Loading