Skip to content
Open
7 changes: 7 additions & 0 deletions .changeset/fix-alert-marker-bucket-alignment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@hyperdx/common-utils": patch
"@hyperdx/api": patch
"@hyperdx/app": patch
---

Align alert firing/recovery chart markers with the evaluated data: markers are now drawn at the start of the newest evaluated bucket (matching the evaluation history table and the plotted data point) instead of at the evaluation time, which sat one bucket to the right.
112 changes: 112 additions & 0 deletions packages/api/src/controllers/__tests__/alertHistory.int.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1262,5 +1262,117 @@ describe('alertHistory controller', () => {
expect(transitions).toHaveLength(1);
expect(transitions[0].state).toBe(AlertState.ALERT);
});

describe('bucketStart', () => {
const createHistoryWithBuckets = (
Comment thread
wrn14897 marked this conversation as resolved.
alertId: any,
createdAt: Date,
state: AlertState,
bucketStarts: Date[],
group?: string,
) =>
AlertHistory.create({
alert: alertId,
createdAt,
state,
counts: state === AlertState.ALERT ? 1 : 0,
...(group != null ? { group } : {}),
lastValues: bucketStarts.map(startTime => ({
startTime,
count: 1,
})),
});

it('emits the newest evaluated bucket start for each transition', async () => {
// Evaluation at t(20) covered 1-minute buckets t(25)..t(21) (anomaly
// alerts bucket finer than the interval) — the marker belongs on the
// newest bucket, where the chart plots the transitioning value.
const alert = await createAlert();
await createHistory(alert._id, t(25), AlertState.OK, 0);
await createHistoryWithBuckets(alert._id, t(20), AlertState.ALERT, [
t(25),
t(24),
t(23),
t(22),
t(21),
]);

const transitions = await getAlertTransitionsInRange({
alertId: new ObjectId(alert._id),
interval: '5m',
startTime: t(22),
endTime: t(5),
});

expect(transitions).toHaveLength(1);
expect(transitions[0].createdAt).toBe(t(20).toISOString());
expect(transitions[0].bucketStart).toBe(t(21).toISOString());
});

it('falls back to createdAt − interval when the window has no lastValues', async () => {
const alert = await createAlert();
await createHistory(alert._id, t(25), AlertState.OK, 0);
await createHistoryWithBuckets(alert._id, t(20), AlertState.ALERT, []);

const transitions = await getAlertTransitionsInRange({
alertId: new ObjectId(alert._id),
interval: '5m',
startTime: t(22),
endTime: t(5),
});

expect(transitions).toHaveLength(1);
expect(transitions[0].bucketStart).toBe(t(25).toISOString());
});

it('takes the newest bucket across group rows in one window', async () => {
// Group-by alerts write one history row per group; the marker should
// reflect the newest bucket evaluated by any of them.
const alert = await createAlert();
await createHistory(alert._id, t(25), AlertState.OK, 0);
await createHistoryWithBuckets(
alert._id,
t(20),
AlertState.ALERT,
[t(23)],
'group-a',
);
await createHistoryWithBuckets(
alert._id,
t(20),
AlertState.OK,
[t(21)],
'group-b',
);

const transitions = await getAlertTransitionsInRange({
alertId: new ObjectId(alert._id),
interval: '5m',
startTime: t(22),
endTime: t(5),
});

expect(transitions).toHaveLength(1);
expect(transitions[0].state).toBe(AlertState.ALERT);
expect(transitions[0].bucketStart).toBe(t(21).toISOString());
});

it('pins the carry-in marker bucketStart to the range start', async () => {
const alert = await createAlert();
await createHistory(alert._id, t(30), AlertState.ALERT, 5);
await createHistory(alert._id, t(25), AlertState.ALERT, 5);

const startTime = t(27);
const transitions = await getAlertTransitionsInRange({
alertId: new ObjectId(alert._id),
interval: '5m',
startTime,
endTime: t(5),
});

expect(transitions).toHaveLength(1);
expect(transitions[0].bucketStart).toBe(startTime.toISOString());
});
});
});
});
65 changes: 45 additions & 20 deletions packages/api/src/controllers/alertHistory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -462,11 +462,15 @@ export async function getRecentAlertHistoriesBatch(

/**
* Returns alert firing/recovery transitions (ALERT-boundary crossings) within
* [startTime, endTime] for one alert, for drawing chart annotations. One window
* before startTime is fetched to know the state on entry: if the alert is
* already firing then, a firing marker is pinned to startTime so a later
* in-range recovery isn't orphaned. PENDING/INSUFFICIENT_DATA count as
* non-firing, so only ALERT crossings are reported.
* [startTime, endTime] for one alert, for drawing chart annotations. Each
* transition carries `bucketStart` — the start of the newest bucket the
* transitioning evaluation covered — so markers land on the data point that
* produced the transition (charts plot buckets at their start, while the
* evaluation runs at the bucket end). One window before startTime is fetched
* to know the state on entry: if the alert is already firing then, a firing
* marker is pinned to startTime so a later in-range recovery isn't orphaned.
* PENDING/INSUFFICIENT_DATA count as non-firing, so only ALERT crossings are
* reported.
*/
export async function getAlertTransitionsInRange({
alertId,
Expand All @@ -482,22 +486,35 @@ export async function getAlertTransitionsInRange({
const intervalMs = intervalToMs(interval);
const lookbackStart = new Date(startTime.getTime() - intervalMs);

// Only the per-window state is needed to detect crossings. ERROR rows are
// failed evaluations, not state observations — excluding them prevents a
// Per-window state detects crossings; the newest evaluated bucket start
// positions the marker where the chart plots that bucket's value. ERROR rows
// are failed evaluations, not state observations — excluding them prevents a
// query failure mid-firing from drawing a false recovery annotation.
const windows = await AlertHistory.aggregate<{ _id: Date; states: string[] }>(
[
{
$match: {
alert: new ObjectId(alertId),
createdAt: { $gte: lookbackStart, $lte: endTime },
state: { $ne: AlertState.ERROR },
},
const windows = await AlertHistory.aggregate<{
_id: Date;
states: string[];
// Newest lastValues.startTime across the window's rows (one per group for
// group-by alerts); null when no row carries lastValues.
lastBucketStart: Date | null;
}>([
{
$match: {
alert: new ObjectId(alertId),
createdAt: { $gte: lookbackStart, $lte: endTime },
state: { $ne: AlertState.ERROR },
},
{ $group: { _id: '$createdAt', states: { $push: '$state' } } },
{ $sort: { _id: 1 } },
],
);
},
{
$group: {
_id: '$createdAt',
states: { $push: '$state' },
// Inner $max traverses each row's lastValues array; the accumulator
// takes the max across rows and ignores nulls (empty arrays).
lastBucketStart: { $max: { $max: '$lastValues.startTime' } },
},
},
{ $sort: { _id: 1 } },
]);

const transitions: AlertTransition[] = [];
// Assume "not firing" before the earliest known window, so an alert whose
Expand All @@ -506,12 +523,14 @@ export async function getAlertTransitionsInRange({
let enteredRange = false;

// Pin a firing marker to the range start if the alert was already firing on
// entry (carried in from before startTime).
// entry (carried in from before startTime). The marker is synthetic ("firing
// when the window opens"), so it carries no bucketStart of its own.
const pinCarryInIfFiring = () => {
if (prevIsAlert) {
transitions.push({
createdAt: startTime.toISOString(),
state: AlertState.ALERT,
bucketStart: startTime.toISOString(),
});
}
};
Expand All @@ -527,9 +546,15 @@ export async function getAlertTransitionsInRange({
}

if (inRange && isAlert !== prevIsAlert) {
// Fall back to createdAt − interval when the window has no lastValues
// (mirrors the evaluation table's fallback for failed evaluations).
const bucketStart =
evalWindow.lastBucketStart ??
new Date(evalWindow._id.getTime() - intervalMs);
transitions.push({
createdAt: evalWindow._id.toISOString(),
state: isAlert ? AlertState.ALERT : AlertState.OK,
bucketStart: bucketStart.toISOString(),
});
}

Expand Down
27 changes: 26 additions & 1 deletion packages/app/src/hooks/__tests__/useAlertAnnotations.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import { getChartColorError, getChartColorSuccess } from '@/utils';
const makeTransition = (
createdAt: string,
state: AlertState,
): AlertTransition => ({ createdAt, state });
bucketStart?: string,
): AlertTransition => ({ createdAt, state, bucketStart });

describe('alertTransitionsToAnnotations', () => {
it('returns no annotations for an empty list', () => {
Expand Down Expand Up @@ -41,6 +42,30 @@ describe('alertTransitionsToAnnotations', () => {
// Distinct keys so React can reconcile the markers.
expect(annotations[0].key).not.toEqual(annotations[1].key);
});

it('draws the marker at bucketStart so it lines up with the plotted bucket', () => {
// Evaluation ran at 00:30 over buckets whose newest starts at 00:29 —
// the chart plots that bucket at 00:29, so the marker must sit there too.
const evaluatedAt = '2026-07-01T00:30:00.000Z';
const bucketStart = '2026-07-01T00:29:00.000Z';

const annotations = alertTransitionsToAnnotations([
makeTransition(evaluatedAt, AlertState.OK, bucketStart),
]);

expect(annotations).toHaveLength(1);
expect(annotations[0]).toMatchObject({ time: bucketStart, label: 'OK' });
});

it('falls back to createdAt when bucketStart is absent (older API)', () => {
const evaluatedAt = '2026-07-01T00:30:00.000Z';

const annotations = alertTransitionsToAnnotations([
makeTransition(evaluatedAt, AlertState.ALERT),
]);

expect(annotations[0]).toMatchObject({ time: evaluatedAt });
});
});

describe('useAlertAnnotations', () => {
Expand Down
12 changes: 9 additions & 3 deletions packages/app/src/hooks/useAlertAnnotations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ import { getChartColorError, getChartColorSuccess } from '@/utils';
/**
* Maps alert state transitions to generic chart annotations: firing (→ ALERT)
* is a red "Alert" marker, recovery (→ OK) a green "OK" marker. Colors come
* from the theme's semantic chart palette (error / success).
* from the theme's semantic chart palette (error / success). Markers are drawn
* at the transition's `bucketStart` — charts plot each bucket's value at its
* start, while the evaluation runs at the bucket end (`createdAt`) — so the
* marker lines up with the data point that produced the transition (and with
* the evaluation table, which shows the same bucket start). `createdAt` is the
* fallback for older API responses without `bucketStart`.
*/
export function alertTransitionsToAnnotations(
transitions: AlertTransition[],
Expand All @@ -18,11 +23,12 @@ export function alertTransitionsToAnnotations(
const okColor = getChartColorSuccess();
return transitions.map(transition => {
const isFiring = transition.state === AlertState.ALERT;
const time = transition.bucketStart ?? transition.createdAt;
return {
time: transition.createdAt,
time,
label: isFiring ? 'Alert' : 'OK',
color: isFiring ? alertColor : okColor,
key: `alert-annotation-${transition.createdAt}-${transition.state}`,
key: `alert-annotation-${time}-${transition.state}`,
};
});
}
Expand Down
6 changes: 6 additions & 0 deletions packages/common-utils/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -884,6 +884,12 @@ export const ALERT_EVALUATION_GROUPS_LIMIT = 50;
export const AlertTransitionSchema = z.object({
createdAt: z.string(),
state: z.nativeEnum(AlertState),
// Start of the newest bucket evaluated by the transitioning window. Charts
// plot each bucket's value at its *start*, while the evaluation runs at the
// bucket *end* (createdAt) — markers drawn at bucketStart line up with the
// data point that produced the transition. Optional for compatibility with
// older API responses; consumers fall back to createdAt.
bucketStart: z.string().optional(),
});

export type AlertTransition = z.infer<typeof AlertTransitionSchema>;
Expand Down
Loading