Lifecycle Events
Introduction
Section titled “Introduction”Every time Warden runs a handler — a command, an event listener, a button click, a scheduled job — it goes through a structured lifecycle. The framework emits events at each stage, giving you hooks to observe, measure, and react to everything that happens in your bot.
You don’t need lifecycle events to build a working bot. But for any bot where observability matters, they’re invaluable. They let you answer questions like: “How long did that ban command take?”, “Which commands are used most?”, “Did any handlers fail in the last hour?” — all without modifying the handlers themselves.
Lifecycle events have their own dedicated @lifecycle() decorator, separate from @event(). This is intentional: @event() is reserved for Discord events and decomposed events — things that happen in Discord. Lifecycle events are framework-internal, and mixing them into the Discord event system would pollute it with concerns that don’t belong there. The @lifecycle() decorator keeps the two worlds cleanly separated while following the same patterns you already know.
The command lifecycle
Section titled “The command lifecycle”When a user runs a slash command, the framework emits events in this order:
CommandPre → CommandDenied / CommandAccepted → CommandRun → CommandSuccess / CommandError → CommandFinishHere’s what each stage means:
| Event | When it fires | What it means |
|---|---|---|
commandPre | Before anything runs | The command was received. Middleware and permission checks haven’t started yet. |
commandDenied | After middleware/permissions reject | The command was blocked — a permission check failed, a cooldown triggered, or middleware short-circuited. |
commandAccepted | After middleware/permissions pass | The command passed all checks and is about to execute. |
commandRun | Immediately before execute() | The handler is running. This is the last event before your code takes over. |
commandSuccess | After execute() returns | The handler completed without throwing. |
commandError | After execute() throws | The handler threw an error. The error is included in the payload. |
commandFinish | Always, after success or error | Cleanup stage. Always fires, regardless of outcome — like a finally block. Includes timing data. |
The branch point is after commandPre: if middleware or permissions reject the interaction, the flow goes to commandDenied and stops. If they pass, it continues through commandAccepted to commandRun and beyond.
Other handler lifecycles
Section titled “Other handler lifecycles”The same lifecycle pattern applies to every handler type in the framework. The event names follow a consistent convention:
| Handler type | Pre | Denied | Accepted | Run | Success | Error | Finish |
|---|---|---|---|---|---|---|---|
| Command | commandPre | commandDenied | commandAccepted | commandRun | commandSuccess | commandError | commandFinish |
| Context menu | contextPre | contextDenied | contextAccepted | contextRun | contextSuccess | contextError | contextFinish |
| Event | eventPre | eventDenied | eventAccepted | eventRun | eventSuccess | eventError | eventFinish |
| Button | buttonPre | buttonDenied | buttonAccepted | buttonRun | buttonSuccess | buttonError | buttonFinish |
| Modal | modalPre | modalDenied | modalAccepted | modalRun | modalSuccess | modalError | modalFinish |
| Menu | menuPre | menuDenied | menuAccepted | menuRun | menuSuccess | menuError | menuFinish |
| Job | jobPre | jobDenied | jobAccepted | jobRun | jobSuccess | jobError | jobFinish |
Every handler type follows the same flow: Pre, Denied/Accepted, Run, Success/Error, Finish. The only difference is the prefix.
Payload structure
Section titled “Payload structure”Every lifecycle event receives a payload with information about the handler execution. The shape depends on the stage:
interface LifecyclePayload { name: string; // handler name (e.g., 'ban', 'MemberJoinLog') type: string; // handler type (e.g., 'command', 'event', 'button') timestamp: number; // when this event fired (Date.now())}
// Added on Finish eventsinterface FinishPayload extends LifecyclePayload { duration: number; // total execution time in milliseconds (from Pre to Finish) success: boolean; // whether the handler completed without error}
// Added on Error eventsinterface ErrorPayload extends LifecyclePayload { error: Error; // the error that was thrown}
// Added on Denied eventsinterface DeniedPayload extends LifecyclePayload { reason: string; // why the handler was denied (e.g., 'permission', 'cooldown', 'middleware')}The duration field on finish events measures the full pipeline — from Pre through middleware, execute(), and any error handling, all the way to Finish. This gives you an accurate picture of the total time the framework spent handling the interaction.
Listening to lifecycle events
Section titled “Listening to lifecycle events”Lifecycle events use the @lifecycle() decorator — not @event(). This keeps your Discord event handlers and framework lifecycle handlers cleanly separated. The @lifecycle() decorator auto-registers the class for DI (no @injectable() needed), just like every other framework decorator:
import {lifecycle, LifecycleHandler, FinishPayload} from '@warden/core';
@lifecycle({event: 'commandFinish'})export default class CommandFinishLogger implements LifecycleHandler { constructor(private logger: Logger) {}
public async execute(payload: FinishPayload): Promise<void> { this.logger.info(`Command /${payload.name} finished in ${payload.duration}ms`, { success: payload.success, }); }}You can register multiple listeners for the same lifecycle event. They run independently.
Practical examples
Section titled “Practical examples”Logging mod command execution times
Section titled “Logging mod command execution times”Knowing how long commands take helps you spot performance issues before your users do. This listener logs every mod command’s execution time:
@lifecycle({event: 'commandFinish'})export default class ModCommandTimer implements LifecycleHandler { constructor(private logger: Logger) {}
public async execute(payload: FinishPayload): Promise<void> { // only care about mod-related commands const modCommands = ['warn', 'mute', 'ban', 'kick', 'case', 'history']; if (!modCommands.includes(payload.name)) return;
const level = payload.duration > 3000 ? 'warn' : 'info'; this.logger[level](`Mod command /${payload.name}: ${payload.duration}ms`, { success: payload.success, }); }}Commands that take longer than 3 seconds get a warning-level log, making them easy to find in your log aggregator.
Tracking command usage analytics
Section titled “Tracking command usage analytics”Track which commands are used, how often, and whether they succeed. This data helps you understand which moderation tools your team relies on:
@lifecycle({event: 'commandFinish'})export default class CommandUsageTracker implements LifecycleHandler { constructor(private analyticsService: AnalyticsService) {}
public async execute(payload: FinishPayload): Promise<void> { await this.analyticsService.trackCommand({ name: payload.name, duration: payload.duration, success: payload.success, timestamp: payload.timestamp, }); }}You might also track denied commands to understand permission issues:
@lifecycle({event: 'commandDenied'})export default class CommandDeniedTracker implements LifecycleHandler { constructor(private analyticsService: AnalyticsService) {}
public async execute(payload: DeniedPayload): Promise<void> { await this.analyticsService.trackDenied({ name: payload.name, reason: payload.reason, timestamp: payload.timestamp, }); }}If a command gets denied frequently, it might mean your permission configuration needs adjusting — or that users are discovering commands they shouldn’t see.
Alerting on repeated failures
Section titled “Alerting on repeated failures”If a handler fails multiple times in a short window, something is probably wrong. This listener keeps a simple in-memory counter and alerts the mod channel when a threshold is crossed:
@lifecycle({event: 'commandError'})export default class FailureAlert implements LifecycleHandler { private failures = new Map<string, {count: number; firstSeen: number}>(); private readonly threshold = 5; private readonly windowMs = 60_000; // 1 minute
constructor(private modLog: ModLogService) {}
public async execute(payload: ErrorPayload): Promise<void> { const now = Date.now(); const entry = this.failures.get(payload.name);
if (!entry || now - entry.firstSeen > this.windowMs) { this.failures.set(payload.name, {count: 1, firstSeen: now}); return; }
entry.count++;
if (entry.count === this.threshold) { await this.modLog.send( Embed.error(`Command /${payload.name} has failed ${this.threshold} times in the last minute`) .addFields({name: 'Latest error', value: payload.error.message}) .setTimestamp(), ); // reset so we don't spam this.failures.delete(payload.name); } }}Foundation for monitoring
Section titled “Foundation for monitoring”Lifecycle events are the building blocks for observability. On their own, they give you logging and simple alerting. Combined with external tools, they power dashboards, APM traces, and real-time monitoring.
Some things you can build on top of lifecycle events:
- Prometheus metrics — Expose command durations and error rates as metrics, then graph them in Grafana
- Structured logging — Emit JSON logs with handler name, type, duration, and success status for your log aggregator
- APM integration — Start a trace span on
commandPreand close it oncommandFinishfor distributed tracing - Health checks — Track the last successful execution of critical jobs and alert if they stop running
The framework keeps the lifecycle event system simple and unopinionated. It emits the events with useful payloads, and you decide what to do with them. Future versions of Warden may ship monitoring plugins that build on this foundation, but the events themselves are stable and available today.