Skip to content

Reading the telemetry

Measure how long your bot takes to answer with the telemetry keys on the bus. Covers what each interaction, write, and event reports, the two timing clocks, catching a failed write, joining the keys on dispatchId, and typing a payload.

When a command feels slow, you want to know whether your code or Discord caused the delay. Timing each handler yourself means a stopwatch in every execute(), which still misses the gates and the replies around it.

seedcord measures each dispatch for you and publishes the numbers on the bus. interactionDispatched fires once per interaction, after your handler finishes. responseAttempted fires on every write through the reply surface, so one interaction can produce several. That count includes the error card seedcord sends when your handler throws.

src/subscribers/SlowDispatch.ts
import { Subscribe, Subscriber } from '@seedcord/gateway';

const SLOW_MS = 2000;

@Subscribe('interactionDispatched')
export class SlowDispatch extends Subscriber<'interactionDispatched'> {
    public async execute(): Promise<void> {
        const { routeId, outcome, durationMs } = this.data;
        if (durationMs < SLOW_MS) return;

        this.logger.warn(
            `${routeId} ${outcome} in ${Math.round(durationMs)}ms`
        );
    }
}

SlowDispatch logs any dispatch that took longer than SLOW_MS, two seconds, with its route and outcome.

What one interactionDispatched reports

fieldtypewhat it holds
dispatchIdstringthis dispatch, unique to it
routeIdstringthe route that ran, slash:ban shaped
interactionIdstringDiscord's id for this interaction
kindInteractionKindslash, button, modal, autocomplete, the five *Menu kinds, and the two *ContextMenu kinds
outcomeDispatchOutcomehandled, refused, or failed
fallbackbooleantrue when no route matched and the unhandled default ran
userIdstring | nullwho ran the route, null when the payload didn't carry a user
guildIdstring | nullthe guild it came from, null in DMs
durationMsnumberdispatch entry until the handler returns or its error card is sent
queuedMsnumberDiscord's interaction creation until dispatch entry

outcome comes from what you threw, whether from a middleware, a gate, or the handler.

  • handled means nothing threw.
  • refused means a Silence, or a Notice with report false.
  • failed means a Notice with report true, or any other throw.

The two clocks

A slow command has two possible causes: the interaction reached your bot late, or your bot took long once it arrived. The dispatch reports each one on its own clock.

discord creates it      dispatch entry            finished
        │                     │                       │
        └───── queuedMs ──────┘                       │
                              │                       │
                              └───── durationMs ──────┘
                               on interactionDispatched

                              middleware, gates, execute()
                              one responseAttempted per write,
                              each with its own durationMs

durationMs runs on a monotonic clock, so a system clock change never moves it. The clock stops once your handler returns, or once the error card for a throw has gone out. That covers your middleware, your gates, and every reply your handler awaited. Middleware after callbacks run after seedcord publishes interactionDispatched, so their time isn't part of durationMs.

Every interaction id carries the moment Discord created it, built into the id itself. seedcord reads that moment back out and subtracts it from your server's clock as the dispatch starts.

discord created it   12:00:00.000
your bot reached it  12:00:00.180
                     ────────────
queuedMs                      180

Here the bot reached the interaction 180ms after Discord created it, so queuedMs reports 180. If the network is slow, or your bot was busy with other work, the number grows.

Warning

queuedMs compares your server's clock against Discord's. The two never agree exactly, so a server running a little behind reports a negative number. A run of negatives means your server's clock is behind, so check that it syncs over NTP. If the interaction id doesn't parse, seedcord reports 0.

What one responseAttempted reports

A dispatch can be slow because one Discord call was slow. responseAttempted times each write on its own, so you can tell a slow reply apart from a slow handler.

fieldtypewhat it holds
dispatchIdstringthe dispatch this write belongs to
routeIdstringthe same route id the dispatch reports
interactionIdstringthe same interaction id
methodstring unionreply, defer, deferUpdate, update, followUp, edit, delete, showModal, or respond
outcomeResponseOutcomesent or failed
durationMsnumberthe write itself, monotonic
messageIdstring | nullthe id of the message Discord returned
errorErroronly on a failed write

send picks a method from the ack state, and method reports the one that ran. The type also allows send, but a published write never carries it.

messageId carries an id for reply, update, followUp, and edit. Every other method reports null, as does a failed write.

Catching a failed write

Your handler sees its own failed write as a throw. seedcord also sends writes of its own, like the error card after a throw. Those don't run through your handler. When any write throws, seedcord publishes outcome failed with the throw on error. Check outcome first, because only the failed shape carries an error field.

src/subscribers/WriteAudit.ts
@Subscribe('responseAttempted')
export class WriteAudit extends Subscriber<'responseAttempted'> {
    public async execute(): Promise<void> {
        const write = this.data;
        if (write.outcome === 'sent') return;

        this.logger.error(
            `${write.method} failed on ${write.interactionId}`,
            write.error
        );
    }
}

WriteAudit returns early on 'sent', so TypeScript knows write.error exists on the next line.

Discord can return a reply or an update without the message it should carry. seedcord throws ReplyCallbackMissingMessage there. That write doesn't publish anything, since the call itself succeeded without naming a message.

What one eventDispatched reports

When an event fires, seedcord runs every handler you registered for it, each behind its own fault boundary. A failing handler doesn't stop the others, so without a report per handler you'd only find it in the error log. eventDispatched publishes once all of those handlers have finished.

Gateway only

eventDispatched is declared in @seedcord/gateway alone. An http bot doesn't receive client events, so it never publishes this key.

fieldtypewhat it holds
dispatchIdstringthis dispatch of the event, the same id eventDispatching published
namekeyof ClientEventsthe event, messageCreate shaped
outcomeDispatchOutcomehow the middleware chain ended
handlersHandlerOutcome[]one entry per handler that ran, in order
durationMsnumberdispatch entry until every handler finishes, monotonic

outcome describes the middleware chain alone. Where the chain refuses the event, it reads refused and leaves handlers empty, since the handlers never started. Each entry in handlers carries its own class name and outcome.

src/subscribers/FlakyHandlers.ts
@Subscribe('eventDispatched')
export class FlakyHandlers extends Subscriber<'eventDispatched'> {
    public async execute(): Promise<void> {
        const { name, handlers } = this.data;

        for (const entry of handlers) {
            if (entry.outcome === 'handled') continue;
            this.logger.warn(
                `${entry.handler} ${entry.outcome} on ${name}`
            );
        }
    }
}

FlakyHandlers skips the handled entries and logs the rest, so one failing handler shows up even while the others succeed.

entry holds only the handler's name and its outcome. seedcord publishes the error itself on a fault key, with the same dispatchId:

  • a raw throw publishes unknownException
  • a Notice with report true publishes handledException

To tell which handler threw, read the fault's origin. On an event it names the event and the handler, like event:messageCreate:AutoMod. Match on dispatchId as well, since every dispatch of that event carries the same origin.

Warning

queuedMs comes from the interaction's snowflake. An event doesn't carry one, so this key omits the field. eventDispatching and eventDispatched both publish only when at least one handler is about to run, which keeps them paired.

Joining the keys

A slow dispatch, its writes, and the fault it threw arrive as separate publishes. To see them as one story, you need a value they share. Every key on this page carries a dispatchId. Two runs of the same route get different ids, so keying a store on it lines up a single dispatch with everything it published.

Your code can write the same id from inside the dispatch.

src/handlers/middlewares/Trace.ts
@RegisterInteractionMiddleware()
export class Trace extends InteractionMiddleware {
    public async execute(): Promise<void> {
        await audit.record({
            trace: this.dispatch.id,
            route: this.dispatch.routeId
        });
    }
}

Trace records this.dispatch.id before any of these keys fire. A handler reaches it through this.dispatch.id, a gate through ctx.dispatch.id, and an error card through its render context.

On the subscriber side, write dispatchId as a column in every row and query on it.

src/subscribers/Dispatches.ts
@Subscribe('interactionDispatched')
export class Dispatches extends Subscriber<'interactionDispatched'> {
    public async execute(): Promise<void> {
        const { dispatchId, routeId, outcome, durationMs } =
            this.data;
        await db.insert({
            dispatchId,
            routeId,
            outcome,
            durationMs
        });
    }
}

@Subscribe('unknownException')
export class Faults extends Subscriber<'unknownException'> {
    public async execute(): Promise<void> {
        const { dispatchId, uuid, error } = this.data;
        await db.insert({
            dispatchId,
            uuid,
            message: error.message
        });
    }
}

Dispatches and Faults store rows you can join on that column.

Warning

unknownException.dispatchId is string | null. An unhandled rejection reaches Node outside any dispatch, which is the case that reports null.

Typing a payload yourself

Once a subscriber grows helpers, each helper needs the payload type, and a hand-written copy of the fields goes stale when seedcord adds one. SubscriptionData takes a key and returns that key's payload type.

src/subscribers/rows.ts
function rowFor(
    data: SubscriptionData<'interactionDispatched'>
): object {
    return {
        id: data.dispatchId,
        route: data.routeId,
        ms: data.durationMs
    };
}

rowFor uses it to type its parameter. A key you declared on Subscriptions works the same way, as in SubscriptionData<'levelUp'>.

Storing them yourself

seedcord publishes these keys and doesn't store any of them. To keep history, like a dashboard of slow routes, write a subscriber that saves each payload to your own database or metrics service.