Skip to content

Commit a1df38a

Browse files
authored
Merge pull request #288 from techulus/feature/cron-failure-notifications
Add cron failure notifications
2 parents fe3bbd0 + 7793706 commit a1df38a

7 files changed

Lines changed: 156 additions & 5 deletions

File tree

‎web/components/settings/email-settings.tsx‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ type AlertField =
1919
| "serverOfflineAlert"
2020
| "buildFailure"
2121
| "deploymentFailure"
22-
| "deploymentMovedAlert";
22+
| "deploymentMovedAlert"
23+
| "cronFailure";
2324

2425
type AlertSetting = {
2526
field: AlertField;
@@ -49,13 +50,19 @@ const ALERT_SETTINGS: AlertSetting[] = [
4950
description:
5051
"Receive a notification when offline replicas need manual recovery",
5152
},
53+
{
54+
field: "cronFailure",
55+
label: "Cron Failure Alert",
56+
description: "Receive a notification when a cron run fails",
57+
},
5258
];
5359

5460
type State = {
5561
serverOfflineAlert: boolean;
5662
buildFailure: boolean;
5763
deploymentFailure: boolean;
5864
deploymentMovedAlert: boolean;
65+
cronFailure: boolean;
5966
isSavingAlerts: boolean;
6067
};
6168

@@ -70,6 +77,7 @@ function createInitialState(props: Props): State {
7077
buildFailure: alertsConfig?.buildFailure ?? true,
7178
deploymentFailure: alertsConfig?.deploymentFailure ?? true,
7279
deploymentMovedAlert: alertsConfig?.deploymentMovedAlert ?? true,
80+
cronFailure: alertsConfig?.cronFailure ?? true,
7381
isSavingAlerts: false,
7482
};
7583
}
@@ -99,6 +107,7 @@ export function EmailSettings({ initialAlertsConfig }: Props) {
99107
buildFailure: state.buildFailure,
100108
deploymentFailure: state.deploymentFailure,
101109
deploymentMovedAlert: state.deploymentMovedAlert,
110+
cronFailure: state.cronFailure,
102111
});
103112
toast.success("Alert settings saved");
104113
router.refresh();
@@ -120,7 +129,8 @@ export function EmailSettings({ initialAlertsConfig }: Props) {
120129
state.deploymentFailure !==
121130
(initialAlertsConfig?.deploymentFailure ?? true) ||
122131
state.deploymentMovedAlert !==
123-
(initialAlertsConfig?.deploymentMovedAlert ?? true);
132+
(initialAlertsConfig?.deploymentMovedAlert ?? true) ||
133+
state.cronFailure !== (initialAlertsConfig?.cronFailure ?? true);
124134

125135
return (
126136
<div className="space-y-6">

‎web/lib/email/index.ts‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,60 @@ async function sendBuildFailureAlert(
261261
});
262262
}
263263

264+
type CronFailureAlertOptions = {
265+
to: string;
266+
serviceId: string;
267+
path: string;
268+
statusCode: number | null;
269+
error: string | null;
270+
};
271+
272+
async function sendCronFailureAlert(
273+
options: CronFailureAlertOptions,
274+
): Promise<void> {
275+
const [result] = await db
276+
.select({
277+
serviceName: services.name,
278+
projectName: projects.name,
279+
projectSlug: projects.slug,
280+
envName: environments.name,
281+
})
282+
.from(services)
283+
.innerJoin(projects, eq(projects.id, services.projectId))
284+
.innerJoin(environments, eq(environments.id, services.environmentId))
285+
.where(eq(services.id, options.serviceId));
286+
287+
if (!result) return;
288+
289+
const baseUrl = getAppBaseUrl();
290+
const serviceUrl = baseUrl
291+
? `${baseUrl}/dashboard/projects/${result.projectSlug}/${result.envName}/services/${options.serviceId}`
292+
: undefined;
293+
const details = [
294+
{ label: "Service", value: result.serviceName },
295+
{ label: "Project", value: result.projectName },
296+
{ label: "Cron Path", value: options.path },
297+
...(options.statusCode !== null
298+
? [{ label: "HTTP Status", value: String(options.statusCode) }]
299+
: []),
300+
...(options.error ? [{ label: "Error", value: options.error }] : []),
301+
];
302+
303+
await sendAlert({
304+
to: options.to,
305+
subject: `Cron Failed: ${result.serviceName}`,
306+
template: Alert({
307+
bannerText: "CRON FAILED",
308+
heading: "Cron Failure Alert",
309+
description: `The cron job "${options.path}" for service "${result.serviceName}" in project "${result.projectName}" has failed.`,
310+
details,
311+
buttonText: serviceUrl ? "View Service" : undefined,
312+
buttonUrl: serviceUrl,
313+
baseUrl,
314+
}),
315+
});
316+
}
317+
264318
type DeploymentFailureAlertOptions = {
265319
to: string;
266320
serviceId: string;
@@ -400,6 +454,9 @@ export async function deliverNotificationEmail(
400454
case "build.failed":
401455
await sendBuildFailureAlert({ ...event, to });
402456
return;
457+
case "cron.failed":
458+
await sendCronFailureAlert({ ...event, to });
459+
return;
403460
case "deployment.failed":
404461
await sendDeploymentFailureAlert({ ...event, to });
405462
}

‎web/lib/inngest/events/notification.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,14 @@ export type NotificationEvent =
2929
serverId: string | null;
3030
failedStage?: string;
3131
}
32+
| {
33+
kind: "cron.failed";
34+
occurrenceId: string;
35+
serviceId: string;
36+
path: string;
37+
statusCode: number | null;
38+
error: string | null;
39+
}
3240
| {
3341
kind: "member.invited";
3442
occurrenceId: string;

‎web/lib/notifications/index.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ export async function notificationEventIsEnabled(event: NotificationEvent) {
3737
return config?.buildFailure !== false;
3838
case "deployment.failed":
3939
return config?.deploymentFailure !== false;
40+
case "cron.failed":
41+
return config?.cronFailure !== false;
4042
}
4143
}
4244

@@ -81,6 +83,13 @@ export async function renderInAppNotification(event: NotificationEvent) {
8183
href: `${serviceHref}/builds/${event.buildId}`,
8284
};
8385
}
86+
if (event.kind === "cron.failed") {
87+
return {
88+
title: `Cron failed: ${context.serviceName}`,
89+
body: `${event.path}: ${event.error ?? "Cron request failed"}`,
90+
href: serviceHref,
91+
};
92+
}
8493
return {
8594
title: `Deployment failed: ${context.serviceName}`,
8695
body: event.failedStage

‎web/lib/service-crons.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { CronExpressionParser } from "cron-parser";
55
import { db } from "@/db";
66
import { secrets, serviceCrons, services } from "@/db/schema";
77
import { decryptSecret } from "@/lib/crypto";
8+
import { notify } from "@/lib/notifications";
89
import { isSafeCronPath, nextOccurrenceAfter } from "@/lib/public-api";
910
import { ingestCronLog, type CronLog } from "@/lib/victoria-logs";
1011

@@ -256,5 +257,20 @@ export async function executeServiceCron(
256257
log_type: "cron",
257258
};
258259
await ingestCronLog(log);
260+
if (status === "failed") {
261+
notify({
262+
kind: "cron.failed",
263+
occurrenceId: cronEventId(cronId, scheduledFor),
264+
serviceId: row.serviceId,
265+
path: row.cron.path,
266+
statusCode,
267+
error,
268+
}).catch((cause) => {
269+
console.error(
270+
"[service-cron] failed to enqueue cron failure notification:",
271+
cause,
272+
);
273+
});
274+
}
259275
return { stale: false as const, status, statusCode, error };
260276
}

‎web/lib/settings-keys.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ export const emailAlertsConfigSchema = z.object({
8989
buildFailure: z.boolean(),
9090
deploymentFailure: z.boolean(),
9191
deploymentMovedAlert: z.boolean(),
92+
cronFailure: z.boolean(),
9293
});
9394

9495
export type EmailAlertsConfig = z.infer<typeof emailAlertsConfigSchema>;

‎web/tests/notifications.test.ts‎

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,12 +99,49 @@ describe("notification pipeline", () => {
9999
).resolves.toBeNull();
100100
});
101101

102+
it("renders cron failures with the service deep link", async () => {
103+
mocks.select.mockReturnValueOnce({
104+
from: vi.fn(() => ({
105+
innerJoin: vi.fn(() => ({
106+
innerJoin: vi.fn(() => ({
107+
where: vi.fn(() =>
108+
Promise.resolve([
109+
{
110+
serviceName: "API",
111+
projectName: "Cloud",
112+
projectSlug: "cloud",
113+
environmentName: "production",
114+
},
115+
]),
116+
),
117+
})),
118+
})),
119+
})),
120+
});
121+
122+
await expect(
123+
renderInAppNotification({
124+
kind: "cron.failed",
125+
occurrenceId: "cron-1",
126+
serviceId: "service-1",
127+
path: "/jobs/nightly",
128+
statusCode: 500,
129+
error: "HTTP status 500",
130+
}),
131+
).resolves.toEqual({
132+
title: "Cron failed: API",
133+
body: "/jobs/nightly: HTTP status 500",
134+
href: "/dashboard/projects/cloud/production/services/service-1",
135+
});
136+
});
137+
102138
it("maps every operational event to its alert toggle", async () => {
103139
mocks.getAlertsConfig.mockResolvedValue({
104140
serverOfflineAlert: false,
105141
buildFailure: false,
106142
deploymentFailure: false,
107143
deploymentMovedAlert: false,
144+
cronFailure: false,
108145
});
109146

110147
await expect(
@@ -141,17 +178,29 @@ describe("notification pipeline", () => {
141178
serverId: "server-1",
142179
}),
143180
).resolves.toBe(false);
181+
await expect(
182+
notificationEventIsEnabled({
183+
kind: "cron.failed",
184+
occurrenceId: "cron-1",
185+
serviceId: "service-1",
186+
path: "/jobs/nightly",
187+
statusCode: 500,
188+
error: "HTTP status 500",
189+
}),
190+
).resolves.toBe(false);
144191
});
145192

146193
it("defaults missing alert settings to enabled", async () => {
147194
mocks.getAlertsConfig.mockResolvedValue(null);
148195

149196
await expect(
150197
notificationEventIsEnabled({
151-
kind: "build.failed",
152-
occurrenceId: "build-1",
198+
kind: "cron.failed",
199+
occurrenceId: "cron-1",
153200
serviceId: "service-1",
154-
buildId: "build-1",
201+
path: "/jobs/nightly",
202+
statusCode: null,
203+
error: "Cron request failed",
155204
}),
156205
).resolves.toBe(true);
157206
});
@@ -162,6 +211,7 @@ describe("notification pipeline", () => {
162211
buildFailure: true,
163212
deploymentFailure: true,
164213
deploymentMovedAlert: true,
214+
cronFailure: true,
165215
});
166216

167217
await deliverInAppNotification({

0 commit comments

Comments
 (0)