diff --git a/.gitignore b/.gitignore index 10f05ce..4f1859d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,7 @@ node_modules/ dist/ .output/ .nuxt/ -admin/ +/admin/ docs/.vitepress/dist/ docs/.vitepress/cache/ .env diff --git a/AGENTS.md b/AGENTS.md index 6a4930a..010f21a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -102,19 +102,21 @@ server/ # Nitro server ├── web/ # HTTP UI/API logic (called from server/routes) │ ├── oauth.ts # handleOAuthStart/Callback, install page + bind, personal-group self-signup │ ├── actions.ts # POST /api/comment|merge|close|react (Bearer token auth via shared middleware) - │ ├── admin.ts # adminLogin/Logout, adminApi* (routes|groups|me|logs|invites|audit|webhook) + │ ├── admin.ts # adminLogin/Logout, adminApi* (routes|groups|me|logs|invites|audit|webhook|metrics|delivery) │ ├── auth.ts # Shared auth middleware + guards: requireAnyAccess, requireGroup(Role), bearerUserId, clientIp │ ├── invites.ts # Invite CRUD (KV invite:{token}, 7d TTL) + acceptInvite (join group as admin/viewer) │ ├── session.ts # Session CRUD (KV session:{id}), isAdminUser, cookie helpers │ ├── groups.ts # Group CRUD (config:groups), member roles (normalizeGroupMembers/memberRole), resolveScope + role helpers (roleAt/canEditRoutes/canEditGroup) │ ├── tenants.ts # Per-group webhook secret CRUD (KV tenant:{groupId}, 32-byte random hex) │ └── richheader.ts # GET /api/richheader: Open Graph page for Telegram avatar link-preview card + ├── observability/ # delivery metrics aggregation + │ └── metrics.ts # DeliveryMetrics + getDeliveryMetrics (SQL GROUP BY over send_logs: totals, per platform/event/status, duration, attempts, recent failures) └── lib/ # shared infra ├── i18n.ts # loadTranslations (KV i18n:{lang} overrides), t() with param interpolation ├── idempotency.ts # IdempotencyStore interface + kvIdempotencyStore (delivery dedup via claim/has) + deliveryKey ├── correlation.ts # newCorrelationId() — per-request/delivery correlation id for logs + responses ├── message-tracker.ts # MessageTracker interface + kvMessageTracker (KV msg:{eventId}:{targetId} for workflow_run/check_run edits) - ├── send-log.ts # SendRecord, recordSend/getSendLog/getSendLogById (D1 send_logs) + ├── send-log.ts # SendRecord, recordSend/getSendLog/getSendLogById/getSendLogByDelivery/getFailedSendLog (D1 send_logs) ├── audit.ts # recordAudit/getAuditLog/pruneAuditLogs (D1 audit_logs, best-effort writes) ├── log.ts # JSON console logger (info/warn/error/fatal) └── locales/ # en.ts, zh.ts translation dictionaries @@ -144,6 +146,8 @@ tests/ # bun test unit tests (webhook, formatter, discord, tel - Route messages to Discord channels/threads and Telegram chats/topics via REST - Edit already-sent messages in place for `workflow_run` / `check_run` progress (stable `updateKey`, KV `msg:*` tracking) - Record every dispatch attempt to D1 `send_logs` (route id, event, target, ok/error, duration, error code) +- Aggregate delivery metrics (`server/lib/observability/metrics.ts`) from `send_logs` — totals, ok/failed counts + failure rate, per-platform/per-event/per-status breakdowns, average duration and attempts, recent failures +- Expose admin observability endpoints — `GET /admin/api/metrics` (delivery metrics, recent failures group-scoped for non-super) and `GET /admin/api/delivery/:deliveryId` (all send-log attempts for one delivery, group-scoped) — through the `/admin/api/[...slug]` catch-all route (`server/routes/admin/api/[...slug].ts`) that wires every admin API handler to its method+path - Serve a per-group webhook ingress (`POST /webhook/{groupId}`, per-group secret in KV `tenant:{groupId}`) for Gitea/classic-GitHub/custom senders; only that group's routes fire; dedup keys are provider- and tenant-scoped (`delivery:{provider}:{groupId}:{id}` via `kvIdempotencyStore`) - Issue a per-request correlation id (`requestId`) in webhook responses and dispatch logs - When the `QUEUE` binding is present, enqueue each verified webhook as a single Queue message (`webhooker-delivery`) instead of dispatching inline; the consumer resolves the payload, re-scopes routes to the tenant group, and dispatches; retryable failures (5xx/network/429-exhaustion) are retried with exponential backoff (5s/30s/2m/10m) up to the queue `max_retries`, then the DLQ marks the delivery dead diff --git a/README.md b/README.md index bbc1a54..0e1a2d2 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,8 @@ Every filter supports plain text, `*`/`?` globs, and `/regex/` patterns (case-in - `GET /admin/api/me` — Current session / scope / roles - `GET /admin/api/logs` — Send logs (scoped) - `GET /admin/api/logs/:id` — Single send-log entry +- `GET /admin/api/metrics` — Delivery metrics (totals, failure rate, per-platform/event/status, recent failures) +- `GET /admin/api/delivery/:deliveryId` — All send-log attempts for one delivery ## Setup Guides diff --git a/README.zh.md b/README.zh.md index f6120db..75f9b3f 100644 --- a/README.zh.md +++ b/README.zh.md @@ -152,6 +152,8 @@ bunx wrangler dev # 启动本地开发服务器 - `GET /admin/api/me` — 当前会话 / 权限范围 / 角色 - `GET /admin/api/logs` — 发送日志(按权限过滤) - `GET /admin/api/logs/:id` — 单条发送日志 +- `GET /admin/api/metrics` — 投递指标(总计、失败率、按平台/事件/状态、最近失败) +- `GET /admin/api/delivery/:deliveryId` — 单次投递的全部发送日志 ## 配置教程 diff --git a/app/components/ConsolePage.vue b/app/components/ConsolePage.vue index 94c9acb..3f3b0ed 100644 --- a/app/components/ConsolePage.vue +++ b/app/components/ConsolePage.vue @@ -108,6 +108,13 @@ + + + @@ -247,10 +263,10 @@ const router = useRouter(); * The console view mirrors the URL path so tabs are deep-linkable: * /admin (groups) · /admin/logs · /admin/audit. Unknown slugs 404. */ -const view = computed<"groups" | "logs" | "audit" | null>(() => { +const view = computed<"groups" | "logs" | "audit" | "metrics" | null>(() => { const seg = route.path.split("/").filter(Boolean)[1]; if (!seg) return "groups"; - if (seg === "groups" || seg === "logs" || seg === "audit") return seg; + if (seg === "groups" || seg === "logs" || seg === "audit" || seg === "metrics") return seg; return null; }); @@ -265,6 +281,7 @@ const { error: auditError, load: loadAudit, } = useAuditApi(); +const { metrics, loading: metricsLoading, error: metricsError, load: loadMetrics } = useMetrics(); const { groups, isSuper, @@ -319,11 +336,12 @@ watch( view, (v) => { if (v === "audit") loadAudit(50, auditFilterGroup.value || undefined); + if (v === "metrics") loadMetrics(); }, { immediate: true }, ); -function switchView(next: "groups" | "logs" | "audit"): void { +function switchView(next: "groups" | "logs" | "audit" | "metrics"): void { const path = next === "groups" ? "/admin" : `/admin/${next}`; if (route.path !== path) router.replace(path); } diff --git a/app/components/MetricsPanel.vue b/app/components/MetricsPanel.vue new file mode 100644 index 0000000..0ae3fd6 --- /dev/null +++ b/app/components/MetricsPanel.vue @@ -0,0 +1,80 @@ + + + diff --git a/app/composables/useI18n.ts b/app/composables/useI18n.ts index dae4975..82909eb 100644 --- a/app/composables/useI18n.ts +++ b/app/composables/useI18n.ts @@ -17,6 +17,19 @@ const en: Dict = { "tab.groups": "Groups", "tab.logs": "Send Logs", "tab.audit": "Audit Log", + "tab.metrics": "Metrics", + "metrics.title": "Delivery Metrics", + "metrics.refresh": "Refresh", + "metrics.total": "Total", + "metrics.ok": "OK", + "metrics.failed": "Failed", + "metrics.failureRate": "Failure rate", + "metrics.avgDuration": "Avg duration", + "metrics.avgAttempts": "Avg attempts", + "metrics.byPlatform": "By platform", + "metrics.byEvent": "By event", + "metrics.recentFailures": "Recent failures", + "metrics.empty": "No delivery data yet", "status.loading": "loading", "status.connected": "connected", "kpi.groups": "GROUPS", @@ -281,6 +294,19 @@ const zh: Dict = { "tab.groups": "分组", "tab.logs": "发送日志", "tab.audit": "审计日志", + "tab.metrics": "指标", + "metrics.title": "投递指标", + "metrics.refresh": "刷新", + "metrics.total": "总计", + "metrics.ok": "成功", + "metrics.failed": "失败", + "metrics.failureRate": "失败率", + "metrics.avgDuration": "平均耗时", + "metrics.avgAttempts": "平均尝试次数", + "metrics.byPlatform": "按平台", + "metrics.byEvent": "按事件", + "metrics.recentFailures": "最近失败", + "metrics.empty": "暂无投递数据", "status.loading": "加载中", "status.connected": "已连接", "kpi.groups": "分组", diff --git a/app/composables/useMetrics.ts b/app/composables/useMetrics.ts new file mode 100644 index 0000000..33ecb7d --- /dev/null +++ b/app/composables/useMetrics.ts @@ -0,0 +1,24 @@ +import type { DeliveryMetrics } from "~/types"; + +export function useMetrics() { + const { needLogin } = useAuthState(); + const metrics = ref(null); + const loading = ref(false); + const error = ref(""); + + async function load(): Promise { + loading.value = true; + error.value = ""; + needLogin.value = false; + try { + const data = await apiFetch<{ metrics?: DeliveryMetrics }>("/admin/api/metrics"); + metrics.value = data.metrics ?? null; + } catch (err) { + if (!needLogin.value) error.value = err instanceof Error ? err.message : String(err); + } finally { + loading.value = false; + } + } + + return { metrics, loading, error, load }; +} diff --git a/app/pages/admin/[...slug].vue b/app/pages/admin/[...slug].vue new file mode 100644 index 0000000..8b11566 --- /dev/null +++ b/app/pages/admin/[...slug].vue @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/app/types.ts b/app/types.ts index 042aeff..d18e773 100644 --- a/app/types.ts +++ b/app/types.ts @@ -189,3 +189,30 @@ export interface SendRecord { attempts?: number; detail?: Record; } + +export interface MetricsBreakdown { + platform?: string; + event?: string; + total: number; + ok: number; + failed: number; +} + +export interface MetricsStatus { + status: string; + count: number; +} + +export interface DeliveryMetrics { + total: number; + ok: number; + failed: number; + failureRate: number; + byPlatform: MetricsBreakdown[]; + byEvent: MetricsBreakdown[]; + byStatus: MetricsStatus[]; + avgDurationMs: number; + totalAttempts: number; + avgAttempts: number; + recentFailures: SendRecord[]; +} diff --git a/docs/api/admin.md b/docs/api/admin.md index 91164d6..71fb02f 100644 --- a/docs/api/admin.md +++ b/docs/api/admin.md @@ -29,6 +29,8 @@ The console itself is served at `/admin`; its tabs are deep-linkable via the URL | `GET /admin/api/logs` | Send logs (scoped to accessible routes) | | `GET /admin/api/logs/:id` | Single send-log entry (scoped) | | `GET /admin/api/audit` | Audit log (scoped to accessible groups) | +| `GET /admin/api/metrics` | Delivery metrics (totals, failure rate, per platform/event/status, recent failures; recent failures scoped to accessible groups for non-super) | +| `GET /admin/api/delivery/:deliveryId` | All send-log attempts for one delivery (group-scoped) | ## Validation diff --git a/docs/zh/api/admin.md b/docs/zh/api/admin.md index a59a79a..19f892e 100644 --- a/docs/zh/api/admin.md +++ b/docs/zh/api/admin.md @@ -29,6 +29,8 @@ | `GET /admin/api/logs` | 发送日志(按可访问的路由过滤) | | `GET /admin/api/logs/:id` | 单条发送日志(按权限过滤) | | `GET /admin/api/audit` | 审计日志(按可访问的分组过滤) | +| `GET /admin/api/metrics` | 投递指标(总计、失败率、按平台/事件/状态、最近失败;非超管按可访问分组过滤最近失败) | +| `GET /admin/api/delivery/:deliveryId` | 单次投递的全部发送日志(按分组过滤) | ## 校验 diff --git a/server/lib/lib/send-log.ts b/server/lib/lib/send-log.ts index 18e8c63..a0e00d7 100644 --- a/server/lib/lib/send-log.ts +++ b/server/lib/lib/send-log.ts @@ -130,3 +130,32 @@ export async function getSendLogById(db: D1Database, id: number): Promise { + try { + const { results } = await db + .prepare(`SELECT ${COLUMNS} FROM send_logs WHERE delivery_id = ? ORDER BY ts DESC`) + .bind(deliveryId) + .all(); + return results.map(toRecord); + } catch (err) { + log.warn({ err, deliveryId }, "Failed to load send log by delivery"); + return []; + } +} + +export async function getFailedSendLog(db: D1Database, limit = 20): Promise { + try { + const { results } = await db + .prepare(`SELECT ${COLUMNS} FROM send_logs WHERE ok = 0 ORDER BY ts DESC LIMIT ?`) + .bind(limit) + .all(); + return results.map(toRecord); + } catch (err) { + log.warn({ err }, "Failed to load failed send log"); + return []; + } +} diff --git a/server/lib/observability/metrics.ts b/server/lib/observability/metrics.ts new file mode 100644 index 0000000..c12227c --- /dev/null +++ b/server/lib/observability/metrics.ts @@ -0,0 +1,122 @@ +import { log } from "../lib/log"; +import { getFailedSendLog, type SendRecord } from "../lib/send-log"; + +export interface DeliveryMetrics { + total: number; + ok: number; + failed: number; + failureRate: number; + byPlatform: { platform: string; total: number; ok: number; failed: number }[]; + byEvent: { event: string; total: number; ok: number; failed: number }[]; + byStatus: { status: string; count: number }[]; + avgDurationMs: number; + totalAttempts: number; + avgAttempts: number; + recentFailures: SendRecord[]; +} + +interface TotalsRow { + total: number; + ok: number; +} + +interface BreakdownRow { + key: string | null; + total: number; + ok: number; +} + +interface StatusRow { + status: number; + count: number; +} + +interface AvgRow { + avg: number | null; +} + +interface AttemptsRow { + total: number; + avg: number | null; +} + +const EMPTY: DeliveryMetrics = { + total: 0, + ok: 0, + failed: 0, + failureRate: 0, + byPlatform: [], + byEvent: [], + byStatus: [], + avgDurationMs: 0, + totalAttempts: 0, + avgAttempts: 0, + recentFailures: [], +}; + +export async function getDeliveryMetrics(db: D1Database): Promise { + try { + const totals = await db + .prepare(`SELECT COUNT(*) AS total, COALESCE(SUM(ok), 0) AS ok FROM send_logs`) + .all(); + const total = Number(totals.results[0]?.total) || 0; + const ok = Number(totals.results[0]?.ok) || 0; + + const byPlatform = await db + .prepare( + `SELECT platform AS key, COUNT(*) AS total, COALESCE(SUM(ok), 0) AS ok + FROM send_logs GROUP BY platform`, + ) + .all(); + const byEvent = await db + .prepare( + `SELECT event AS key, COUNT(*) AS total, COALESCE(SUM(ok), 0) AS ok + FROM send_logs GROUP BY event`, + ) + .all(); + const byStatus = await db + .prepare( + `SELECT status, COUNT(*) AS count FROM send_logs + WHERE status IS NOT NULL GROUP BY status`, + ) + .all(); + const duration = await db + .prepare(`SELECT AVG(duration_ms) AS avg FROM send_logs WHERE duration_ms IS NOT NULL`) + .all(); + const attempts = await db + .prepare( + `SELECT COALESCE(SUM(attempts), 0) AS total, AVG(attempts) AS avg + FROM send_logs WHERE attempts IS NOT NULL`, + ) + .all(); + const recentFailures = await getFailedSendLog(db, 20); + + return { + total, + ok, + failed: total - ok, + failureRate: total > 0 ? (total - ok) / total : 0, + byPlatform: byPlatform.results.map((r) => { + const t = Number(r.total) || 0; + const o = Number(r.ok) || 0; + return { platform: r.key ?? "unknown", total: t, ok: o, failed: t - o }; + }), + byEvent: byEvent.results.map((r) => { + const t = Number(r.total) || 0; + const o = Number(r.ok) || 0; + return { event: r.key ?? "unknown", total: t, ok: o, failed: t - o }; + }), + byStatus: byStatus.results.map((r) => ({ + status: String(r.status), + count: Number(r.count) || 0, + })), + avgDurationMs: Number(duration.results[0]?.avg) || 0, + totalAttempts: Number(attempts.results[0]?.total) || 0, + avgAttempts: Number(attempts.results[0]?.avg) || 0, + recentFailures, + }; + } catch (err) { + log.warn({ err }, "Failed to compute delivery metrics"); + return EMPTY; + } +} diff --git a/server/lib/web/admin.ts b/server/lib/web/admin.ts index 78dafc6..80c7a20 100644 --- a/server/lib/web/admin.ts +++ b/server/lib/web/admin.ts @@ -20,7 +20,8 @@ import { clientIp, type GroupAccess, } from "./auth"; -import { getSendLog, getSendLogById } from "../lib/send-log"; +import { getSendLog, getSendLogById, getSendLogByDelivery } from "../lib/send-log"; +import { getDeliveryMetrics } from "../observability/metrics"; import { getAuditLog, recordAudit } from "../lib/audit"; import { createInvite, @@ -1012,3 +1013,37 @@ export async function adminAudit(event: H3Event): Promise e.groupId != null && auth.scope.groupIds.has(e.groupId)); return { audit: visible }; } + +/** GET /admin/api/metrics */ +export async function adminApiMetrics(event: H3Event): Promise> { + const auth = await requireAnyAccess(event); + const env = cfEnv(event); + const metrics = await getDeliveryMetrics(env.DB); + if (auth.scope.isSuper) return { metrics }; + return { + metrics: { + ...metrics, + recentFailures: metrics.recentFailures.filter( + (f) => f.groupId != null && auth.scope.groupIds.has(f.groupId), + ), + }, + }; +} + +/** GET /admin/api/delivery/:deliveryId */ +export async function adminApiDelivery( + event: H3Event, + deliveryId: string, +): Promise> { + const auth = await requireAnyAccess(event); + const env = cfEnv(event); + const rows = await getSendLogByDelivery(env.DB, deliveryId); + if (rows.length === 0) return respondError(event, 404, "Delivery not found"); + if ( + !auth.scope.isSuper && + rows.some((r) => r.groupId == null || !auth.scope.groupIds.has(r.groupId)) + ) { + return respondError(event, 403, "Forbidden"); + } + return { deliveryId, attempts: rows }; +} diff --git a/server/routes/admin/api/[...slug].ts b/server/routes/admin/api/[...slug].ts new file mode 100644 index 0000000..eae54e6 --- /dev/null +++ b/server/routes/admin/api/[...slug].ts @@ -0,0 +1,76 @@ +import { defineEventHandler, getMethod, getRouterParam, setResponseStatus } from "h3"; +import { + adminApiMe, + adminApiGroupsGet, + adminApiGroupsPut, + adminApiRoutesGet, + adminApiRoutesPut, + adminApiLogs, + adminApiLogsById, + adminGroupRoutesGet, + adminGroupRoutesPut, + adminGroupInvitesPost, + adminGroupInvitesGet, + adminInviteDelete, + adminGroupRename, + adminGroupWebhookGet, + adminGroupWebhookRegenerate, + adminGroupWebhookDelete, + adminAudit, + adminApiMetrics, + adminApiDelivery, +} from "../../../lib/web/admin"; + +export default defineEventHandler((event) => { + const method = getMethod(event); + const seg = (getRouterParam(event, "slug") ?? "").split("/").filter(Boolean); + + if (seg[0] === "me" && method === "GET") return adminApiMe(event); + if (seg[0] === "groups" && seg.length === 1) { + if (method === "GET") return adminApiGroupsGet(event); + if (method === "PUT") return adminApiGroupsPut(event); + } + if (seg[0] === "routes" && seg.length === 1) { + if (method === "GET") return adminApiRoutesGet(event); + if (method === "PUT") return adminApiRoutesPut(event); + } + if (seg[0] === "logs" && seg.length === 1 && method === "GET") return adminApiLogs(event); + if (seg[0] === "logs" && seg.length === 2 && method === "GET") + return adminApiLogsById(event, Number(seg[1])); + if (seg[0] === "groups" && seg[2] === "routes" && seg.length === 3) { + if (method === "GET") return adminGroupRoutesGet(event, seg[1]); + if (method === "PUT") return adminGroupRoutesPut(event, seg[1]); + } + if (seg[0] === "groups" && seg[2] === "invites" && seg.length === 3) { + if (method === "GET") return adminGroupInvitesGet(event, seg[1]); + if (method === "POST") return adminGroupInvitesPost(event, seg[1]); + } + if (seg[0] === "invites" && seg.length === 2 && method === "DELETE") + return adminInviteDelete(event, seg[1]); + if ( + seg[0] === "groups" && + seg[2] === "rename" && + seg.length === 3 && + (method === "POST" || method === "PUT") + ) + return adminGroupRename(event, seg[1]); + if ( + seg[0] === "groups" && + seg[2] === "webhook" && + seg[3] === "regenerate" && + seg.length === 4 && + method === "POST" + ) + return adminGroupWebhookRegenerate(event, seg[1]); + if (seg[0] === "groups" && seg[2] === "webhook" && seg.length === 3) { + if (method === "GET") return adminGroupWebhookGet(event, seg[1]); + if (method === "DELETE") return adminGroupWebhookDelete(event, seg[1]); + } + if (seg[0] === "audit" && seg.length === 1 && method === "GET") return adminAudit(event); + if (seg[0] === "metrics" && seg.length === 1 && method === "GET") return adminApiMetrics(event); + if (seg[0] === "delivery" && seg.length === 2 && method === "GET") + return adminApiDelivery(event, seg[1]); + + setResponseStatus(event, 404); + return { error: "Not found" }; +}); diff --git a/tests/delivery-metrics.test.ts b/tests/delivery-metrics.test.ts new file mode 100644 index 0000000..aaab9ed --- /dev/null +++ b/tests/delivery-metrics.test.ts @@ -0,0 +1,223 @@ +import { describe, it, expect } from "bun:test"; +import { getDeliveryMetrics } from "../server/lib/observability/metrics"; +import { + getSendLogByDelivery, + getFailedSendLog, + type SendRecord, +} from "../server/lib/lib/send-log"; +import { adminApiMetrics, adminApiDelivery } from "../server/lib/web/admin"; +import { createAdminSession, adminCookie } from "../server/lib/web/session"; +import { makeEvent, responseStatus } from "./helpers"; +import type { Env } from "../server/lib/types"; + +function createMockKV(): KVNamespace { + const store = new Map(); + return { + get: async (key: string, type?: string) => { + const v = store.get(key); + if (v == null) return null; + if (type === "json") return JSON.parse(v); + return v; + }, + put: async (key: string, value: string) => { + store.set(key, value); + }, + delete: async (key: string) => { + store.delete(key); + }, + list: async () => ({ + keys: [...store.keys()].map((k) => ({ name: k })), + list_complete: true, + cacheStatus: null, + }), + } as unknown as KVNamespace; +} + +function createEnv(overrides: Partial = {}): Env { + return { + GITHUB_WEBHOOK_SECRET: "secret", + KV: createMockKV(), + DB: createMetricsDB({}), + ...overrides, + }; +} + +function createMetricsDB(rowsBySql: Record): D1Database { + const all = (sql: string) => async (): Promise<{ results: unknown[] }> => { + for (const [key, rows] of Object.entries(rowsBySql)) { + if (sql.includes(key)) return { results: rows }; + } + return { results: [] }; + }; + return { + prepare: (sql: string) => ({ + all: all(sql), + bind: (..._args: unknown[]) => ({ + all: all(sql), + run: async () => ({ success: true }), + }), + }), + } as unknown as D1Database; +} + +const logRow = (overrides: Record = {}): Record => ({ + id: 1, + ts: 1700000000000, + route_id: "r1", + group_id: "mine", + event: "push", + repo: "acme/widget", + target: "discord:111", + ok: 1, + error: null, + status: 200, + message_id: "m1", + delivery_id: "d1", + platform: "discord", + actor: "alice", + action: null, + duration_ms: 100, + error_code: null, + attempts: 1, + detail: null, + ...overrides, +}); + +describe("delivery metrics", () => { + it("aggregates send_logs into metrics", async () => { + const db = createMetricsDB({ + "platform AS key": [ + { key: "discord", total: 7, ok: 6 }, + { key: "telegram", total: 3, ok: 2 }, + ], + "event AS key": [{ key: "push", total: 10, ok: 8 }], + "GROUP BY status": [ + { status: "200", count: 8 }, + { status: "500", count: 2 }, + ], + "AVG(duration_ms)": [{ avg: 120.5 }], + "SUM(attempts)": [{ total: 12, avg: 1.2 }], + "ok = 0": [logRow({ ok: 0, status: 500, error_code: "DISCORD_5XX" })], + "COUNT(*) AS total": [{ total: 10, ok: 8 }], + }); + const m = await getDeliveryMetrics(db); + expect(m.total).toBe(10); + expect(m.ok).toBe(8); + expect(m.failed).toBe(2); + expect(m.failureRate).toBeCloseTo(0.2); + expect(m.byPlatform).toEqual([ + { platform: "discord", total: 7, ok: 6, failed: 1 }, + { platform: "telegram", total: 3, ok: 2, failed: 1 }, + ]); + expect(m.byEvent).toEqual([{ event: "push", total: 10, ok: 8, failed: 2 }]); + expect(m.byStatus).toEqual([ + { status: "200", count: 8 }, + { status: "500", count: 2 }, + ]); + expect(m.avgDurationMs).toBeCloseTo(120.5); + expect(m.totalAttempts).toBe(12); + expect(m.avgAttempts).toBeCloseTo(1.2); + expect(m.recentFailures).toHaveLength(1); + expect(m.recentFailures[0]!.errorCode).toBe("DISCORD_5XX"); + }); + + it("returns zeroed metrics on empty database", async () => { + const m = await getDeliveryMetrics(createMetricsDB({})); + expect(m.total).toBe(0); + expect(m.ok).toBe(0); + expect(m.failed).toBe(0); + expect(m.failureRate).toBe(0); + expect(m.byPlatform).toEqual([]); + expect(m.recentFailures).toEqual([]); + }); + + it("correlates send_logs by delivery id", async () => { + const db = createMetricsDB({ + delivery_id: [ + logRow({ id: 1, ok: 1, delivery_id: "d1" }), + logRow({ id: 2, ok: 0, delivery_id: "d1", platform: "telegram", status: 500 }), + ], + }); + const rows = await getSendLogByDelivery(db, "d1"); + expect(rows).toHaveLength(2); + expect(rows[0]!.deliveryId).toBe("d1"); + expect(rows[1]!.ok).toBe(false); + }); + + it("lists failed sends", async () => { + const db = createMetricsDB({ + "ok = 0": [logRow({ ok: 0, error_code: "NETWORK" })], + }); + const rows: SendRecord[] = await getFailedSendLog(db, 5); + expect(rows).toHaveLength(1); + expect(rows[0]!.ok).toBe(false); + expect(rows[0]!.errorCode).toBe("NETWORK"); + }); +}); + +describe("admin metrics/delivery handlers", () => { + it("returns global metrics for super admins", async () => { + const kv = createMockKV(); + const db = createMetricsDB({ "COUNT(*) AS total": [{ total: 5, ok: 4 }] }); + const env = createEnv({ KV: kv, DB: db, ADMIN_USER_IDS: "1001" }); + const sid = await createAdminSession(kv, "1001", "alice"); + const event = makeEvent("/api/metrics", { headers: { cookie: adminCookie(sid) }, env }); + const res = (await adminApiMetrics(event)) as { metrics: { total: number } }; + expect(responseStatus(event)).toBe(200); + expect(res.metrics.total).toBe(5); + }); + + it("filters recentFailures for non-super users", async () => { + const kv = createMockKV(); + await kv.put( + "config:groups", + JSON.stringify([ + { id: "mine", name: "Mine", adminIds: [], members: [{ login: "bob", role: "owner" }] }, + ]), + ); + const db = createMetricsDB({ + "ok = 0": [logRow({ ok: 0, group_id: "mine" }), logRow({ ok: 0, group_id: "theirs" })], + "COUNT(*) AS total": [{ total: 1, ok: 0 }], + }); + const env = createEnv({ KV: kv, DB: db }); + const sid = await createAdminSession(kv, "2002", "bob"); + const event = makeEvent("/api/metrics", { headers: { cookie: adminCookie(sid) }, env }); + const res = (await adminApiMetrics(event)) as { + metrics: { recentFailures: Array<{ groupId?: string }> }; + }; + expect(res.metrics.recentFailures).toHaveLength(1); + expect(res.metrics.recentFailures[0]!.groupId).toBe("mine"); + }); + + it("returns 404 for an unknown delivery", async () => { + const kv = createMockKV(); + const env = createEnv({ + KV: kv, + DB: createMetricsDB({ delivery_id: [] }), + ADMIN_USER_IDS: "1001", + }); + const sid = await createAdminSession(kv, "1001", "alice"); + const event = makeEvent("/api/delivery/nope", { headers: { cookie: adminCookie(sid) }, env }); + const res = (await adminApiDelivery(event, "nope")) as { error?: string }; + expect(responseStatus(event)).toBe(404); + expect(res.error).toBe("Delivery not found"); + }); + + it("returns correlated attempts for a delivery", async () => { + const kv = createMockKV(); + const db = createMetricsDB({ + delivery_id: [logRow({ id: 1, ok: 1, delivery_id: "d1" })], + }); + const env = createEnv({ KV: kv, DB: db, ADMIN_USER_IDS: "1001" }); + const sid = await createAdminSession(kv, "1001", "alice"); + const event = makeEvent("/api/delivery/d1", { headers: { cookie: adminCookie(sid) }, env }); + const res = (await adminApiDelivery(event, "d1")) as { + deliveryId?: string; + attempts?: Array<{ deliveryId?: string }>; + }; + expect(responseStatus(event)).toBe(200); + expect(res.deliveryId).toBe("d1"); + expect(res.attempts).toHaveLength(1); + expect(res.attempts![0]!.deliveryId).toBe("d1"); + }); +});