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 diff --git a/package-lock.json b/package-lock.json index a7a35c6..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.3.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.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.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 16c6c3f..18f24a1 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.1", "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 352dd49..29e29c8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,8 @@ import { commands as commandsArray, } from "@commands/commands"; import { DISCORD_TOKEN } from "@config"; +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"; @@ -49,6 +51,7 @@ router.onError((err, interaction) => { const commandIds = new Map(); router.setGlobals({ commandIds }); +observeRouter(router); registerRoutes(router); // load commands @@ -96,9 +99,11 @@ client.on("interactionCreate", async (interaction) => { try { if (interaction instanceof ChatInputCommandInteraction) { + recordUsage(`COMMAND /${interaction.commandName}`); await command.execute(router, interaction); } } catch (error) { + recordError(`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..ac22803 --- /dev/null +++ b/src/main/db/diagnostics.test.ts @@ -0,0 +1,51 @@ +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 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); + + 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..de6f8dd --- /dev/null +++ b/src/main/db/diagnostics.ts @@ -0,0 +1,42 @@ +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 = (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" + ? `${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..de8e70f --- /dev/null +++ b/src/main/diagnostics.test.ts @@ -0,0 +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 { Globals } from "@routes/types"; + +import { observeRouter } from "./diagnostics"; + +vi.mock("@db/diagnostics", () => ({ + recordError: vi.fn(), + recordUsage: vi.fn(), +})); + +const interaction = {} as Interaction; +const info = (trigger: RouteInfo["trigger"]): RouteInfo => ({ + method: "POST", + path: "/ring/user", + trigger, +}); + +const observedRouter = () => { + const router = new EmbedRouter(); + observeRouter(router); + return router; +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +test("an interaction-triggered route counts under its method and pattern", () => { + observedRouter().emit("route", interaction, info("interaction")); + + expect(recordUsage).toHaveBeenCalledExactlyOnceWith("POST /ring/user"); +}); + +test("dispatch and redirect hops are not counted", () => { + const router = observedRouter(); + router.emit("route", interaction, info("dispatch")); + router.emit("route", interaction, info("redirect")); + + expect(recordUsage).not.toHaveBeenCalled(); +}); + +test("a route error is recorded under the failing route's key", () => { + const boom = new Error("boom"); + observedRouter().emit("routeError", boom, interaction, info("interaction")); + + expect(recordError).toHaveBeenCalledExactlyOnceWith("POST /ring/user", boom); +}); + +test("a router-internal error with no route info is recorded as ROUTER", () => { + const boom = new Error("boom"); + observedRouter().emit("routeError", boom, interaction, undefined); + + expect(recordError).toHaveBeenCalledExactlyOnceWith("ROUTER", boom); +}); diff --git a/src/main/diagnostics.ts b/src/main/diagnostics.ts new file mode 100644 index 0000000..e37c4f7 --- /dev/null +++ b/src/main/diagnostics.ts @@ -0,0 +1,19 @@ +import { RouteInfo } from "discord-embed-router"; + +import { recordError, recordUsage } from "@db/diagnostics"; +import { RingRouter } from "@routes/types"; + +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 => { + router.on("route", (_interaction, info) => { + if (info.trigger === "interaction") recordUsage(routeKey(info)); + }); + router.onError((err, _interaction, info) => { + recordError(info ? routeKey(info) : "ROUTER", err); + }); +}; 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( 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;