feat: expose health probes and metrics
This commit is contained in:
@@ -16,6 +16,8 @@ READING_HISTORY_RETENTION_DAYS=0
|
|||||||
AUDIT_RETENTION_DAYS=365
|
AUDIT_RETENTION_DAYS=365
|
||||||
# Optional Discord webhook URL or ntfy topic URL (for example https://ntfy.sh/my-private-topic).
|
# Optional Discord webhook URL or ntfy topic URL (for example https://ntfy.sh/my-private-topic).
|
||||||
ALERT_WEBHOOK_URL=
|
ALERT_WEBHOOK_URL=
|
||||||
|
# Optional Bearer token for GET /api/metrics. Leave empty only when metrics are restricted by your network/proxy.
|
||||||
|
METRICS_TOKEN=
|
||||||
# Optional: create the first Memos source for the bootstrap admin.
|
# Optional: create the first Memos source for the bootstrap admin.
|
||||||
SEED_MEMOS_NAME=
|
SEED_MEMOS_NAME=
|
||||||
SEED_MEMOS_URL=
|
SEED_MEMOS_URL=
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
- Hardened incoming RSS/Atom sources with public-HTTPS validation, redirect checks, response-size limits, and safe XML declaration rejection.
|
- Hardened incoming RSS/Atom sources with public-HTTPS validation, redirect checks, response-size limits, and safe XML declaration rejection.
|
||||||
- Added production configuration validation for secrets, encryption keys, and the public HTTPS URL, plus an administrator-facing status check.
|
- Added production configuration validation for secrets, encryption keys, and the public HTTPS URL, plus an administrator-facing status check.
|
||||||
|
- Added Prometheus-compatible metrics and distinct liveness/readiness health probes.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import { NextResponse } from "next/server";
|
|||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
export async function GET() {
|
export async function GET(request: Request) {
|
||||||
|
const probe = new URL(request.url).searchParams.get("probe");
|
||||||
|
if (probe === "live") return NextResponse.json({ ok: true, status: "live", version: process.env.APP_VERSION || "development", timestamp: new Date().toISOString() });
|
||||||
try {
|
try {
|
||||||
db.prepare("SELECT 1").get();
|
db.prepare("SELECT 1").get();
|
||||||
const failedJobs = Number((db.prepare("SELECT count(*) AS count FROM sync_jobs WHERE status='failed'").get() as { count: number }).count);
|
const failedJobs = Number((db.prepare("SELECT count(*) AS count FROM sync_jobs WHERE status='failed'").get() as { count: number }).count);
|
||||||
return NextResponse.json({ ok: true, version: process.env.APP_VERSION || "development", database: "ok", failedJobs, timestamp: new Date().toISOString() });
|
return NextResponse.json({ ok: true, status: "ready", version: process.env.APP_VERSION || "development", database: "ok", failedJobs, timestamp: new Date().toISOString() });
|
||||||
} catch { return NextResponse.json({ ok: false, database: "error" }, { status: 503 }); }
|
} catch { return NextResponse.json({ ok: false, database: "error" }, { status: 503 }); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { timingSafeEqual } from "node:crypto";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prometheusMetrics } from "@/lib/metrics";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export async function GET(request: Request) { const expected = process.env.METRICS_TOKEN; const supplied = request.headers.get("authorization")?.replace(/^Bearer\s+/i, "") || ""; if (expected) { const actual = Buffer.from(supplied), target = Buffer.from(expected); if (actual.length !== target.length || !timingSafeEqual(actual, target)) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } return new NextResponse(prometheusMetrics(), { headers: { "Content-Type": "text/plain; version=0.0.4; charset=utf-8", "Cache-Control": "no-store" } }); }
|
||||||
@@ -62,3 +62,7 @@ Hub 原生附件預設只接受圖片、PDF、純文字與 Markdown。若要串
|
|||||||
## 外部告警
|
## 外部告警
|
||||||
|
|
||||||
設定 `ALERT_WEBHOOK_URL` 後,Worker 會在同步重試耗盡、或簽章 Webhook 超過 7 天未收到事件時發送告警。支援 Discord incoming webhook 或 ntfy topic URL;同一事件每小時最多通知一次。
|
設定 `ALERT_WEBHOOK_URL` 後,Worker 會在同步重試耗盡、或簽章 Webhook 超過 7 天未收到事件時發送告警。支援 Discord incoming webhook 或 ntfy topic URL;同一事件每小時最多通知一次。
|
||||||
|
|
||||||
|
## 監控指標與健康檢查
|
||||||
|
|
||||||
|
`GET /api/health?probe=live` 只確認程序存活;預設的 `GET /api/health` 是 readiness 檢查,會確認 SQLite 可讀取。Prometheus 格式的 `GET /api/metrics` 提供來源、公開貼文、同步佇列、Webhook 與附件快取的聚合指標。若設定 `METRICS_TOKEN`,請以 `Authorization: Bearer <token>` 抓取。
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { db } from "@/lib/db";
|
||||||
|
|
||||||
|
const line = (name: string, value: number, labels = "") => `${name}${labels ? `{${labels}}` : ""} ${Number.isFinite(value) ? value : 0}`;
|
||||||
|
export function prometheusMetrics() {
|
||||||
|
const count = (sql: string) => Number((db.prepare(sql).get() as { count: number }).count);
|
||||||
|
const jobs = db.prepare("SELECT status,count(*) AS count FROM sync_jobs GROUP BY status").all() as { status: string; count: number }[];
|
||||||
|
const queuedAge = Number((db.prepare("SELECT COALESCE(MAX(strftime('%s','now')-strftime('%s',created_at)),0) AS age FROM sync_jobs WHERE status='queued'").get() as { age: number }).age);
|
||||||
|
const signed = count("SELECT count(*) AS count FROM sources WHERE is_enabled=1 AND webhook_mode='signed'"); const recentWebhook = count("SELECT count(*) AS count FROM sources WHERE is_enabled=1 AND webhook_mode='signed' AND last_webhook_at>=datetime('now','-24 hours')");
|
||||||
|
const cacheRows = db.prepare("SELECT attachments_json FROM posts").all() as { attachments_json: string }[]; let cacheBytes = 0; for (const row of cacheRows) { try { cacheBytes += (JSON.parse(row.attachments_json) as { url?: string; size?: number }[]).filter((item) => item.url?.startsWith("/uploads/cache/")).reduce((sum, item) => sum + Number(item.size || 0), 0); } catch {} }
|
||||||
|
return ["# HELP mebbling_sources_enabled Number of enabled sources", "# TYPE mebbling_sources_enabled gauge", line("mebbling_sources_enabled", count("SELECT count(*) AS count FROM sources WHERE is_enabled=1")), "# HELP mebbling_posts_public Number of visible public posts", "# TYPE mebbling_posts_public gauge", line("mebbling_posts_public", count("SELECT count(*) AS count FROM posts WHERE visibility='PUBLIC' AND hidden=0")), "# HELP mebbling_sync_jobs Number of sync jobs by status", "# TYPE mebbling_sync_jobs gauge", ...jobs.map((job) => line("mebbling_sync_jobs", job.count, `status=\"${job.status.replaceAll('"', '')}\"`)), line("mebbling_sync_queue_oldest_seconds", queuedAge), line("mebbling_signed_webhooks", signed), line("mebbling_signed_webhooks_recent_24h", recentWebhook), line("mebbling_attachment_cache_bytes", cacheBytes)].join("\n") + "\n";
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user