-
Notifications
You must be signed in to change notification settings - Fork 0
feat(monitoring): add project health and failure alerts #52
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
base: dev
Are you sure you want to change the base?
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 |
|---|---|---|
| @@ -1,14 +1,38 @@ | ||
| import { Elysia } from "elysia"; | ||
| import { createAlert, deleteAlert, listAlerts, updateAlertEnabled } from "../../db/repo"; | ||
| import type { AlertChannel, AlertType } from "../../types"; | ||
| import { created, fail, ok } from "../response"; | ||
|
|
||
| const ALERT_TYPES: ReadonlySet<string> = new Set<AlertType>(["cpu", "memory", "downtime", "cert_expiry"]); | ||
| const ALERT_CHANNELS: ReadonlySet<string> = new Set<AlertChannel>(["email", "slack", "webhook"]); | ||
|
|
||
| export const alertsRoutes = new Elysia() | ||
| .get("/projects/:id/alerts", async ({ params }) => ok(await listAlerts(params.id))) | ||
| .post("/projects/:id/alerts", async ({ params, body, set }: any) => { | ||
| if (!body?.type || !body?.channel) { | ||
| set.status = 400; | ||
| return fail("type and channel are required"); | ||
| } | ||
| if (!ALERT_TYPES.has(String(body.type))) { | ||
| set.status = 400; | ||
| return fail(`type must be one of: ${[...ALERT_TYPES].join(", ")}`); | ||
| } | ||
| if (!ALERT_CHANNELS.has(String(body.channel))) { | ||
| set.status = 400; | ||
| return fail(`channel must be one of: ${[...ALERT_CHANNELS].join(", ")}`); | ||
| } | ||
| if (body.channel !== "email") { | ||
| let valid = false; | ||
| try { | ||
| valid = ["http:", "https:"].includes(new URL(String(body.destination ?? "")).protocol); | ||
|
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. 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift Restrict webhook destinations before storing them. This check accepts internal HTTP addresses such as 🤖 Prompt for AI Agents |
||
| } catch { | ||
| valid = false; | ||
| } | ||
| if (!valid) { | ||
| set.status = 400; | ||
| return fail("destination must be an http(s) URL for this channel"); | ||
| } | ||
| } | ||
| return created( | ||
| await createAlert({ | ||
| projectId: params.id, | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,4 +1,5 @@ | ||||||||||||||||||||||||||||||||||||||||||||
| import { Elysia } from "elysia"; | ||||||||||||||||||||||||||||||||||||||||||||
| import { fail } from "./response"; | ||||||||||||||||||||||||||||||||||||||||||||
| import { agentRoutes } from "./agents"; | ||||||||||||||||||||||||||||||||||||||||||||
| import { alertsRoutes } from "./alerts"; | ||||||||||||||||||||||||||||||||||||||||||||
| import { apiKeysRoutes } from "./api-keys"; | ||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -11,6 +12,7 @@ import { envVarsRoutes } from "./env-vars"; | |||||||||||||||||||||||||||||||||||||||||||
| import { githubRoutes } from "./github"; | ||||||||||||||||||||||||||||||||||||||||||||
| import { healthRoutes } from "./health"; | ||||||||||||||||||||||||||||||||||||||||||||
| import { projectsRoutes } from "./projects"; | ||||||||||||||||||||||||||||||||||||||||||||
| import { projectStatusRoutes } from "./projects/status"; | ||||||||||||||||||||||||||||||||||||||||||||
| import { prometheusRoutes } from "./prometheus"; | ||||||||||||||||||||||||||||||||||||||||||||
| import { routesRoutes } from "./routes"; | ||||||||||||||||||||||||||||||||||||||||||||
| import { scalingRoutes } from "./scaling"; | ||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -44,7 +46,7 @@ const authMiddleware = (app: Elysia) => | |||||||||||||||||||||||||||||||||||||||||||
| const payload = await verifyAccessToken(match[1]); | ||||||||||||||||||||||||||||||||||||||||||||
| if (payload) return; | ||||||||||||||||||||||||||||||||||||||||||||
| set.status = 401; | ||||||||||||||||||||||||||||||||||||||||||||
| return { error: "Invalid session" }; | ||||||||||||||||||||||||||||||||||||||||||||
| return fail("Invalid session"); | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| const authHeader = request.headers.get("authorization"); | ||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -55,22 +57,36 @@ const authMiddleware = (app: Elysia) => | |||||||||||||||||||||||||||||||||||||||||||
| const key = await validateApiKey(token); | ||||||||||||||||||||||||||||||||||||||||||||
| if (key) return; | ||||||||||||||||||||||||||||||||||||||||||||
| set.status = 401; | ||||||||||||||||||||||||||||||||||||||||||||
| return { error: "Invalid API key" }; | ||||||||||||||||||||||||||||||||||||||||||||
| return fail("Invalid API key"); | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| set.status = 401; | ||||||||||||||||||||||||||||||||||||||||||||
| return { error: "Authentication required" }; | ||||||||||||||||||||||||||||||||||||||||||||
| return fail("Authentication required"); | ||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| const INTERNAL_ERROR = | ||||||||||||||||||||||||||||||||||||||||||||
| /Failed query:|params:|getaddrinfo|ECONNREFUSED|ETIMEDOUT|EAI_AGAIN|EHOSTUNREACH|timeout exceeded|node:internal|Cannot read propert|is not a function|is not a constructor|Unexpected token/i; | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| export const apiRoutes = new Elysia({ | ||||||||||||||||||||||||||||||||||||||||||||
| prefix: "/api", | ||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||
| .onError(({ error, set }) => { | ||||||||||||||||||||||||||||||||||||||||||||
| const err = error as { status?: number; message?: string }; | ||||||||||||||||||||||||||||||||||||||||||||
| set.status = typeof err?.status === "number" ? err.status : 500; | ||||||||||||||||||||||||||||||||||||||||||||
| const message = err?.message ?? "Internal server error"; | ||||||||||||||||||||||||||||||||||||||||||||
| if (INTERNAL_ERROR.test(message)) { | ||||||||||||||||||||||||||||||||||||||||||||
| console.error("[API] Unhandled error:", error); | ||||||||||||||||||||||||||||||||||||||||||||
| return fail("Internal server error"); | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
| return fail(message); | ||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+74
to
+83
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. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win Return a generic message by default in the global The handler returns Invert the logic. Pass through only messages that the code intends clients to see, such as Elysia's own 4xx errors or an explicit 🔒️ Proposed fix .onError(({ error, set }) => {
- const err = error as { status?: number; message?: string };
- set.status = typeof err?.status === "number" ? err.status : 500;
- const message = err?.message ?? "Internal server error";
- if (INTERNAL_ERROR.test(message)) {
- console.error("[API] Unhandled error:", error);
- return fail("Internal server error");
- }
- return fail(message);
+ const err = error as { status?: number; message?: string };
+ const status = typeof err?.status === "number" ? err.status : 500;
+ set.status = status;
+ const message = err?.message ?? "Internal server error";
+ if (status >= 500 || INTERNAL_ERROR.test(message)) {
+ console.error("[API] Unhandled error:", error);
+ return fail("Internal server error");
+ }
+ return fail(message);
})Based on learnings: "do not return exception messages, stack traces, or other internal error details in the HTTP response body... Log full exception details server-side instead." 📝 Committable suggestion
Suggested change
🤖 Prompt for AI AgentsSource: Learnings |
||||||||||||||||||||||||||||||||||||||||||||
| .use(authRoutes) | ||||||||||||||||||||||||||||||||||||||||||||
| .use(authMiddleware) | ||||||||||||||||||||||||||||||||||||||||||||
| .use(agentRoutes) | ||||||||||||||||||||||||||||||||||||||||||||
| .use(healthRoutes) | ||||||||||||||||||||||||||||||||||||||||||||
| .use(projectsRoutes) | ||||||||||||||||||||||||||||||||||||||||||||
| .use(projectStatusRoutes) | ||||||||||||||||||||||||||||||||||||||||||||
| .use(deploymentsRoutes) | ||||||||||||||||||||||||||||||||||||||||||||
| .use(envVarsRoutes) | ||||||||||||||||||||||||||||||||||||||||||||
| .use(sharedEnvVarsRoutes) | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| import { Elysia } from "elysia"; | ||
| import { getProjectById } from "../../db/repo"; | ||
| import { getProjectStatus } from "../../monitoring/project-status"; | ||
| import { fail, ok } from "../response"; | ||
|
|
||
| const DEFAULT_WINDOW_SECONDS = 3600; | ||
| const MIN_WINDOW_SECONDS = 60; | ||
| const MAX_WINDOW_SECONDS = 604800; | ||
|
|
||
| const clampWindow = (raw: unknown): number => { | ||
| const value = Number(raw); | ||
| if (!Number.isFinite(value) || value <= 0) return DEFAULT_WINDOW_SECONDS; | ||
| return Math.min(MAX_WINDOW_SECONDS, Math.max(MIN_WINDOW_SECONDS, Math.floor(value))); | ||
| }; | ||
|
|
||
| export const projectStatusRoutes = new Elysia().get("/projects/:id/status", async ({ params: { id }, query, set }) => { | ||
| const project = await getProjectById(id); | ||
| if (!project) { | ||
| set.status = 404; | ||
| return fail("Project not found"); | ||
| } | ||
| try { | ||
| return ok(await getProjectStatus(id, clampWindow((query as any)?.window))); | ||
| } catch (err) { | ||
| console.error(`[Status] Failed to build status for project ${id}:`, err); | ||
| set.status = 500; | ||
| return fail("Failed to build project status"); | ||
| } | ||
| }); |
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.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Reject certificate-expiry rules until the evaluator supports them.
The new allowlist accepts
cert_expiry, butAlertEvaluator.probereturnsno_datafor that type. A user can create an enabled rule that never sends an alert. Remove the type from this allowlist, or implement its evaluation before accepting it. (raw.githubusercontent.com)🤖 Prompt for AI Agents