From 4585742fbd7e8ea7613ab85bbbaf3ca590aab68d Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:42:27 -0400 Subject: [PATCH 1/6] Document diagnostic data collection in the privacy policy Co-Authored-By: Claude Fable 5 --- Privacy.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Privacy.md b/Privacy.md index 64a8d49..ea9555b 100644 --- a/Privacy.md +++ b/Privacy.md @@ -13,6 +13,12 @@ - Status (online, offline) of users who are using `/mode set auto` +## Diagnostic Data + +- Error reports (the command or interaction that failed, the type of error and where in the code it occurred, and a timestamp) are collected to fix bugs +- Anonymous aggregate usage counts of commands and embed interactions are collected +- Diagnostic data never includes User IDs, server or channel names, command arguments, or error message text + ## Deleting Data - Stored Data will never be deleted automatically From b4fde73d598319ac31a53ee55f4203501037563c Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:42:27 -0400 Subject: [PATCH 2/6] Add usage_counts and error_reports diagnostics tables Co-Authored-By: Claude Fable 5 --- .../migrations/20260720000000_diagnostics.sql | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 supabase/migrations/20260720000000_diagnostics.sql diff --git a/supabase/migrations/20260720000000_diagnostics.sql b/supabase/migrations/20260720000000_diagnostics.sql new file mode 100644 index 0000000..78494ec --- /dev/null +++ b/supabase/migrations/20260720000000_diagnostics.sql @@ -0,0 +1,37 @@ +-- Anonymous diagnostics. `interaction` is a feature key, never user data: +-- a command name ("COMMAND /ring") or a registered route pattern +-- ("POST /ring/user") — route params stay as ":name" placeholders. + +-- aggregate usage: one counter per feature per day +create table usage_counts ( + interaction text not null, + day date not null default current_date, + count bigint not null default 0, + primary key (interaction, day) +); + +-- `error` is the error's type, code, and stack frames — never the free-text +-- message, which can embed IDs and server/channel names +create table error_reports ( + id bigint generated always as identity primary key, + interaction text not null, + error text not null, + created_at timestamptz not null default now() +); + +-- atomic increment; supabase-js upserts can't express count = count + 1 +create function record_usage(p_interaction text) returns void +language sql as $$ + insert into usage_counts (interaction, count) + values (p_interaction, 1) + on conflict (interaction, day) + do update set count = usage_counts.count + 1; +$$; + +grant select, insert, update, delete + on usage_counts, error_reports + to service_role; +grant execute on function record_usage to service_role; + +alter table usage_counts enable row level security; +alter table error_reports enable row level security; From 7fce59cd92e0f350dffb4813b0546dd4880f9530 Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:57:11 -0400 Subject: [PATCH 3/6] Collect anonymous usage counts and error reports Route handlers are wrapped at registration to record usage and errors. Usage counts once per Discord interaction, first key wins, so command dispatches and redirect chains aren't double-counted. Error reports store only the error's type, code, and stack frames; the free-text message can embed IDs and server/channel names, which the privacy policy forbids. Co-Authored-By: Claude Fable 5 --- src/index.ts | 14 +++ src/main/db/database.types.ts | 46 +++++++++- src/main/db/diagnostics.test.ts | 37 ++++++++ src/main/db/diagnostics.ts | 36 ++++++++ src/main/diagnostics.test.ts | 154 ++++++++++++++++++++++++++++++++ src/main/diagnostics.ts | 82 +++++++++++++++++ 6 files changed, 368 insertions(+), 1 deletion(-) create mode 100644 src/main/db/diagnostics.test.ts create mode 100644 src/main/db/diagnostics.ts create mode 100644 src/main/diagnostics.test.ts create mode 100644 src/main/diagnostics.ts diff --git a/src/index.ts b/src/index.ts index 352dd49..b8b6bbf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,11 @@ import { commands as commandsArray, } from "@commands/commands"; import { DISCORD_TOKEN } from "@config"; +import { + instrumentRouter, + recordErrorOnce, + recordInteractionUsage, +} from "@main/diagnostics"; import { onVoiceChannelJoin } from "@main/ring"; import { registerRoutes } from "@routes/index"; import { Globals } from "@routes/types"; @@ -31,6 +36,9 @@ const client = new Client({ // commands dispatch into it explicitly below const router = new EmbedRouter(client, { name: "ringvc" }); router.onError((err, interaction) => { + // handler errors are already reported under their route key; this catches + // router-internal ones + recordErrorOnce("ROUTER", err); console.error(err); if ( interaction && @@ -49,6 +57,7 @@ router.onError((err, interaction) => { const commandIds = new Map(); router.setGlobals({ commandIds }); +instrumentRouter(router); registerRoutes(router); // load commands @@ -96,9 +105,14 @@ client.on("interactionCreate", async (interaction) => { try { if (interaction instanceof ChatInputCommandInteraction) { + recordInteractionUsage( + `COMMAND /${interaction.commandName}`, + interaction, + ); await command.execute(router, interaction); } } catch (error) { + recordErrorOnce(`COMMAND /${interaction.commandName}`, error); console.error(error); await interaction .reply({ diff --git a/src/main/db/database.types.ts b/src/main/db/database.types.ts index 97241cd..f119fa9 100644 --- a/src/main/db/database.types.ts +++ b/src/main/db/database.types.ts @@ -86,6 +86,27 @@ export type Database = { }, ]; }; + error_reports: { + Row: { + created_at: string; + error: string; + id: number; + interaction: string; + }; + Insert: { + created_at?: string; + error: string; + id?: never; + interaction: string; + }; + Update: { + created_at?: string; + error?: string; + id?: never; + interaction?: string; + }; + Relationships: []; + }; filter_entries: { Row: { channel_id: string | null; @@ -138,6 +159,24 @@ export type Database = { }, ]; }; + usage_counts: { + Row: { + count: number; + day: string; + interaction: string; + }; + Insert: { + count?: number; + day?: string; + interaction: string; + }; + Update: { + count?: number; + day?: string; + interaction?: string; + }; + Relationships: []; + }; users: { Row: { mode: Database["public"]["Enums"]["discord_user_mode"]; @@ -188,7 +227,12 @@ export type Database = { [_ in never]: never; }; Functions: { - [_ in never]: never; + record_usage: { + Args: { + p_interaction: string; + }; + Returns: undefined; + }; }; Enums: { discord_user_mode: "normal" | "stealth" | "auto"; diff --git a/src/main/db/diagnostics.test.ts b/src/main/db/diagnostics.test.ts new file mode 100644 index 0000000..1688a5b --- /dev/null +++ b/src/main/db/diagnostics.test.ts @@ -0,0 +1,37 @@ +import { expect, test, vi } from "vitest"; + +import { db } from "./client"; +import { recordError } from "./diagnostics"; + +vi.mock("./client", () => ({ + db: { from: vi.fn(), rpc: vi.fn() }, +})); + +test("recordError stores the error type and stack frames, never the message", () => { + const insert = vi.fn().mockResolvedValue({ error: null }); + vi.mocked(db.from).mockReturnValue({ insert } as never); + + const boom = new Error("<@123456789012345678> can't join #secret-lounge"); + recordError("POST /ring/user", boom); + + expect(db.from).toHaveBeenCalledWith("error_reports"); + const stored = insert.mock.calls[0]?.[0] as { error: string }; + expect(stored.error).toMatch(/^Error\n/); + expect(stored.error).toContain("at "); + expect(stored.error).not.toContain("secret-lounge"); + expect(stored.error).not.toContain("123456789012345678"); +}); + +test("recordError includes an error code when one is present", () => { + const insert = vi.fn().mockResolvedValue({ error: null }); + vi.mocked(db.from).mockReturnValue({ insert } as never); + + const apiError = Object.assign(new Error("Missing Access in My Server"), { + code: 50001, + }); + recordError("GET /ring", apiError); + + const stored = insert.mock.calls[0]?.[0] as { error: string }; + expect(stored.error).toMatch(/^Error \(50001\)/); + expect(stored.error).not.toContain("My Server"); +}); diff --git a/src/main/db/diagnostics.ts b/src/main/db/diagnostics.ts new file mode 100644 index 0000000..61ba758 --- /dev/null +++ b/src/main/db/diagnostics.ts @@ -0,0 +1,36 @@ +import { db } from "./client"; + +// diagnostics must never break or slow the bot: both writes are +// fire-and-forget and swallow their own failures + +export const recordUsage = (interaction: string): void => { + db.rpc("record_usage", { p_interaction: interaction }).then(({ error }) => { + if (error) console.error("recordUsage failed:", error.message); + }, console.error); +}; + +// keeps only what can't contain user data — the error's name, its code, and +// its stack frames (code locations). The free-text message is deliberately +// dropped: it can embed IDs, server names, and channel names, and the privacy +// policy forbids storing those +const describeError = (error: unknown): string => { + if (!(error instanceof Error)) return typeof error; + const code = (error as { code?: unknown }).code; + const label = + typeof code === "number" || typeof code === "string" + ? `${error.name} (${code})` + : error.name; + const frames = (error.stack ?? "") + .split("\n") + .filter((line) => line.trimStart().startsWith("at ")) + .join("\n"); + return frames ? `${label}\n${frames}` : label; +}; + +export const recordError = (interaction: string, error: unknown): void => { + db.from("error_reports") + .insert({ interaction, error: describeError(error) }) + .then(({ error: dbError }) => { + if (dbError) console.error("recordError failed:", dbError.message); + }, console.error); +}; diff --git a/src/main/diagnostics.test.ts b/src/main/diagnostics.test.ts new file mode 100644 index 0000000..1a285db --- /dev/null +++ b/src/main/diagnostics.test.ts @@ -0,0 +1,154 @@ +import { Interaction } from "discord.js"; +import { beforeEach, expect, test, vi } from "vitest"; + +import { recordError, recordUsage } from "@db/diagnostics"; +import { RingRouter } from "@routes/types"; + +import { instrumentRouter, recordInteractionUsage } from "./diagnostics"; + +vi.mock("@db/diagnostics", () => ({ + recordError: vi.fn(), + recordUsage: vi.fn(), +})); + +type AnyHandler = (...args: unknown[]) => unknown; +type Registered = Map; + +// fake router capturing whatever handler ends up registered per method +const fakeRouter = () => { + const registered: Registered = new Map(); + const register = + (method: string) => (_path: unknown, handler: AnyHandler) => { + registered.set(method, handler); + }; + const router = { + get: register("get"), + post: register("post"), + put: register("put"), + patch: register("patch"), + delete: register("delete"), + modal: register("modal"), + route: (_path: unknown, handlers: Record) => { + for (const [method, handler] of Object.entries(handlers)) { + registered.set(`route:${method}`, handler); + } + }, + }; + return { router: router as unknown as RingRouter, registered }; +}; + +const anInteraction = () => ({ id: "interaction" }) as unknown as Interaction; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +test("a wrapped handler records usage keyed by method and path", async () => { + const { router, registered } = fakeRouter(); + instrumentRouter(router); + + router.post("/ring/user", vi.fn().mockResolvedValue({ redirect: "/ring" })); + const result = await registered.get("post")?.("router", anInteraction(), {}); + + expect(recordUsage).toHaveBeenCalledExactlyOnceWith("POST /ring/user"); + expect(result).toEqual({ redirect: "/ring" }); +}); + +test("a multi-path registration is keyed by its first path", async () => { + const { router, registered } = fakeRouter(); + instrumentRouter(router); + + router.get(["/filter", "/filter/:scope"], vi.fn().mockResolvedValue({})); + await registered.get("get")?.("router", anInteraction(), {}); + + expect(recordUsage).toHaveBeenCalledExactlyOnceWith("GET /filter"); +}); + +test("route() wraps each handler under its own method", async () => { + const { router, registered } = fakeRouter(); + instrumentRouter(router); + + router.route("/mode", { + get: vi.fn().mockResolvedValue({}), + post: vi.fn().mockResolvedValue({ redirect: "/mode" }), + }); + await registered.get("route:get")?.("router", anInteraction(), {}); + await registered.get("route:post")?.("router", anInteraction(), {}); + + expect(vi.mocked(recordUsage).mock.calls).toEqual([ + ["GET /mode"], + ["POST /mode"], + ]); +}); + +test("one interaction passing through several handlers counts once", async () => { + const { router, registered } = fakeRouter(); + instrumentRouter(router); + + router.post("/mode", vi.fn().mockResolvedValue({ redirect: "/mode" })); + router.get("/mode", vi.fn().mockResolvedValue({})); + + const interaction = anInteraction(); + await registered.get("post")?.("router", interaction, {}); + await registered.get("get")?.("router", interaction, {}); + + expect(recordUsage).toHaveBeenCalledExactlyOnceWith("POST /mode"); +}); + +test("a command-counted interaction is not recounted by route handlers", async () => { + const { router, registered } = fakeRouter(); + instrumentRouter(router); + + router.get("/ring", vi.fn().mockResolvedValue({})); + + const interaction = anInteraction(); + recordInteractionUsage("COMMAND /ring", interaction); + await registered.get("get")?.("router", interaction, {}); + + expect(recordUsage).toHaveBeenCalledExactlyOnceWith("COMMAND /ring"); +}); + +test("a rejecting handler records the error and still rejects", async () => { + const { router, registered } = fakeRouter(); + instrumentRouter(router); + + const boom = new Error("boom"); + router.post("/ring/user", vi.fn().mockRejectedValue(boom)); + + await expect( + registered.get("post")?.("router", anInteraction(), {}), + ).rejects.toThrow("boom"); + expect(recordError).toHaveBeenCalledExactlyOnceWith("POST /ring/user", boom); +}); + +test("a synchronously throwing handler records the error and still throws", () => { + const { router, registered } = fakeRouter(); + instrumentRouter(router); + + const boom = new Error("boom"); + router.get("/help", () => { + throw boom; + }); + + expect(() => registered.get("get")?.("router", anInteraction(), {})).toThrow( + "boom", + ); + expect(recordError).toHaveBeenCalledExactlyOnceWith("GET /help", boom); +}); + +test("an error propagating through nested handlers is recorded once", async () => { + const { router, registered } = fakeRouter(); + instrumentRouter(router); + + const boom = new Error("boom"); + router.get("/ring", vi.fn().mockRejectedValue(boom)); + + const interaction = anInteraction(); + const inner = registered.get("get")?.("router", interaction, {}); + router.post("/ring/user", () => inner as never); + await expect( + registered.get("post")?.("router", interaction, {}), + ).rejects.toThrow("boom"); + + expect(recordError).toHaveBeenCalledExactlyOnceWith("GET /ring", boom); +}); diff --git a/src/main/diagnostics.ts b/src/main/diagnostics.ts new file mode 100644 index 0000000..da11345 --- /dev/null +++ b/src/main/diagnostics.ts @@ -0,0 +1,82 @@ +import { Interaction } from "discord.js"; + +import { recordError, recordUsage } from "@db/diagnostics"; +import { RingRouter } from "@routes/types"; + +// commands dispatch into routes and POSTs redirect into GETs, so one Discord +// interaction reaches several handlers; only its first key is counted +const countedInteractions = new WeakSet(); + +export const recordInteractionUsage = ( + key: string, + interaction: Interaction, +): void => { + if (countedInteractions.has(interaction)) return; + countedInteractions.add(interaction); + recordUsage(key); +}; + +// an error rethrown through nested wrapped handlers is reported only where +// it was first caught (the innermost, most specific key) +const reportedErrors = new WeakSet(); + +export const recordErrorOnce = (key: string, error: unknown): void => { + if (typeof error === "object" && error !== null) { + if (reportedErrors.has(error)) return; + reportedErrors.add(error); + } + recordError(key, error); +}; + +type AnyHandler = (...args: unknown[]) => unknown; + +const keyOf = (method: string, path: unknown): string => + `${method.toUpperCase()} ${String(Array.isArray(path) ? path[0] : path)}`; + +const wrap = (key: string, handler: AnyHandler): AnyHandler => { + return (...args) => { + recordInteractionUsage(key, args[1] as Interaction); + try { + const result = handler(...args); + if (result instanceof Promise) { + return result.catch((error: unknown) => { + recordErrorOnce(key, error); + throw error; + }); + } + return result; + } catch (error) { + recordErrorOnce(key, error); + throw error; + } + }; +}; + +const methods = ["get", "post", "put", "patch", "delete", "modal"] as const; + +// wraps every subsequently registered route handler to record usage and +// errors; call before registerRoutes +export const instrumentRouter = (router: RingRouter): void => { + type Registrar = (path: unknown, handler: AnyHandler) => void; + for (const method of methods) { + const original = router[method].bind(router) as Registrar; + (router[method] as Registrar) = (path, handler) => + original(path, wrap(keyOf(method, path), handler)); + } + + type RouteRegistrar = ( + path: unknown, + handlers: Record, + ) => void; + const originalRoute = router.route.bind(router) as RouteRegistrar; + (router.route as RouteRegistrar) = (path, handlers) => + originalRoute( + path, + Object.fromEntries( + Object.entries(handlers).map(([method, handler]) => [ + method, + wrap(keyOf(method, path), handler), + ]), + ), + ); +}; From 88e4ee1885aa107b6b26f4420ea87f3cd62dbdde Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Mon, 20 Jul 2026 22:44:56 -0400 Subject: [PATCH 4/6] Use discord-embed-router 1.4 route events for diagnostics The route event's trigger field says why a handler ran, so usage counts at entry points only (commands in interactionCreate, embed clicks via trigger "interaction") and the per-interaction dedupe state goes away. Errors come from routeError with RouteInfo for the key, and describeError unwraps the router's wrapper to the root cause. Co-Authored-By: Claude Fable 5 --- package-lock.json | 8 +- package.json | 2 +- src/index.ts | 19 ++--- src/main/db/diagnostics.test.ts | 14 +++ src/main/db/diagnostics.ts | 10 ++- src/main/diagnostics.test.ts | 146 ++++++-------------------------- src/main/diagnostics.ts | 97 +++++---------------- 7 files changed, 78 insertions(+), 218 deletions(-) diff --git a/package-lock.json b/package-lock.json index a7a35c6..fdb91c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "": { "dependencies": { "@supabase/supabase-js": "^2.110.2", - "discord-embed-router": "^1.3.0", + "discord-embed-router": "^1.4.0", "discord.js": "^14.22.1", "dotenv": "^17.2.2", "is-online": "^11.0.0", @@ -2946,9 +2946,9 @@ ] }, "node_modules/discord-embed-router": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/discord-embed-router/-/discord-embed-router-1.3.0.tgz", - "integrity": "sha512-3IDplfqGYjpqW5uZf+mumu07RZ5DpPWm/EevbUJNAvKb61AC0FYSDOD4VNqdTo25xoOWwiOCfQr0xtd4er8GMA==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/discord-embed-router/-/discord-embed-router-1.4.0.tgz", + "integrity": "sha512-bxNo06SL0W5pPwVmEUWaYuV6OEl9/PI6o39MWIhfCVYtwGa9mdJXpFz0z3OcVHogze/TrQ7yeI/KcL3/xgOrUA==", "license": "MIT", "workspaces": [ "examples/basic-bot" diff --git a/package.json b/package.json index 16c6c3f..134f0d0 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ }, "dependencies": { "@supabase/supabase-js": "^2.110.2", - "discord-embed-router": "^1.3.0", + "discord-embed-router": "^1.4.0", "discord.js": "^14.22.1", "dotenv": "^17.2.2", "is-online": "^11.0.0", diff --git a/src/index.ts b/src/index.ts index b8b6bbf..29e29c8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,11 +14,8 @@ import { commands as commandsArray, } from "@commands/commands"; import { DISCORD_TOKEN } from "@config"; -import { - instrumentRouter, - recordErrorOnce, - recordInteractionUsage, -} from "@main/diagnostics"; +import { recordError, recordUsage } from "@db/diagnostics"; +import { observeRouter } from "@main/diagnostics"; import { onVoiceChannelJoin } from "@main/ring"; import { registerRoutes } from "@routes/index"; import { Globals } from "@routes/types"; @@ -36,9 +33,6 @@ const client = new Client({ // commands dispatch into it explicitly below const router = new EmbedRouter(client, { name: "ringvc" }); router.onError((err, interaction) => { - // handler errors are already reported under their route key; this catches - // router-internal ones - recordErrorOnce("ROUTER", err); console.error(err); if ( interaction && @@ -57,7 +51,7 @@ router.onError((err, interaction) => { const commandIds = new Map(); router.setGlobals({ commandIds }); -instrumentRouter(router); +observeRouter(router); registerRoutes(router); // load commands @@ -105,14 +99,11 @@ client.on("interactionCreate", async (interaction) => { try { if (interaction instanceof ChatInputCommandInteraction) { - recordInteractionUsage( - `COMMAND /${interaction.commandName}`, - interaction, - ); + recordUsage(`COMMAND /${interaction.commandName}`); await command.execute(router, interaction); } } catch (error) { - recordErrorOnce(`COMMAND /${interaction.commandName}`, error); + recordError(`COMMAND /${interaction.commandName}`, error); console.error(error); await interaction .reply({ diff --git a/src/main/db/diagnostics.test.ts b/src/main/db/diagnostics.test.ts index 1688a5b..ac22803 100644 --- a/src/main/db/diagnostics.test.ts +++ b/src/main/db/diagnostics.test.ts @@ -22,6 +22,20 @@ test("recordError stores the error type and stack frames, never the message", () expect(stored.error).not.toContain("123456789012345678"); }); +test("recordError describes the root cause of a wrapped error", () => { + const insert = vi.fn().mockResolvedValue({ error: null }); + vi.mocked(db.from).mockReturnValue({ insert } as never); + + const cause = Object.assign(new TypeError("deep failure"), { code: 10003 }); + recordError( + "GET /ring", + new Error("Error while handling GET /ring", { cause }), + ); + + const stored = insert.mock.calls[0]?.[0] as { error: string }; + expect(stored.error).toMatch(/^TypeError \(10003\)/); +}); + test("recordError includes an error code when one is present", () => { const insert = vi.fn().mockResolvedValue({ error: null }); vi.mocked(db.from).mockReturnValue({ insert } as never); diff --git a/src/main/db/diagnostics.ts b/src/main/db/diagnostics.ts index 61ba758..de6f8dd 100644 --- a/src/main/db/diagnostics.ts +++ b/src/main/db/diagnostics.ts @@ -13,8 +13,14 @@ export const recordUsage = (interaction: string): void => { // its stack frames (code locations). The free-text message is deliberately // dropped: it can embed IDs, server names, and channel names, and the privacy // policy forbids storing those -const describeError = (error: unknown): string => { - if (!(error instanceof Error)) return typeof error; +const describeError = (raw: unknown): string => { + if (!(raw instanceof Error)) return typeof raw; + // the router wraps handler errors ("Error while handling POST /x"); the + // root cause has the specific type, code, and frames. Bounded in case of + // a cause cycle + let error: Error = raw; + for (let depth = 0; error.cause instanceof Error && depth < 5; depth++) + error = error.cause; const code = (error as { code?: unknown }).code; const label = typeof code === "number" || typeof code === "string" diff --git a/src/main/diagnostics.test.ts b/src/main/diagnostics.test.ts index 1a285db..de8e70f 100644 --- a/src/main/diagnostics.test.ts +++ b/src/main/diagnostics.test.ts @@ -1,154 +1,58 @@ +import { EmbedRouter, RouteInfo } from "discord-embed-router"; import { Interaction } from "discord.js"; import { beforeEach, expect, test, vi } from "vitest"; import { recordError, recordUsage } from "@db/diagnostics"; -import { RingRouter } from "@routes/types"; +import { Globals } from "@routes/types"; -import { instrumentRouter, recordInteractionUsage } from "./diagnostics"; +import { observeRouter } from "./diagnostics"; vi.mock("@db/diagnostics", () => ({ recordError: vi.fn(), recordUsage: vi.fn(), })); -type AnyHandler = (...args: unknown[]) => unknown; -type Registered = Map; +const interaction = {} as Interaction; +const info = (trigger: RouteInfo["trigger"]): RouteInfo => ({ + method: "POST", + path: "/ring/user", + trigger, +}); -// fake router capturing whatever handler ends up registered per method -const fakeRouter = () => { - const registered: Registered = new Map(); - const register = - (method: string) => (_path: unknown, handler: AnyHandler) => { - registered.set(method, handler); - }; - const router = { - get: register("get"), - post: register("post"), - put: register("put"), - patch: register("patch"), - delete: register("delete"), - modal: register("modal"), - route: (_path: unknown, handlers: Record) => { - for (const [method, handler] of Object.entries(handlers)) { - registered.set(`route:${method}`, handler); - } - }, - }; - return { router: router as unknown as RingRouter, registered }; +const observedRouter = () => { + const router = new EmbedRouter(); + observeRouter(router); + return router; }; -const anInteraction = () => ({ id: "interaction" }) as unknown as Interaction; - beforeEach(() => { vi.clearAllMocks(); }); -test("a wrapped handler records usage keyed by method and path", async () => { - const { router, registered } = fakeRouter(); - instrumentRouter(router); - - router.post("/ring/user", vi.fn().mockResolvedValue({ redirect: "/ring" })); - const result = await registered.get("post")?.("router", anInteraction(), {}); +test("an interaction-triggered route counts under its method and pattern", () => { + observedRouter().emit("route", interaction, info("interaction")); expect(recordUsage).toHaveBeenCalledExactlyOnceWith("POST /ring/user"); - expect(result).toEqual({ redirect: "/ring" }); -}); - -test("a multi-path registration is keyed by its first path", async () => { - const { router, registered } = fakeRouter(); - instrumentRouter(router); - - router.get(["/filter", "/filter/:scope"], vi.fn().mockResolvedValue({})); - await registered.get("get")?.("router", anInteraction(), {}); - - expect(recordUsage).toHaveBeenCalledExactlyOnceWith("GET /filter"); -}); - -test("route() wraps each handler under its own method", async () => { - const { router, registered } = fakeRouter(); - instrumentRouter(router); - - router.route("/mode", { - get: vi.fn().mockResolvedValue({}), - post: vi.fn().mockResolvedValue({ redirect: "/mode" }), - }); - await registered.get("route:get")?.("router", anInteraction(), {}); - await registered.get("route:post")?.("router", anInteraction(), {}); - - expect(vi.mocked(recordUsage).mock.calls).toEqual([ - ["GET /mode"], - ["POST /mode"], - ]); -}); - -test("one interaction passing through several handlers counts once", async () => { - const { router, registered } = fakeRouter(); - instrumentRouter(router); - - router.post("/mode", vi.fn().mockResolvedValue({ redirect: "/mode" })); - router.get("/mode", vi.fn().mockResolvedValue({})); - - const interaction = anInteraction(); - await registered.get("post")?.("router", interaction, {}); - await registered.get("get")?.("router", interaction, {}); - - expect(recordUsage).toHaveBeenCalledExactlyOnceWith("POST /mode"); }); -test("a command-counted interaction is not recounted by route handlers", async () => { - const { router, registered } = fakeRouter(); - instrumentRouter(router); - - router.get("/ring", vi.fn().mockResolvedValue({})); +test("dispatch and redirect hops are not counted", () => { + const router = observedRouter(); + router.emit("route", interaction, info("dispatch")); + router.emit("route", interaction, info("redirect")); - const interaction = anInteraction(); - recordInteractionUsage("COMMAND /ring", interaction); - await registered.get("get")?.("router", interaction, {}); - - expect(recordUsage).toHaveBeenCalledExactlyOnceWith("COMMAND /ring"); + expect(recordUsage).not.toHaveBeenCalled(); }); -test("a rejecting handler records the error and still rejects", async () => { - const { router, registered } = fakeRouter(); - instrumentRouter(router); - +test("a route error is recorded under the failing route's key", () => { const boom = new Error("boom"); - router.post("/ring/user", vi.fn().mockRejectedValue(boom)); + observedRouter().emit("routeError", boom, interaction, info("interaction")); - await expect( - registered.get("post")?.("router", anInteraction(), {}), - ).rejects.toThrow("boom"); expect(recordError).toHaveBeenCalledExactlyOnceWith("POST /ring/user", boom); }); -test("a synchronously throwing handler records the error and still throws", () => { - const { router, registered } = fakeRouter(); - instrumentRouter(router); - +test("a router-internal error with no route info is recorded as ROUTER", () => { const boom = new Error("boom"); - router.get("/help", () => { - throw boom; - }); - - expect(() => registered.get("get")?.("router", anInteraction(), {})).toThrow( - "boom", - ); - expect(recordError).toHaveBeenCalledExactlyOnceWith("GET /help", boom); -}); - -test("an error propagating through nested handlers is recorded once", async () => { - const { router, registered } = fakeRouter(); - instrumentRouter(router); - - const boom = new Error("boom"); - router.get("/ring", vi.fn().mockRejectedValue(boom)); - - const interaction = anInteraction(); - const inner = registered.get("get")?.("router", interaction, {}); - router.post("/ring/user", () => inner as never); - await expect( - registered.get("post")?.("router", interaction, {}), - ).rejects.toThrow("boom"); + observedRouter().emit("routeError", boom, interaction, undefined); - expect(recordError).toHaveBeenCalledExactlyOnceWith("GET /ring", boom); + expect(recordError).toHaveBeenCalledExactlyOnceWith("ROUTER", boom); }); diff --git a/src/main/diagnostics.ts b/src/main/diagnostics.ts index da11345..7658777 100644 --- a/src/main/diagnostics.ts +++ b/src/main/diagnostics.ts @@ -1,82 +1,27 @@ +import { EventEmitter } from "node:events"; + +import { RouteInfo } from "discord-embed-router"; import { Interaction } from "discord.js"; import { recordError, recordUsage } from "@db/diagnostics"; import { RingRouter } from "@routes/types"; -// commands dispatch into routes and POSTs redirect into GETs, so one Discord -// interaction reaches several handlers; only its first key is counted -const countedInteractions = new WeakSet(); - -export const recordInteractionUsage = ( - key: string, - interaction: Interaction, -): void => { - if (countedInteractions.has(interaction)) return; - countedInteractions.add(interaction); - recordUsage(key); -}; - -// an error rethrown through nested wrapped handlers is reported only where -// it was first caught (the innermost, most specific key) -const reportedErrors = new WeakSet(); - -export const recordErrorOnce = (key: string, error: unknown): void => { - if (typeof error === "object" && error !== null) { - if (reportedErrors.has(error)) return; - reportedErrors.add(error); - } - recordError(key, error); -}; - -type AnyHandler = (...args: unknown[]) => unknown; - -const keyOf = (method: string, path: unknown): string => - `${method.toUpperCase()} ${String(Array.isArray(path) ? path[0] : path)}`; - -const wrap = (key: string, handler: AnyHandler): AnyHandler => { - return (...args) => { - recordInteractionUsage(key, args[1] as Interaction); - try { - const result = handler(...args); - if (result instanceof Promise) { - return result.catch((error: unknown) => { - recordErrorOnce(key, error); - throw error; - }); - } - return result; - } catch (error) { - recordErrorOnce(key, error); - throw error; - } - }; -}; - -const methods = ["get", "post", "put", "patch", "delete", "modal"] as const; - -// wraps every subsequently registered route handler to record usage and -// errors; call before registerRoutes -export const instrumentRouter = (router: RingRouter): void => { - type Registrar = (path: unknown, handler: AnyHandler) => void; - for (const method of methods) { - const original = router[method].bind(router) as Registrar; - (router[method] as Registrar) = (path, handler) => - original(path, wrap(keyOf(method, path), handler)); - } - - type RouteRegistrar = ( - path: unknown, - handlers: Record, - ) => void; - const originalRoute = router.route.bind(router) as RouteRegistrar; - (router.route as RouteRegistrar) = (path, handlers) => - originalRoute( - path, - Object.fromEntries( - Object.entries(handlers).map(([method, handler]) => [ - method, - wrap(keyOf(method, path), handler), - ]), - ), - ); +export const routeKey = (info: RouteInfo): string => + `${info.method} ${info.path}`; + +// counts usage and records errors for embed interactions. Only +// "interaction"-triggered hops count: command dispatches are counted at the +// command entry point, and redirect hops are renders, not user actions +export const observeRouter = (router: RingRouter): void => { + // the cast is a workaround: under esModuleInterop:false the router's + // published typings lose their EventEmitter base, hiding .on() + (router as unknown as EventEmitter).on( + "route", + (_interaction: Interaction, info: RouteInfo) => { + if (info.trigger === "interaction") recordUsage(routeKey(info)); + }, + ); + router.onError((err, _interaction, info) => { + recordError(info ? routeKey(info) : "ROUTER", err); + }); }; From f8f384d31f3cdf61da010d683eab028355ff589c Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Mon, 20 Jul 2026 23:09:45 -0400 Subject: [PATCH 5/6] Drop the EventEmitter cast fixed by discord-embed-router 1.4.1 Co-Authored-By: Claude Fable 5 --- package-lock.json | 8 ++++---- package.json | 2 +- src/main/diagnostics.ts | 14 +++----------- 3 files changed, 8 insertions(+), 16 deletions(-) diff --git a/package-lock.json b/package-lock.json index fdb91c2..564c012 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "": { "dependencies": { "@supabase/supabase-js": "^2.110.2", - "discord-embed-router": "^1.4.0", + "discord-embed-router": "^1.4.1", "discord.js": "^14.22.1", "dotenv": "^17.2.2", "is-online": "^11.0.0", @@ -2946,9 +2946,9 @@ ] }, "node_modules/discord-embed-router": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/discord-embed-router/-/discord-embed-router-1.4.0.tgz", - "integrity": "sha512-bxNo06SL0W5pPwVmEUWaYuV6OEl9/PI6o39MWIhfCVYtwGa9mdJXpFz0z3OcVHogze/TrQ7yeI/KcL3/xgOrUA==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/discord-embed-router/-/discord-embed-router-1.4.1.tgz", + "integrity": "sha512-XpIBf0EdP/sAFuAJWEt1cVmsOUbHbd9WFCnAuDicz8csbam7RuUvghRW0AksmK9xLgLDmRzJETyNJFJLWOFpJA==", "license": "MIT", "workspaces": [ "examples/basic-bot" diff --git a/package.json b/package.json index 134f0d0..18f24a1 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ }, "dependencies": { "@supabase/supabase-js": "^2.110.2", - "discord-embed-router": "^1.4.0", + "discord-embed-router": "^1.4.1", "discord.js": "^14.22.1", "dotenv": "^17.2.2", "is-online": "^11.0.0", diff --git a/src/main/diagnostics.ts b/src/main/diagnostics.ts index 7658777..e37c4f7 100644 --- a/src/main/diagnostics.ts +++ b/src/main/diagnostics.ts @@ -1,7 +1,4 @@ -import { EventEmitter } from "node:events"; - import { RouteInfo } from "discord-embed-router"; -import { Interaction } from "discord.js"; import { recordError, recordUsage } from "@db/diagnostics"; import { RingRouter } from "@routes/types"; @@ -13,14 +10,9 @@ export const routeKey = (info: RouteInfo): string => // "interaction"-triggered hops count: command dispatches are counted at the // command entry point, and redirect hops are renders, not user actions export const observeRouter = (router: RingRouter): void => { - // the cast is a workaround: under esModuleInterop:false the router's - // published typings lose their EventEmitter base, hiding .on() - (router as unknown as EventEmitter).on( - "route", - (_interaction: Interaction, info: RouteInfo) => { - if (info.trigger === "interaction") recordUsage(routeKey(info)); - }, - ); + router.on("route", (_interaction, info) => { + if (info.trigger === "interaction") recordUsage(routeKey(info)); + }); router.onError((err, _interaction, info) => { recordError(info ? routeKey(info) : "ROUTER", err); }); From a80f7b40949242f4fec03e2ccf55e6260593ba01 Mon Sep 17 00:00:00 2001 From: altrup <51763643+altrup@users.noreply.github.com> Date: Mon, 20 Jul 2026 23:18:25 -0400 Subject: [PATCH 6/6] format --- src/routes/signups/roles/by-channel/get.ts | 3 ++- src/routes/signups/roles/by-role/get.ts | 3 ++- src/routes/signups/roles/get.ts | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/routes/signups/roles/by-channel/get.ts b/src/routes/signups/roles/by-channel/get.ts index d82fccd..d9e644d 100644 --- a/src/routes/signups/roles/by-channel/get.ts +++ b/src/routes/signups/roles/by-channel/get.ts @@ -36,7 +36,8 @@ export const rolesByChannelGet: Handler<"GET"> = async ( ) => { const guild = interaction.guild; if (!guild) return guildOnlyRender(router, interaction); - if (!canManageRoleSignups(interaction)) return noPermissionRender(router, interaction); + if (!canManageRoleSignups(interaction)) + return noPermissionRender(router, interaction); const scope = roleScopeOf(state.params); if (!scope) return rolesGet(router, interaction, state); diff --git a/src/routes/signups/roles/by-role/get.ts b/src/routes/signups/roles/by-role/get.ts index dcf1244..0215de0 100644 --- a/src/routes/signups/roles/by-role/get.ts +++ b/src/routes/signups/roles/by-role/get.ts @@ -37,7 +37,8 @@ export const rolesByRoleGet: Handler<"GET"> = async ( ) => { const guild = interaction.guild; if (!guild) return guildOnlyRender(router, interaction); - if (!canManageRoleSignups(interaction)) return noPermissionRender(router, interaction); + if (!canManageRoleSignups(interaction)) + return noPermissionRender(router, interaction); const scope = roleScopeOf(state.params); if (!scope) return rolesGet(router, interaction, state); diff --git a/src/routes/signups/roles/get.ts b/src/routes/signups/roles/get.ts index 924e6c9..3df9ea7 100644 --- a/src/routes/signups/roles/get.ts +++ b/src/routes/signups/roles/get.ts @@ -16,7 +16,8 @@ import { BY_CHANNEL, BY_ROLE, LEAD, roleFrame } from "./_shared"; export const rolesGet: Handler<"GET"> = async (router, interaction, state) => { const guild = interaction.guild; if (!guild) return guildOnlyRender(router, interaction); - if (!canManageRoleSignups(interaction)) return noPermissionRender(router, interaction); + if (!canManageRoleSignups(interaction)) + return noPermissionRender(router, interaction); const channelSelectRow = new ActionRowBuilder() .addComponents(