feat(monitoring): add stats counters API and public status page

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 <noreply@anthropic.com>
This commit is contained in:
Julien Herr
2026-05-23 09:50:51 +02:00
parent f4d5edda0e
commit b534ce5bf8
15 changed files with 484 additions and 6 deletions
+5
View File
@@ -32,7 +32,9 @@ Single Cloudflare Worker built with Hono. Routes:
| Method | Path | Purpose | | Method | Path | Purpose |
| ------------------------------------ | ---------------------------------------------------------------------- | ------- | | ------------------------------------ | ---------------------------------------------------------------------- | ------- |
| `GET /` | Public status page (monitoring counters + link to admin) |
| `POST /api/inbound` | Webhook from ForwardEmail; IP-allowlisted to their MX sources | | `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 /rss/:feedId` | Public RSS 2.0 feed |
| `GET /atom/:feedId` | Public Atom feed (with WebSub hub header) | | `GET /atom/:feedId` | Public Atom feed (with WebSub hub header) |
| `GET /entries/:feedId/:entryId` | Individual email HTML view | | `GET /entries/:feedId/:entryId` | Individual email HTML view |
@@ -56,6 +58,8 @@ src/
entries.ts # Single email HTML view entries.ts # Single email HTML view
files.ts # R2 attachment serving files.ts # R2 attachment serving
hub.ts # WebSub hub 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.tsx # Admin UI entrypoint (hono/jsx)
admin/ # Admin sub-modules admin/ # Admin sub-modules
feeds.tsx # Feeds CRUD UI feeds.tsx # Feeds CRUD UI
@@ -99,6 +103,7 @@ All data lives in the `EMAIL_STORAGE` KV namespace:
| `feed:<feedId>:metadata` | `{ emails: Array<{ key, subject, receivedAt, size?, attachmentIds? }> }` | | `feed:<feedId>:metadata` | `{ emails: Array<{ key, subject, receivedAt, size?, attachmentIds? }> }` |
| `feed:<feedId>:<timestamp>` | Full `EmailData` | | `feed:<feedId>:<timestamp>` | Full `EmailData` |
| `websub:<feedId>:<callbackHash>` | `WebSubSubscription` | | `websub:<feedId>:<callbackHash>` | `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. `src/lib/storage.ts` contains key-builder helpers — use them; don't inline key strings in routes.
+22
View File
@@ -41,6 +41,7 @@ Common path:
2. The Worker resolves the feed from the recipient address and stores the email in KV. 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. 3. `https://yourdomain.com/rss/:feedId` renders RSS from stored items.
4. `/admin` provides feed management and email deletion. 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: Main routes:
@@ -50,6 +51,27 @@ Main routes:
- `src/routes/atom.ts`: Atom feed rendering - `src/routes/atom.ts`: Atom feed rendering
- `src/routes/files.ts`: attachment file serving from R2 - `src/routes/files.ts`: attachment file serving from R2
- `src/routes/admin.ts`: admin UI + feed CRUD - `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 ## Requirements
+3
View File
@@ -21,3 +21,6 @@ export const MAX_LEASE_SECONDS = 30 * 24 * 3600; // 30 days
/** KV key for the global feed list. */ /** KV key for the global feed list. */
export const FEEDS_LIST_KEY = "feeds:list"; export const FEEDS_LIST_KEY = "feeds:list";
/** KV key for the monitoring counters singleton. */
export const STATS_KEY = "stats:counters";
+11 -2
View File
@@ -6,6 +6,8 @@ import { handle as handleAtom } from "./routes/atom";
import { handle as handleAdmin } from "./routes/admin"; import { handle as handleAdmin } from "./routes/admin";
import { handle as handleEntry } from "./routes/entries"; import { handle as handleEntry } from "./routes/entries";
import { handle as handleFiles } from "./routes/files"; 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 { hubRouter } from "./routes/hub";
import { handleCloudflareEmail } from "./lib/cloudflare-email"; import { handleCloudflareEmail } from "./lib/cloudflare-email";
import { Env } from "./types"; import { Env } from "./types";
@@ -15,6 +17,7 @@ import {
purgeExpiredFeeds, purgeExpiredFeeds,
removeFeedsFromListBulk, removeFeedsFromListBulk,
} from "./routes/admin/helpers"; } from "./routes/admin/helpers";
import { bumpCounters } from "./utils/stats";
import { FORWARD_EMAIL_IPS_CACHE_TTL_MS } from "./config/constants"; import { FORWARD_EMAIL_IPS_CACHE_TTL_MS } from "./config/constants";
type AppEnv = { Bindings: Env }; type AppEnv = { Bindings: Env };
@@ -137,6 +140,9 @@ api.use("/inbound", async (c, next) => {
// API routes (inbound webhook) // API routes (inbound webhook)
api.post("/inbound", handleInbound); api.post("/inbound", handleInbound);
// Public monitoring stats (JSON)
api.get("/stats", handleStats);
// RSS feed routes (public) // RSS feed routes (public)
rss.get("/:feedId", handleRSS); rss.get("/:feedId", handleRSS);
@@ -164,8 +170,8 @@ app.route("/hub", hubRouter);
// Health check endpoint for monitoring // Health check endpoint for monitoring
app.get("/health", (c) => c.json({ status: "ok", timestamp: Date.now() })); app.get("/health", (c) => c.json({ status: "ok", timestamp: Date.now() }));
// Root path redirects to admin dashboard // Public status page (counters + link to admin)
app.get("/", (c) => c.redirect("/admin")); app.get("/", handleHome);
// Catch-all for 404s // Catch-all for 404s
app.all("*", (c) => c.text("Not Found", 404)); app.all("*", (c) => c.text("Not Found", 404));
@@ -192,6 +198,9 @@ export default {
} }
if (expiredIds.length > 0) { if (expiredIds.length > 0) {
await removeFeedsFromListBulk(env.EMAIL_STORAGE, expiredIds); await removeFeedsFromListBulk(env.EMAIL_STORAGE, expiredIds);
await bumpCounters(env.EMAIL_STORAGE, {
feeds_deleted: expiredIds.length,
});
logger.info("Feed TTL cleanup", { deleted: expiredIds.length }); logger.info("Feed TTL cleanup", { deleted: expiredIds.length });
} }
}, },
+29
View File
@@ -6,6 +6,7 @@ import {
ProcessEmailInput, ProcessEmailInput,
RawAttachment, RawAttachment,
} from "./email-processor"; } from "./email-processor";
import { getCounters } from "../utils/stats";
const VALID_FEED_ID = "apple.mountain.42"; const VALID_FEED_ID = "apple.mountain.42";
const VALID_TO = `${VALID_FEED_ID}@test.getmynews.app`; const VALID_TO = `${VALID_FEED_ID}@test.getmynews.app`;
@@ -467,3 +468,31 @@ describe("processEmail — attachments", () => {
expect(mockR2._has(oldAttachmentId)).toBe(false); 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);
});
});
+9 -1
View File
@@ -7,6 +7,7 @@ import {
FeedMetadata, FeedMetadata,
} from "../types"; } from "../types";
import { notifySubscribers } from "../utils/websub"; import { notifySubscribers } from "../utils/websub";
import { bumpCounters } from "../utils/stats";
import { logger } from "./logger"; import { logger } from "./logger";
import { FEED_MAX_BYTES } from "../config/constants"; import { FEED_MAX_BYTES } from "../config/constants";
@@ -248,8 +249,15 @@ export async function processEmail(
ctx?: ExecutionContext, ctx?: ExecutionContext,
): Promise<Response> { ): Promise<Response> {
const validation = await validateEmail(input, env); 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 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 }); return new Response("Email processed successfully", { status: 200 });
} }
+16 -1
View File
@@ -2,6 +2,7 @@ import { Hono } from "hono";
import { z } from "zod"; import { z } from "zod";
import { Env, FeedConfig, FeedMetadata } from "../../types"; import { Env, FeedConfig, FeedMetadata } from "../../types";
import { generateFeedId } from "../../utils/id-generator"; import { generateFeedId } from "../../utils/id-generator";
import { bumpCounters } from "../../utils/stats";
import { waitUntilSafe } from "../../utils/worker"; import { waitUntilSafe } from "../../utils/worker";
import { feedRssUrl, feedEmailAddress } from "../../utils/urls"; import { feedRssUrl, feedEmailAddress } from "../../utils/urls";
import { logger } from "../../lib/logger"; import { logger } from "../../lib/logger";
@@ -191,6 +192,11 @@ feedsRouter.post("/create", async (c) => {
expiresAt, expiresAt,
); );
await bumpCounters(emailStorage, {
feeds_created: 1,
last_feed_created_at: new Date().toISOString(),
});
if (isJson) { if (isJson) {
return c.json({ return c.json({
feedId, feedId,
@@ -528,7 +534,10 @@ feedsRouter.post("/:feedId/delete", async (c) => {
try { try {
await deleteFeedFast(emailStorage, feedId); await deleteFeedFast(emailStorage, feedId);
await removeFeedFromList(emailStorage, feedId); const removed = await removeFeedFromList(emailStorage, feedId);
if (removed) {
await bumpCounters(emailStorage, { feeds_deleted: 1 });
}
waitUntilSafe( waitUntilSafe(
c, c,
@@ -658,6 +667,9 @@ feedsRouter.post("/bulk-delete", async (c) => {
} }
const deletedFeedIds = await removeFeedsFromListBulk(emailStorage, okIds); const deletedFeedIds = await removeFeedsFromListBulk(emailStorage, okIds);
if (deletedFeedIds.length > 0) {
await bumpCounters(emailStorage, { feeds_deleted: deletedFeedIds.length });
}
const removed = new Set(deletedFeedIds); const removed = new Set(deletedFeedIds);
okIds.forEach((feedId) => { okIds.forEach((feedId) => {
@@ -707,6 +719,9 @@ feedsRouter.post("/bulk-delete", async (c) => {
} }
const deletedFeedIds = await removeFeedsFromListBulk(emailStorage, okIds); const deletedFeedIds = await removeFeedsFromListBulk(emailStorage, okIds);
if (deletedFeedIds.length > 0) {
await bumpCounters(emailStorage, { feeds_deleted: deletedFeedIds.length });
}
return c.redirect( return c.redirect(
`${redirectBase}&message=bulkDeleted&count=${deletedFeedIds.length}`, `${redirectBase}&message=bulkDeleted&count=${deletedFeedIds.length}`,
+3 -2
View File
@@ -8,10 +8,11 @@ const designSystem = [variablesCss, layoutCss, componentsCss, utilitiesCss].join
type LayoutProps = { type LayoutProps = {
title: string; title: string;
label?: string;
children: import("hono/jsx").Child; children: import("hono/jsx").Child;
}; };
export const Layout = ({ title, children }: LayoutProps) => { export const Layout = ({ title, label = "admin", children }: LayoutProps) => {
return ( return (
<html> <html>
<head> <head>
@@ -38,7 +39,7 @@ export const Layout = ({ title, children }: LayoutProps) => {
<a href="https://kill-the.news/" class="site-header-logo" target="_blank" rel="noopener"> <a href="https://kill-the.news/" class="site-header-logo" target="_blank" rel="noopener">
kill-the-news kill-the-news
</a> </a>
<span class="site-header-label">admin</span> <span class="site-header-label">{label}</span>
</header> </header>
{children} {children}
<footer class="site-footer"> <footer class="site-footer">
+57
View File
@@ -0,0 +1,57 @@
import { Context } from "hono";
import { Env } from "../types";
import { getStats } from "../utils/stats";
import { Layout } from "./admin/ui";
function formatDateTime(iso?: string): string {
if (!iso) return "Never";
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return "Never";
return date.toISOString().replace("T", " ").replace(/\.\d+Z$/, " UTC");
}
export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
const stats = await getStats(c.env);
const cards: Array<{ label: string; value: string | number; time?: boolean }> =
[
{ label: "Active feeds", value: stats.active_feeds },
{ label: "Feeds created", value: stats.feeds_created },
{ label: "Feeds deleted", value: stats.feeds_deleted },
{ label: "Emails received", value: stats.emails_received },
{ label: "Emails rejected", value: stats.emails_rejected },
{ label: "WebSub subscribers", value: stats.websub_subscriptions_active },
{ label: "Last email", value: formatDateTime(stats.last_email_at), time: true },
{ label: "Last feed created", value: formatDateTime(stats.last_feed_created_at), time: true },
{ label: "Online since", value: formatDateTime(stats.first_seen), time: true },
];
return c.html(
<Layout title="Status" label="status">
<div class="container fade-in">
<div class="header-with-actions">
<div class="header-title">
<h1>kill-the-news</h1>
<p>Instance status &amp; monitoring</p>
</div>
<div class="header-actions">
<a href="/admin" class="button">
Go to admin
</a>
</div>
</div>
<div class="stats-grid">
{cards.map((card) => (
<div class="card stat-card">
<span class={`stat-value${card.time ? " stat-value-time" : ""}`}>
{card.value}
</span>
<span class="stat-label">{card.label}</span>
</div>
))}
</div>
</div>
</Layout>,
);
}
+66
View File
@@ -0,0 +1,66 @@
import { describe, it, expect } from "vitest";
import worker from "../index";
import { createMockEnv } from "../test/setup";
import { bumpCounters } from "../utils/stats";
import { FEEDS_LIST_KEY } from "../config/constants";
import type { Env, StatsResponse } from "../types";
function req(path: string, init: RequestInit = {}): Request {
return new Request(`https://test.getmynews.app${path}`, init);
}
describe("GET /api/stats", () => {
it("returns zeroed stats for a fresh instance", async () => {
const env = createMockEnv() as unknown as Env;
const res = await worker.fetch(req("/api/stats"), env);
expect(res.status).toBe(200);
const body = (await res.json()) as StatsResponse;
expect(body).toMatchObject({
feeds_created: 0,
feeds_deleted: 0,
emails_received: 0,
emails_rejected: 0,
active_feeds: 0,
websub_subscriptions_active: 0,
});
});
it("reflects persisted counters and live values", async () => {
const env = createMockEnv() as unknown as Env;
await env.EMAIL_STORAGE.put(
FEEDS_LIST_KEY,
JSON.stringify({ feeds: [{ id: "a", title: "A" }] }),
);
await env.EMAIL_STORAGE.put("websub:a:hash", "{}");
await bumpCounters(env.EMAIL_STORAGE, {
emails_received: 3,
emails_rejected: 1,
feeds_created: 1,
});
const res = await worker.fetch(req("/api/stats"), env);
const body = (await res.json()) as StatsResponse;
expect(body.active_feeds).toBe(1);
expect(body.websub_subscriptions_active).toBe(1);
expect(body.emails_received).toBe(3);
expect(body.emails_rejected).toBe(1);
expect(body.feeds_created).toBe(1);
});
});
describe("GET / (public status page)", () => {
it("returns an HTML status page with counters and an admin link", async () => {
const env = createMockEnv() as unknown as Env;
await bumpCounters(env.EMAIL_STORAGE, { emails_received: 7 });
const res = await worker.fetch(req("/"), env);
expect(res.status).toBe(200);
expect(res.headers.get("Content-Type")).toContain("text/html");
const html = await res.text();
expect(html).toContain('href="/admin"');
expect(html).toContain("Active feeds");
expect(html).toContain("Emails received");
expect(html).toContain("7");
});
});
+7
View File
@@ -0,0 +1,7 @@
import { Context } from "hono";
import { Env } from "../types";
import { getStats } from "../utils/stats";
export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
return c.json(await getStats(c.env));
}
+33
View File
@@ -1010,3 +1010,36 @@ table.table code {
opacity: 0.6; opacity: 0.6;
pointer-events: none; pointer-events: none;
} }
/* Status page — stat cards grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: var(--spacing-md);
margin-bottom: var(--spacing-lg);
}
.stat-card {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
margin-bottom: 0;
}
.stat-value {
font-size: 2rem;
font-weight: 700;
color: var(--color-primary);
line-height: 1.1;
}
.stat-value-time {
font-size: 1rem;
font-weight: 600;
color: var(--color-text-primary);
}
.stat-label {
font-size: 0.85rem;
color: var(--color-text-secondary);
}
+17
View File
@@ -69,6 +69,23 @@ export interface FeedListItem {
expires_at?: number; // Cached from FeedConfig to avoid per-feed KV reads expires_at?: number; // Cached from FeedConfig to avoid per-feed KV reads
} }
// Cumulative monitoring counters (persisted as a KV singleton)
export interface Counters {
feeds_created: number;
feeds_deleted: number;
emails_received: number;
emails_rejected: number;
last_email_at?: string; // ISO 8601
last_feed_created_at?: string; // ISO 8601
first_seen?: string; // ISO 8601 — first time counters were written (instance start)
}
// Monitoring API response: persisted counters + live-computed values
export interface StatsResponse extends Counters {
active_feeds: number;
websub_subscriptions_active: number;
}
// WebSub (PubSubHubbub) subscription configuration // WebSub (PubSubHubbub) subscription configuration
export interface WebSubSubscription { export interface WebSubSubscription {
callbackUrl: string; callbackUrl: string;
+122
View File
@@ -0,0 +1,122 @@
import { describe, it, expect } from "vitest";
import { createMockEnv } from "../test/setup";
import {
getCounters,
bumpCounters,
countKeysByPrefix,
getStats,
} from "./stats";
import { STATS_KEY, FEEDS_LIST_KEY } from "../config/constants";
import { Env } from "../types";
describe("stats helper", () => {
it("returns zeroed counters when nothing is stored", async () => {
const env = createMockEnv() as unknown as Env;
const counters = await getCounters(env.EMAIL_STORAGE);
expect(counters).toMatchObject({
feeds_created: 0,
feeds_deleted: 0,
emails_received: 0,
emails_rejected: 0,
});
expect(counters.first_seen).toBeUndefined();
});
it("accumulates numeric deltas across bumps", async () => {
const env = createMockEnv() as unknown as Env;
const kv = env.EMAIL_STORAGE;
await bumpCounters(kv, { emails_received: 1 });
await bumpCounters(kv, { emails_received: 2, emails_rejected: 1 });
await bumpCounters(kv, { feeds_created: 1, feeds_deleted: 3 });
const counters = await getCounters(kv);
expect(counters.emails_received).toBe(3);
expect(counters.emails_rejected).toBe(1);
expect(counters.feeds_created).toBe(1);
expect(counters.feeds_deleted).toBe(3);
});
it("overwrites date-time fields and sets first_seen once", async () => {
const env = createMockEnv() as unknown as Env;
const kv = env.EMAIL_STORAGE;
await bumpCounters(kv, {
emails_received: 1,
last_email_at: "2026-01-01T00:00:00.000Z",
});
const first = await getCounters(kv);
const firstSeen = first.first_seen;
expect(firstSeen).toBeDefined();
expect(first.last_email_at).toBe("2026-01-01T00:00:00.000Z");
await bumpCounters(kv, {
emails_received: 1,
last_email_at: "2026-02-02T00:00:00.000Z",
});
const second = await getCounters(kv);
expect(second.last_email_at).toBe("2026-02-02T00:00:00.000Z");
expect(second.first_seen).toBe(firstSeen);
});
it("counts keys by prefix", async () => {
const env = createMockEnv() as unknown as Env;
const kv = env.EMAIL_STORAGE;
await kv.put("websub:a:1", "{}");
await kv.put("websub:a:2", "{}");
await kv.put("feed:x:config", "{}");
expect(await countKeysByPrefix(kv, "websub:")).toBe(2);
expect(await countKeysByPrefix(kv, "missing:")).toBe(0);
});
it("getStats combines persisted counters with live values", async () => {
const env = createMockEnv() as unknown as Env;
const kv = env.EMAIL_STORAGE;
await kv.put(
FEEDS_LIST_KEY,
JSON.stringify({
feeds: [
{ id: "a", title: "A" },
{ id: "b", title: "B" },
],
}),
);
await kv.put("websub:a:1", "{}");
await bumpCounters(kv, { emails_received: 5, feeds_created: 2 });
const stats = await getStats(env);
expect(stats.active_feeds).toBe(2);
expect(stats.websub_subscriptions_active).toBe(1);
expect(stats.emails_received).toBe(5);
expect(stats.feeds_created).toBe(2);
});
it("never throws on a failing KV (counters are best-effort)", async () => {
const brokenKv = {
get: async () => {
throw new Error("kv down");
},
put: async () => {
throw new Error("kv down");
},
} as unknown as KVNamespace;
await expect(
bumpCounters(brokenKv, { emails_received: 1 }),
).resolves.toBeUndefined();
expect(await getCounters(brokenKv)).toMatchObject({ emails_received: 0 });
expect(await countKeysByPrefix(brokenKv, "websub:")).toBe(0);
});
it("persists under the stats KV key", async () => {
const env = createMockEnv() as unknown as Env;
const kv = env.EMAIL_STORAGE;
await bumpCounters(kv, { feeds_created: 1 });
const raw = (await kv.get(STATS_KEY, { type: "json" })) as {
feeds_created: number;
};
expect(raw.feeds_created).toBe(1);
});
});
+84
View File
@@ -0,0 +1,84 @@
import { Counters, Env, StatsResponse } from "../types";
import { STATS_KEY } from "../config/constants";
import { logger } from "../lib/logger";
import { listAllFeeds } from "../routes/admin/helpers";
const EMPTY_COUNTERS: Counters = {
feeds_created: 0,
feeds_deleted: 0,
emails_received: 0,
emails_rejected: 0,
};
export async function getCounters(kv: KVNamespace): Promise<Counters> {
try {
const stored = (await kv.get(STATS_KEY, {
type: "json",
})) as Counters | null;
return { ...EMPTY_COUNTERS, ...(stored || {}) };
} catch (error) {
logger.error("Error reading counters", { error: String(error) });
return { ...EMPTY_COUNTERS };
}
}
/**
* Read-modify-write the counters singleton. KV has no atomic increment, so
* concurrent invocations can lose updates — accepted given KV's eventual
* consistency and this app's low volume (see email-processor.ts storeEmail).
* Never throws: counter failures must not break ingestion or admin flows.
*/
export async function bumpCounters(
kv: KVNamespace,
changes: Partial<Omit<Counters, "first_seen">>,
): Promise<void> {
try {
const current = await getCounters(kv);
current.feeds_created += changes.feeds_created ?? 0;
current.feeds_deleted += changes.feeds_deleted ?? 0;
current.emails_received += changes.emails_received ?? 0;
current.emails_rejected += changes.emails_rejected ?? 0;
if (changes.last_email_at) current.last_email_at = changes.last_email_at;
if (changes.last_feed_created_at)
current.last_feed_created_at = changes.last_feed_created_at;
if (!current.first_seen) current.first_seen = new Date().toISOString();
await kv.put(STATS_KEY, JSON.stringify(current));
} catch (error) {
logger.error("Error updating counters", { error: String(error) });
}
}
export async function countKeysByPrefix(
kv: KVNamespace,
prefix: string,
): Promise<number> {
let total = 0;
let cursor: string | undefined;
try {
do {
const listed = await kv.list({ prefix, cursor, limit: 1000 });
total += listed.keys.length;
cursor = listed.list_complete ? undefined : listed.cursor;
} while (cursor);
} catch (error) {
logger.error("Error counting keys", { prefix, error: String(error) });
}
return total;
}
export async function getStats(env: Env): Promise<StatsResponse> {
const kv = env.EMAIL_STORAGE;
const [counters, feeds, websubCount] = await Promise.all([
getCounters(kv),
listAllFeeds(kv),
countKeysByPrefix(kv, "websub:"),
]);
return {
...counters,
active_feeds: feeds.length,
websub_subscriptions_active: websubCount,
};
}