From b534ce5bf89a6073db69f433b53e34ea41b580a9 Mon Sep 17 00:00:00 2001 From: Julien Herr Date: Sat, 23 May 2026 09:50:51 +0200 Subject: [PATCH] feat(monitoring): add stats counters API and public status page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add GET /api/stats exposing cumulative counters (feeds created/deleted, emails received/rejected, recent date-times) plus live values (active feeds, active WebSub subscriptions). Counters persist in a stats:counters KV singleton and are incremented at the email-processing chokepoint and feed create/delete paths. Replace the / → /admin redirect with a public status page rendering these figures with a link to the admin. Co-Authored-By: Claude Opus 4.7 --- CLAUDE.md | 5 ++ README.md | 22 ++++++ src/config/constants.ts | 3 + src/index.ts | 13 +++- src/lib/email-processor.test.ts | 29 ++++++++ src/lib/email-processor.ts | 10 ++- src/routes/admin/feeds.tsx | 17 ++++- src/routes/admin/ui.tsx | 5 +- src/routes/home.tsx | 57 +++++++++++++++ src/routes/stats.test.ts | 66 +++++++++++++++++ src/routes/stats.ts | 7 ++ src/styles/components.css | 33 +++++++++ src/types/index.ts | 17 +++++ src/utils/stats.test.ts | 122 ++++++++++++++++++++++++++++++++ src/utils/stats.ts | 84 ++++++++++++++++++++++ 15 files changed, 484 insertions(+), 6 deletions(-) create mode 100644 src/routes/home.tsx create mode 100644 src/routes/stats.test.ts create mode 100644 src/routes/stats.ts create mode 100644 src/utils/stats.test.ts create mode 100644 src/utils/stats.ts diff --git a/CLAUDE.md b/CLAUDE.md index 5a82a27..50e0800 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,7 +32,9 @@ Single Cloudflare Worker built with Hono. Routes: | Method | Path | Purpose | | ------------------------------------ | ---------------------------------------------------------------------- | ------- | +| `GET /` | Public status page (monitoring counters + link to admin) | | `POST /api/inbound` | Webhook from ForwardEmail; IP-allowlisted to their MX sources | +| `GET /api/stats` | Public monitoring counters (JSON) | | `GET /rss/:feedId` | Public RSS 2.0 feed | | `GET /atom/:feedId` | Public Atom feed (with WebSub hub header) | | `GET /entries/:feedId/:entryId` | Individual email HTML view | @@ -56,6 +58,8 @@ src/ entries.ts # Single email HTML view files.ts # R2 attachment serving hub.ts # WebSub hub + home.tsx # Public status page (GET /) + stats.ts # Monitoring counters API (GET /api/stats) admin.tsx # Admin UI entrypoint (hono/jsx) admin/ # Admin sub-modules feeds.tsx # Feeds CRUD UI @@ -99,6 +103,7 @@ All data lives in the `EMAIL_STORAGE` KV namespace: | `feed::metadata` | `{ emails: Array<{ key, subject, receivedAt, size?, attachmentIds? }> }` | | `feed::` | Full `EmailData` | | `websub::` | `WebSubSubscription` | +| `stats:counters` | `Counters` (cumulative monitoring counters singleton) | `src/lib/storage.ts` contains key-builder helpers — use them; don't inline key strings in routes. diff --git a/README.md b/README.md index b3781e2..8e6437a 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ Common path: 2. The Worker resolves the feed from the recipient address and stores the email in KV. 3. `https://yourdomain.com/rss/:feedId` renders RSS from stored items. 4. `/admin` provides feed management and email deletion. +5. `https://yourdomain.com/` shows a public status page with monitoring counters and a link to the admin. Main routes: @@ -50,6 +51,27 @@ Main routes: - `src/routes/atom.ts`: Atom feed rendering - `src/routes/files.ts`: attachment file serving from R2 - `src/routes/admin.ts`: admin UI + feed CRUD +- `src/routes/home.tsx`: public status page (`GET /`) +- `src/routes/stats.ts`: monitoring counters API (`GET /api/stats`) + +### Monitoring + +`GET /api/stats` returns JSON counters (public, no auth) for uptime/monitoring tools: + +| Field | Meaning | +| ----------------------------- | -------------------------------------------------------- | +| `active_feeds` | Feeds currently configured (live) | +| `feeds_created` | Total feeds ever created (cumulative) | +| `feeds_deleted` | Total feeds ever deleted (cumulative) | +| `emails_received` | Total emails ingested successfully (cumulative) | +| `emails_rejected` | Total emails rejected during validation (cumulative) | +| `websub_subscriptions_active` | Active WebSub subscriptions (live) | +| `last_email_at` | ISO 8601 date-time of the last ingested email | +| `last_feed_created_at` | ISO 8601 date-time of the last feed creation | +| `first_seen` | ISO 8601 date-time the instance first recorded a counter | + +The same figures are rendered on the public status page at `GET /`. Cumulative counters +are persisted in the `EMAIL_STORAGE` KV under the `stats:counters` key. ## Requirements diff --git a/src/config/constants.ts b/src/config/constants.ts index 21c7ddb..af04fd4 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -21,3 +21,6 @@ export const MAX_LEASE_SECONDS = 30 * 24 * 3600; // 30 days /** KV key for the global feed list. */ export const FEEDS_LIST_KEY = "feeds:list"; + +/** KV key for the monitoring counters singleton. */ +export const STATS_KEY = "stats:counters"; diff --git a/src/index.ts b/src/index.ts index cf0c7b6..50d2e49 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,8 @@ import { handle as handleAtom } from "./routes/atom"; import { handle as handleAdmin } from "./routes/admin"; import { handle as handleEntry } from "./routes/entries"; import { handle as handleFiles } from "./routes/files"; +import { handle as handleStats } from "./routes/stats"; +import { handle as handleHome } from "./routes/home"; import { hubRouter } from "./routes/hub"; import { handleCloudflareEmail } from "./lib/cloudflare-email"; import { Env } from "./types"; @@ -15,6 +17,7 @@ import { purgeExpiredFeeds, removeFeedsFromListBulk, } from "./routes/admin/helpers"; +import { bumpCounters } from "./utils/stats"; import { FORWARD_EMAIL_IPS_CACHE_TTL_MS } from "./config/constants"; type AppEnv = { Bindings: Env }; @@ -137,6 +140,9 @@ api.use("/inbound", async (c, next) => { // API routes (inbound webhook) api.post("/inbound", handleInbound); +// Public monitoring stats (JSON) +api.get("/stats", handleStats); + // RSS feed routes (public) rss.get("/:feedId", handleRSS); @@ -164,8 +170,8 @@ app.route("/hub", hubRouter); // Health check endpoint for monitoring app.get("/health", (c) => c.json({ status: "ok", timestamp: Date.now() })); -// Root path redirects to admin dashboard -app.get("/", (c) => c.redirect("/admin")); +// Public status page (counters + link to admin) +app.get("/", handleHome); // Catch-all for 404s app.all("*", (c) => c.text("Not Found", 404)); @@ -192,6 +198,9 @@ export default { } if (expiredIds.length > 0) { await removeFeedsFromListBulk(env.EMAIL_STORAGE, expiredIds); + await bumpCounters(env.EMAIL_STORAGE, { + feeds_deleted: expiredIds.length, + }); logger.info("Feed TTL cleanup", { deleted: expiredIds.length }); } }, diff --git a/src/lib/email-processor.test.ts b/src/lib/email-processor.test.ts index e1d2ed2..a11c1fd 100644 --- a/src/lib/email-processor.test.ts +++ b/src/lib/email-processor.test.ts @@ -6,6 +6,7 @@ import { ProcessEmailInput, RawAttachment, } from "./email-processor"; +import { getCounters } from "../utils/stats"; const VALID_FEED_ID = "apple.mountain.42"; const VALID_TO = `${VALID_FEED_ID}@test.getmynews.app`; @@ -467,3 +468,31 @@ describe("processEmail — attachments", () => { expect(mockR2._has(oldAttachmentId)).toBe(false); }); }); + +describe("processEmail — monitoring counters", () => { + it("increments emails_received and sets last_email_at on success", async () => { + const env = createMockEnv(); + await env.EMAIL_STORAGE.put( + `feed:${VALID_FEED_ID}:config`, + JSON.stringify({}), + ); + + await processEmail(makeInput(), env as any); + + const counters = await getCounters(env.EMAIL_STORAGE as any); + expect(counters.emails_received).toBe(1); + expect(counters.emails_rejected).toBe(0); + expect(counters.last_email_at).toBeDefined(); + }); + + it("increments emails_rejected when validation fails", async () => { + const env = createMockEnv(); + + // No feed config → 404 rejection + await processEmail(makeInput(), env as any); + + const counters = await getCounters(env.EMAIL_STORAGE as any); + expect(counters.emails_rejected).toBe(1); + expect(counters.emails_received).toBe(0); + }); +}); diff --git a/src/lib/email-processor.ts b/src/lib/email-processor.ts index e3b6e87..e804734 100644 --- a/src/lib/email-processor.ts +++ b/src/lib/email-processor.ts @@ -7,6 +7,7 @@ import { FeedMetadata, } from "../types"; import { notifySubscribers } from "../utils/websub"; +import { bumpCounters } from "../utils/stats"; import { logger } from "./logger"; import { FEED_MAX_BYTES } from "../config/constants"; @@ -248,8 +249,15 @@ export async function processEmail( ctx?: ExecutionContext, ): Promise { const validation = await validateEmail(input, env); - if (!validation.ok) return validation.response; + if (!validation.ok) { + await bumpCounters(env.EMAIL_STORAGE, { emails_rejected: 1 }); + return validation.response; + } await storeEmail(validation.feedId, input, env, ctx); + await bumpCounters(env.EMAIL_STORAGE, { + emails_received: 1, + last_email_at: new Date().toISOString(), + }); return new Response("Email processed successfully", { status: 200 }); } diff --git a/src/routes/admin/feeds.tsx b/src/routes/admin/feeds.tsx index 3f04dfc..98fcdd1 100644 --- a/src/routes/admin/feeds.tsx +++ b/src/routes/admin/feeds.tsx @@ -2,6 +2,7 @@ import { Hono } from "hono"; import { z } from "zod"; import { Env, FeedConfig, FeedMetadata } from "../../types"; import { generateFeedId } from "../../utils/id-generator"; +import { bumpCounters } from "../../utils/stats"; import { waitUntilSafe } from "../../utils/worker"; import { feedRssUrl, feedEmailAddress } from "../../utils/urls"; import { logger } from "../../lib/logger"; @@ -191,6 +192,11 @@ feedsRouter.post("/create", async (c) => { expiresAt, ); + await bumpCounters(emailStorage, { + feeds_created: 1, + last_feed_created_at: new Date().toISOString(), + }); + if (isJson) { return c.json({ feedId, @@ -528,7 +534,10 @@ feedsRouter.post("/:feedId/delete", async (c) => { try { await deleteFeedFast(emailStorage, feedId); - await removeFeedFromList(emailStorage, feedId); + const removed = await removeFeedFromList(emailStorage, feedId); + if (removed) { + await bumpCounters(emailStorage, { feeds_deleted: 1 }); + } waitUntilSafe( c, @@ -658,6 +667,9 @@ feedsRouter.post("/bulk-delete", async (c) => { } const deletedFeedIds = await removeFeedsFromListBulk(emailStorage, okIds); + if (deletedFeedIds.length > 0) { + await bumpCounters(emailStorage, { feeds_deleted: deletedFeedIds.length }); + } const removed = new Set(deletedFeedIds); okIds.forEach((feedId) => { @@ -707,6 +719,9 @@ feedsRouter.post("/bulk-delete", async (c) => { } const deletedFeedIds = await removeFeedsFromListBulk(emailStorage, okIds); + if (deletedFeedIds.length > 0) { + await bumpCounters(emailStorage, { feeds_deleted: deletedFeedIds.length }); + } return c.redirect( `${redirectBase}&message=bulkDeleted&count=${deletedFeedIds.length}`, diff --git a/src/routes/admin/ui.tsx b/src/routes/admin/ui.tsx index 2537226..35fdcd8 100644 --- a/src/routes/admin/ui.tsx +++ b/src/routes/admin/ui.tsx @@ -8,10 +8,11 @@ const designSystem = [variablesCss, layoutCss, componentsCss, utilitiesCss].join type LayoutProps = { title: string; + label?: string; children: import("hono/jsx").Child; }; -export const Layout = ({ title, children }: LayoutProps) => { +export const Layout = ({ title, label = "admin", children }: LayoutProps) => { return ( @@ -38,7 +39,7 @@ export const Layout = ({ title, children }: LayoutProps) => { - admin + {label} {children}