Skip to content

Reporting faults

Send the throws your bot reports to a Discord channel through a webhook. Covers what seedcord checks at boot, writing a reporter of your own, and the one card a minute limit.

seedcord logs every reported throw, and nobody watches a production bot's terminal. A reporter posts each one to a Discord channel instead. seedcord ships two and registers both while your bot starts. Each one reads a webhook url from an environment variable. Set both, so a throw you didn't expect reaches a channel you read.

.env
HANDLED_EXCEPTION_WEBHOOK_URL=https://discord.com/api/webhooks/...
UNKNOWN_EXCEPTION_WEBHOOK_URL=https://discord.com/api/webhooks/...
variablebus keywhat reaches it
HANDLED_EXCEPTION_WEBHOOK_URLhandledExceptiona Notice you threw with report true, and every Fault
UNKNOWN_EXCEPTION_WEBHOOK_URLunknownExceptionevery other throw, plus the two process listeners

The handledException card shows, from the top:

  • the class name and its message
  • the kind, the command, and the customId
  • the user, guild, channel, and interaction ids
  • the uuid and a stack, taken from the cause when the throw has one and from the throw itself otherwise

seedcord attaches the raw interaction as source.json. A fault raised in an event handler shows the event and the handler in place of the kind, and it drops the command, customId, and interaction id.

The handled exception card in Discord for a Fault thrown from the ban command, with the kind, command, and ids, the uuid, the cause's stack, and a source.json attachment

The unknownException card shows the guild and the user, then the uuid with the error's stack. Under that, seedcord attaches any metadata on the throw as metadata.json. Three timestamps appear only when the error is a DiscordAPIError from an interaction callback, like the 10062 a late reply throws. They show when Discord sent the interaction, when the error reached the log, and the gap between them. The card below came from a plain Error, so it has none.

The unknown exception card in Discord, with the guild and user, the uuid, a stack trace, and a metadata.json attachment

What seedcord checks at boot

An unset variable disables its reporter and logs one line.

HandledException disabled, HANDLED_EXCEPTION_WEBHOOK_URL is not set

Warning

seedcord matches every url it finds against Discord's webhook form and throws when one doesn't match. It then asks Discord about every webhook it accepted, and a 404 or a 401 throws too. That second throw names every dead variable at once, so a first boot reports all of them together.

Your own reporter

A reporter delivers one bus key to one webhook. Write your own when a kind of throw should go to a channel of its own, or when the shipped card is missing something you need. Subclass WebhookLog, pass the key to @Subscribe, and pass the environment variable to @WebhookUrl.

src/subscribers/FaultMirror.ts
import {
    BuilderComponent,
    Subscribe,
    WebhookLog,
    WebhookUrl
} from '@seedcord/gateway';

import type { WebhookReport } from '@seedcord/gateway';

@Subscribe('handledException')
@WebhookUrl('FAULT_MIRROR_WEBHOOK_URL')
export class FaultMirror extends WebhookLog<'handledException'> {
    public report(): WebhookReport {
        const { uuid, origin } = this.data;

        return {
            username: 'Faults',
            components: [new MirrorCard(uuid, origin).component]
        };
    }
}

this.data is that key's payload. Give the generic on WebhookLog the same key you passed @Subscribe, since this.data takes its type from the generic. Return a promise from report() when you have to load something before the card can be built.

fieldwhat it does
componentsthe card itself, and the only field you have to return
usernamethe author name Discord shows, defaulting to your class name
avatarUrlthe author avatar, defaulting to the webhook's own
filesbytes to upload, which a file or thumbnail component references by name

The shipped handledException reporter uses files to attach source.json every time. The unknownException one attaches metadata.json only when the throw carries metadata, so a process listener's card arrives without a file.

seedcord scans the folder set in subscribers.path for your reporters. The scaffold ships null there, so point it at your folder.

src/bot.ts
export const seedcord = new Seedcord({
    bot,
    subscribers: {
        path: resolve(import.meta.dirname, './subscribers')
    }
});

seedcord registers the two shipped reporters whether or not you set that path. A reporter is one kind of bus subscriber, and the Events tab covers the bus and the rest of what a subscriber like FaultMirror can do.

One card a minute

One bug in a busy command can throw on every interaction. Unthrottled, that's a card per throw, and the first one scrolls away under the rest.

seedcord calls throttleKey() before it sends each card. The base returns null, so each publish sends its own card. Return a string from your override. Events sharing that string then collapse into one card a minute.

seedcord then calls report() with the number it dropped while the minute was running. Here is FaultMirror again with both methods.

src/subscribers/FaultMirror.ts
import {
    BuilderComponent,
    Subscribe,
    WebhookLog,
    WebhookUrl
} from '@seedcord/gateway';

import type { WebhookReport } from '@seedcord/gateway';

@Subscribe('handledException')
@WebhookUrl('FAULT_MIRROR_WEBHOOK_URL')
export class FaultMirror extends WebhookLog<'handledException'> {
    protected override throttleKey(): string {
        return `mirror:${this.data.origin}`;
    }

    public report(suppressed: number): WebhookReport {
        const { uuid, origin } = this.data;

        return {
            username: 'Faults',
            components: [
                new MirrorCard(uuid, origin, suppressed).component
            ]
        };
    }
}

FaultMirror keys on origin and ends its card with +N more. The two shipped reporters key on the origin plus the error's class name, and they end their card with this line.

-# 12 more of these since the last report

seedcord keeps the count when a send fails, so the next card still carries those events.