Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
c1b8292
feat: add support for `scheduleStartAt` and `scheduleOffsetMinutes` i…
melsalcedo Feb 9, 2026
ffa5bf2
Update packages/api/src/controllers/alerts.ts
mlsalcedo Feb 17, 2026
7f1506c
Update packages/api/src/controllers/alerts.ts
mlsalcedo Feb 17, 2026
8408688
refactor: simplify `scheduleStartAt` validation using `fns.isValid` f…
melsalcedo Feb 17, 2026
bf4b847
feat: improve alert scheduling by updating `scheduleStartAt` validati…
melsalcedo Feb 17, 2026
799c84c
Merge branch 'main' into startTime-offset
teeohhem Feb 19, 2026
03c07cb
refactor(alerts): dedupe interval and offset validation
melsalcedo Feb 19, 2026
f8b379e
feat(app): use DateTimePicker for scheduleStartAt input
melsalcedo Feb 20, 2026
ea4ab24
feat(app): add datetime picker to dashboard alert start time
melsalcedo Feb 20, 2026
dfadc7e
refactor(alerts): share scheduleStartAt schema and parser
melsalcedo Feb 20, 2026
b903e61
chore(alerts): clarify anchor start label in forms
melsalcedo Feb 20, 2026
d1a028d
fix(alerts): handle null scheduleStartAt and add scheduling guards
melsalcedo Feb 20, 2026
29af34b
chore(alerts): address review feedback for schedule start
melsalcedo Feb 20, 2026
30c4865
chore(alerts): normalize schedule defaults and 1m offset UX
melsalcedo Feb 20, 2026
32a7267
refactor(alerts): centralize schedule fields and harden API validation
melsalcedo Feb 20, 2026
539b531
fix(alerts): avoid no-op schedule writes for existing alerts
melsalcedo Feb 20, 2026
41e0c26
refactor(alerts): share no-op schedule normalization helper
melsalcedo Feb 20, 2026
6e31044
fix(alerts): tighten scheduleStartAt validation and defaults
melsalcedo Feb 20, 2026
d1498fc
fix(alerts): guard new-alert schedule normalization
melsalcedo Feb 20, 2026
fe52938
chore(alerts): tighten copy and offset bounds
melsalcedo Feb 20, 2026
f7cceef
chore(alerts): clarify offset behavior with anchored start
melsalcedo Feb 20, 2026
6a4c407
chore(alerts): guard anchor bounds and document zod coupling
melsalcedo Feb 20, 2026
be098f9
feat(alerts): enforce anchored offset semantics across layers
melsalcedo Feb 20, 2026
7d502a2
fix(alerts): clear stale offset when scheduleStartAt is set
melsalcedo Feb 20, 2026
e718023
fix(alerts): preserve schema compatibility and harden no-op normaliza…
melsalcedo Feb 20, 2026
6c96d66
fix(common-utils): keep validated alert schemas internal
melsalcedo Feb 20, 2026
2d29bb5
fix(alerts): tighten external validation and clear stale offsets
melsalcedo Feb 20, 2026
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
14 changes: 14 additions & 0 deletions packages/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,20 @@
"$ref": "#/components/schemas/AlertInterval",
"example": "1h"
},
"scheduleOffsetMinutes": {
"type": "integer",
"minimum": 0,
"description": "Offset from the interval boundary in minutes. For example, 2 with a 5m interval evaluates windows at :02, :07, :12, etc. (UTC).",
"nullable": true,
"example": 2
},
"scheduleStartAt": {
"type": "string",
"format": "date-time",
"description": "Absolute UTC start time anchor. Alert windows start from this timestamp and repeat every interval.",
"nullable": true,
"example": "2026-02-08T10:00:00.000Z"
},
"source": {
"$ref": "#/components/schemas/AlertSource",
"example": "tile"
Expand Down
14 changes: 14 additions & 0 deletions packages/api/src/controllers/alerts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export type AlertInput = {
source?: AlertSource;
channel: AlertChannel;
interval: AlertInterval;
scheduleOffsetMinutes?: number;
scheduleStartAt?: string | null;
thresholdType: AlertThresholdType;
threshold: number;

Expand All @@ -46,9 +48,21 @@ export type AlertInput = {
};

const makeAlert = (alert: AlertInput, userId?: ObjectId): Partial<IAlert> => {
const hasScheduleStartAt = Object.prototype.hasOwnProperty.call(
alert,
'scheduleStartAt',
);

return {
channel: alert.channel,
interval: alert.interval,
...(alert.scheduleOffsetMinutes != null && {
scheduleOffsetMinutes: alert.scheduleOffsetMinutes,
}),
...(hasScheduleStartAt && {
scheduleStartAt:
alert.scheduleStartAt == null ? null : new Date(alert.scheduleStartAt),
}),
source: alert.source,
threshold: alert.threshold,
thresholdType: alert.thresholdType,
Expand Down
11 changes: 11 additions & 0 deletions packages/api/src/models/alert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ export interface IAlert {
id: string;
channel: AlertChannel;
interval: AlertInterval;
scheduleOffsetMinutes?: number;
scheduleStartAt?: Date | null;
source?: AlertSource;
state: AlertState;
team: ObjectId;
Expand Down Expand Up @@ -88,6 +90,15 @@ const AlertSchema = new Schema<IAlert>(
type: String,
required: true,
},
scheduleOffsetMinutes: {
type: Number,
min: 0,
required: false,
},
scheduleStartAt: {
type: Date,
required: false,
},
channel: Schema.Types.Mixed, // slack, email, etc
state: {
type: String,
Expand Down
5 changes: 4 additions & 1 deletion packages/api/src/routers/api/alerts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
getAlertsEnhanced,
updateAlert,
} from '@/controllers/alerts';
import AlertSchema, { IAlert } from '@/models/alert';
import { alertSchema, objectIdSchema } from '@/utils/zod';

const router = express.Router();
Expand Down Expand Up @@ -67,6 +68,8 @@ router.get('/', async (req, res, next) => {
..._.pick(alert, [
'_id',
'interval',
'scheduleOffsetMinutes',
'scheduleStartAt',
'threshold',
'thresholdType',
'state',
Expand Down Expand Up @@ -96,7 +99,7 @@ router.post(
return res.sendStatus(403);
}
try {
const alertInput = req.body;
const alertInput = req.body as unknown as IAlert;
return res.json({
data: await createAlert(teamId, alertInput, userId),
});
Expand Down
12 changes: 12 additions & 0 deletions packages/api/src/routers/external-api/v2/alerts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,18 @@ import { alertSchema, objectIdSchema } from '@/utils/zod';
* interval:
* $ref: '#/components/schemas/AlertInterval'
* example: "1h"
* scheduleOffsetMinutes:
* type: integer
* minimum: 0
* description: Offset from the interval boundary in minutes. For example, 2 with a 5m interval evaluates windows at :02, :07, :12, etc. (UTC).
* nullable: true
* example: 2
* scheduleStartAt:
* type: string
* format: date-time
* description: Absolute UTC start time anchor. Alert windows start from this timestamp and repeat every interval.
* nullable: true
* example: "2026-02-08T10:00:00.000Z"
* source:
* $ref: '#/components/schemas/AlertSource'
* example: "tile"
Expand Down
40 changes: 40 additions & 0 deletions packages/api/src/tasks/checkAlerts/__tests__/checkAlerts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import * as checkAlert from '@/tasks/checkAlerts';
import {
doesExceedThreshold,
getPreviousAlertHistories,
getScheduledWindowStart,
processAlert,
} from '@/tasks/checkAlerts';
import {
Expand Down Expand Up @@ -122,6 +123,45 @@ describe('checkAlerts', () => {
});
});

describe('getScheduledWindowStart', () => {
it('should align to the default interval boundary when offset is 0', () => {
const now = new Date('2024-01-01T12:13:45.000Z');
const windowStart = getScheduledWindowStart(now, 5, 0);

expect(windowStart).toEqual(new Date('2024-01-01T12:10:00.000Z'));
});

it('should align to an offset boundary when schedule offset is provided', () => {
const now = new Date('2024-01-01T12:13:45.000Z');
const windowStart = getScheduledWindowStart(now, 5, 2);

expect(windowStart).toEqual(new Date('2024-01-01T12:12:00.000Z'));
});

it('should keep previous offset window until the next offset boundary', () => {
const now = new Date('2024-01-01T12:11:59.000Z');
const windowStart = getScheduledWindowStart(now, 5, 2);

expect(windowStart).toEqual(new Date('2024-01-01T12:07:00.000Z'));
});

it('should align windows using scheduleStartAt as an absolute anchor', () => {
const now = new Date('2024-01-01T12:13:45.000Z');
const scheduleStartAt = new Date('2024-01-01T12:02:30.000Z');
const windowStart = getScheduledWindowStart(now, 5, 0, scheduleStartAt);

expect(windowStart).toEqual(new Date('2024-01-01T12:12:30.000Z'));
});

it('should prioritize scheduleStartAt over offset alignment', () => {
const now = new Date('2024-01-01T12:13:45.000Z');
const scheduleStartAt = new Date('2024-01-01T12:02:30.000Z');
const windowStart = getScheduledWindowStart(now, 5, 2, scheduleStartAt);

expect(windowStart).toEqual(new Date('2024-01-01T12:12:30.000Z'));
});
});

describe('Alert Templates', () => {
// Create a mock metadata object with the necessary methods
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
Expand Down
149 changes: 145 additions & 4 deletions packages/api/src/tasks/checkAlerts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,95 @@ export const doesExceedThreshold = (
return false;
};

const normalizeScheduleOffsetMinutes = ({
alertId,
scheduleOffsetMinutes,
windowSizeInMins,
}: {
alertId: string;
scheduleOffsetMinutes: number | undefined;
windowSizeInMins: number;
}) => {
if (scheduleOffsetMinutes == null) {
return 0;
}

if (!Number.isFinite(scheduleOffsetMinutes)) {
return 0;
}

const normalized = Math.max(0, Math.floor(scheduleOffsetMinutes));
if (normalized < windowSizeInMins) {
return normalized;
}

const scheduleOffsetInMins = normalized % windowSizeInMins;
logger.warn(
{
alertId,
scheduleOffsetMinutes,
normalizedScheduleOffsetMinutes: scheduleOffsetInMins,
windowSizeInMins,
},
'scheduleOffsetMinutes is greater than or equal to the interval and was normalized',
);
return scheduleOffsetInMins;
};

const normalizeScheduleStartAt = ({
alertId,
scheduleStartAt,
}: {
alertId: string;
scheduleStartAt: unknown;
}) => {
if (scheduleStartAt == null) {
return undefined;
}

const scheduleStartAtDate =
scheduleStartAt instanceof Date
? scheduleStartAt
: new Date(scheduleStartAt as string);
const scheduleStartAtMs = scheduleStartAtDate.getTime();
if (Number.isFinite(scheduleStartAtMs)) {
return scheduleStartAtDate;
}

logger.warn(
{
alertId,
scheduleStartAt,
},
'Invalid scheduleStartAt value detected, ignoring start time schedule',
);
return undefined;
};

export const getScheduledWindowStart = (
now: Date,
windowSizeInMins: number,
scheduleOffsetMinutes = 0,
scheduleStartAt?: Date,
) => {
if (scheduleStartAt != null) {
const windowSizeMs = windowSizeInMins * 60 * 1000;
const elapsedMs = Math.max(0, now.getTime() - scheduleStartAt.getTime());
const windowCountSinceStart = Math.floor(elapsedMs / windowSizeMs);
return new Date(
scheduleStartAt.getTime() + windowCountSinceStart * windowSizeMs,
);
}

if (scheduleOffsetMinutes <= 0) {
return roundDownToXMinutes(windowSizeInMins)(now);
}

const shiftedNow = fns.subMinutes(now, scheduleOffsetMinutes);
const roundedShiftedNow = roundDownToXMinutes(windowSizeInMins)(shiftedNow);
return fns.addMinutes(roundedShiftedNow, scheduleOffsetMinutes);
};

const fireChannelEvent = async ({
alert,
alertProvider,
Expand Down Expand Up @@ -126,6 +215,12 @@ const fireChannelEvent = async ({
dashboardId: dashboard?.id,
groupBy: alert.groupBy,
interval: alert.interval,
...(alert.scheduleOffsetMinutes != null && {
scheduleOffsetMinutes: alert.scheduleOffsetMinutes,
}),
...(alert.scheduleStartAt != null && {
scheduleStartAt: alert.scheduleStartAt.toISOString(),
}),
message: alert.message,
name: alert.name,
savedSearchId: savedSearch?.id,
Expand Down Expand Up @@ -227,6 +322,7 @@ const getAlertEvaluationDateRange = (
hasGroupBy: boolean,
nowInMinsRoundDown: Date,
windowSizeInMins: number,
scheduleStartAt?: Date,
) => {
// Calculate date range for the query
// Find the latest createdAt among all histories for this alert
Expand All @@ -248,10 +344,16 @@ const getAlertEvaluationDateRange = (
previousCreatedAt = previous?.createdAt;
}

const rawStartTime = previousCreatedAt
? previousCreatedAt.getTime()
: fns.subMinutes(nowInMinsRoundDown, windowSizeInMins).getTime();
const clampedStartTime =
scheduleStartAt == null
? rawStartTime
: Math.max(rawStartTime, scheduleStartAt.getTime());

return calcAlertDateRange(
previousCreatedAt
? previousCreatedAt.getTime()
: fns.subMinutes(nowInMinsRoundDown, windowSizeInMins).getTime(),
clampedStartTime,
nowInMinsRoundDown.getTime(),
windowSizeInMins,
);
Expand Down Expand Up @@ -388,7 +490,43 @@ export const processAlert = async (
const { alert, source, previousMap } = details;
try {
const windowSizeInMins = ms(alert.interval) / 60000;
const nowInMinsRoundDown = roundDownToXMinutes(windowSizeInMins)(now);
const scheduleStartAt = normalizeScheduleStartAt({
alertId: alert.id,
scheduleStartAt: alert.scheduleStartAt,
});
if (scheduleStartAt != null && now < scheduleStartAt) {
logger.info(
{
alertId: alert.id,
now,
scheduleStartAt,
},
'Skipped alert check because scheduleStartAt is in the future',
);
return;
}

const scheduleOffsetMinutes = normalizeScheduleOffsetMinutes({
alertId: alert.id,
scheduleOffsetMinutes: alert.scheduleOffsetMinutes,
windowSizeInMins,
});
if (scheduleStartAt != null && scheduleOffsetMinutes > 0) {
logger.info(
{
alertId: alert.id,
scheduleStartAt,
scheduleOffsetMinutes,
},
'scheduleStartAt is set; scheduleOffsetMinutes is ignored for window alignment',
);
}
const nowInMinsRoundDown = getScheduledWindowStart(
now,
windowSizeInMins,
scheduleOffsetMinutes,
scheduleStartAt,
);
const hasGroupBy = alert.groupBy && alert.groupBy.length > 0;

// Check if we should skip this alert check based on last evaluation time
Expand All @@ -400,6 +538,8 @@ export const processAlert = async (
now,
alertId: alert.id,
hasGroupBy,
scheduleOffsetMinutes,
scheduleStartAt,
},
`Skipped to check alert since the time diff is still less than 1 window size`,
);
Expand All @@ -411,6 +551,7 @@ export const processAlert = async (
!!hasGroupBy,
nowInMinsRoundDown,
windowSizeInMins,
scheduleStartAt,
);

const chartConfig = getChartConfigFromAlert(
Expand Down
Loading