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.
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
| field | type | what it holds |
|---|---|---|
dispatchId | string | this dispatch, unique to it |
routeId | string | the route that ran, slash:ban shaped |
interactionId | string | Discord's id for this interaction |
kind | InteractionKind | slash, button, modal, autocomplete, the five *Menu kinds, and the two *ContextMenu kinds |
outcome | DispatchOutcome | handled, refused, or failed |
fallback | boolean | true when no route matched and the unhandled default ran |
userId | string | null | who ran the route, null when the payload didn't carry a user |
guildId | string | null | the guild it came from, null in DMs |
durationMs | number | dispatch entry until the handler returns or its error card is sent |
queuedMs | number | Discord's interaction creation until dispatch entry |
outcome comes from what you threw, whether from a middleware, a gate, or the handler.
handledmeans nothing threw.refusedmeans aSilence, or aNoticewithreportfalse.failedmeans aNoticewithreporttrue, 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 durationMsdurationMs 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 180Here 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.
| field | type | what it holds |
|---|---|---|
dispatchId | string | the dispatch this write belongs to |
routeId | string | the same route id the dispatch reports |
interactionId | string | the same interaction id |
method | string union | reply, defer, deferUpdate, update, followUp, edit, delete, showModal, or respond |
outcome | ResponseOutcome | sent or failed |
durationMs | number | the write itself, monotonic |
messageId | string | null | the id of the message Discord returned |
error | Error | only 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.
@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.
| field | type | what it holds |
|---|---|---|
dispatchId | string | this dispatch of the event, the same id eventDispatching published |
name | keyof ClientEvents | the event, messageCreate shaped |
outcome | DispatchOutcome | how the middleware chain ended |
handlers | HandlerOutcome[] | one entry per handler that ran, in order |
durationMs | number | dispatch 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.
@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
Noticewithreporttrue publisheshandledException
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.
@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.
@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.
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.