Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Privacy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -49,6 +51,7 @@ router.onError((err, interaction) => {

const commandIds = new Map<CommandName, string>();
router.setGlobals({ commandIds });
observeRouter(router);
registerRoutes(router);

// load commands
Expand Down Expand Up @@ -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({
Expand Down
46 changes: 45 additions & 1 deletion src/main/db/database.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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"];
Expand Down Expand Up @@ -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";
Expand Down
51 changes: 51 additions & 0 deletions src/main/db/diagnostics.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
42 changes: 42 additions & 0 deletions src/main/db/diagnostics.ts
Original file line number Diff line number Diff line change
@@ -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);
};
58 changes: 58 additions & 0 deletions src/main/diagnostics.test.ts
Original file line number Diff line number Diff line change
@@ -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<Globals>();
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);
});
19 changes: 19 additions & 0 deletions src/main/diagnostics.ts
Original file line number Diff line number Diff line change
@@ -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);
});
};
3 changes: 2 additions & 1 deletion src/routes/signups/roles/by-channel/get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion src/routes/signups/roles/by-role/get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion src/routes/signups/roles/get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RouteChannelSelectMenuBuilder>()
.addComponents(
Expand Down
37 changes: 37 additions & 0 deletions supabase/migrations/20260720000000_diagnostics.sql
Original file line number Diff line number Diff line change
@@ -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;
Loading