Repository navigation
feat(alerts): add per-group acknowledgments for grouped alerts #2696
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| --- | ||
| '@hyperdx/api': patch | ||
| '@hyperdx/app': patch | ||
| --- | ||
|
|
||
| Add per-group alert acknowledgments and group resume overrides so grouped alerts can mute or resume notifications for one group without changing other groups or the aggregate alert state. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,6 +19,12 @@ type GroupedAlertHistory = { | |
| lastValues: IAlertHistory['lastValues'][]; | ||
| }; | ||
|
|
||
| type AlertGroupHistorySummary = { | ||
| group: string; | ||
| state: AlertState; | ||
| history: Omit<IAlertHistory, 'alert' | 'group'>[]; | ||
| }; | ||
|
|
||
| function groupStateToOverallState(states: string[]): AlertState { | ||
| if (states.includes(AlertState.ALERT)) { | ||
| return AlertState.ALERT; | ||
|
|
@@ -44,6 +50,17 @@ function mapGroupedHistories( | |
| })); | ||
| } | ||
|
|
||
| function mapHistoryEntry( | ||
| history: Pick<IAlertHistory, 'createdAt' | 'counts' | 'lastValues' | 'state'>, | ||
| ): Omit<IAlertHistory, 'alert' | 'group'> { | ||
| return { | ||
| createdAt: history.createdAt, | ||
| counts: history.counts, | ||
| lastValues: history.lastValues, | ||
| state: history.state, | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Gets the most recent alert histories for a given alert ID, | ||
| * limiting to the given number of entries. | ||
|
|
@@ -131,6 +148,82 @@ export async function getRecentAlertHistoriesBatch( | |
| ); | ||
| } | ||
|
|
||
| export async function getRecentAlertGroupSummaries({ | ||
| alertId, | ||
| interval, | ||
| limit, | ||
| }: { | ||
| alertId: ObjectId; | ||
| interval: AlertInterval; | ||
| limit: number; | ||
| }): Promise<AlertGroupHistorySummary[]> { | ||
| const lookbackMs = limit * ALERT_INTERVAL_TO_MINUTES[interval] * 60 * 1000; | ||
|
|
||
| const histories = await AlertHistory.find({ | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. issue (blocking): this can make the alerts page unusable for an entire team, and it gets worse silently as their telemetry grows. Why this blocks. All of that is pulled into the API process heap and serialised into a single response. So the failure isn't "the page is a bit slow" — it's heap pressure on a shared API process and a response large enough to make the alerts page unusable for everyone on the team, at exactly the moment they need it. It also degrades silently and gradually: this will pass review, pass staging, and fall over first in your largest production tenant, where the diagnosis is hardest. What's happening. const histories = await AlertHistory.find({
alert: new ObjectId(alertId),
createdAt: { $gte: new Date(Date.now() - lookbackMs) },
group: { $type: 'string' },
}).sort({ group: 1, createdAt: -1 }).lean();No Fix. Mirror the sibling with const MAX_ALERT_GROUP_SUMMARIES = 50;
const histories = await AlertHistory.find({ ... })
.sort({ group: 1, createdAt: -1 })
.limit(MAX_ALERT_GROUP_SUMMARIES * limit) // backstop; group:1 sort truncates by group
.lean();
// ...
return Array.from(groupedHistories.values())
.sort(...)
.slice(0, MAX_ALERT_GROUP_SUMMARIES);Prefer an ordering that keeps ALERT-state groups when the cap bites, and return a |
||
| alert: new ObjectId(alertId), | ||
| createdAt: { $gte: new Date(Date.now() - lookbackMs) }, | ||
| group: { $type: 'string' }, | ||
| }) | ||
| .sort({ group: 1, createdAt: -1 }) | ||
| .lean(); | ||
|
|
||
| const groupedHistories = new Map<string, AlertGroupHistorySummary>(); | ||
|
|
||
| for (const history of histories) { | ||
| const group = history.group; | ||
|
|
||
| if (group == null) { | ||
| continue; | ||
| } | ||
|
|
||
| const existing = groupedHistories.get(group); | ||
|
|
||
| if (existing == null) { | ||
| groupedHistories.set(group, { | ||
| group, | ||
| state: history.state, | ||
| history: [mapHistoryEntry(history)], | ||
| }); | ||
| continue; | ||
| } | ||
|
|
||
| if (existing.history.length < limit) { | ||
| existing.history.push(mapHistoryEntry(history)); | ||
| } | ||
| } | ||
|
|
||
| return Array.from(groupedHistories.values()).sort( | ||
| (a, b) => | ||
| b.history[0]!.createdAt.getTime() - a.history[0]!.createdAt.getTime(), | ||
| ); | ||
| } | ||
|
|
||
| export async function getRecentAlertGroupSummariesBatch( | ||
| alerts: { alertId: ObjectId; interval: AlertInterval }[], | ||
| limit: number, | ||
| ): Promise<Map<string, AlertGroupHistorySummary[]>> { | ||
| const queue = new PQueue({ concurrency: ALERT_HISTORY_QUERY_CONCURRENCY }); | ||
|
|
||
| const entries = await Promise.all( | ||
| alerts.map(({ alertId, interval }) => | ||
| queue.add(async () => { | ||
| const summaries = await getRecentAlertGroupSummaries({ | ||
| alertId, | ||
| interval, | ||
| limit, | ||
| }); | ||
| return [alertId.toString(), summaries] as const; | ||
| }), | ||
| ), | ||
| ); | ||
|
|
||
| return new Map( | ||
| entries.filter( | ||
| (e): e is [string, AlertGroupHistorySummary[]] => e !== undefined, | ||
| ), | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Returns alert firing/recovery transitions (ALERT-boundary crossings) within | ||
| * [startTime, endTime] for one alert, for drawing chart annotations. One window | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
suggestion: Given this is a new feature being added to the app, I'd say this is a minor change, not a patch bump. See: https://semver.org/