router!: emit telemetry events instead of logging
@@ -5,6 +5,7 @@
## table of contents - [bootstrapping](#bootstrapping) +- [logging](#logging) - [defining your first command](#defining-your-first-command) - [adding subcommands](#adding-subcommands) - [context menus](#context-menus)@@ -46,6 +47,32 @@ await client.login(process.env.DISCORD_TOKEN);
``` and we're ready to start building out some commands and components! + +## logging + +kitten extends Node's native `EventEmitter` and does not enforce any specific +logging library. instead, it emits typed events (`debug`, `info`, `warn`, and +`error`) which you can handle however you like. kitten will not log on its own. + +```ts +// subscribe to events for logging and diagnostics +kitten.on("debug", (message, data) => { + console.debug(`[DEBUG] ${message}`, data ?? ""); +}); + +kitten.on("info", (message, data) => { + console.info(`[INFO] ${message}`, data ?? ""); +}); + +kitten.on("warn", (message, data) => { + console.warn(`[WARN] ${message}`, data ?? ""); +}); + +// hook into execution errors +kitten.on("error", (message, data) => { + console.error(`[ERROR] ${message}`, data ?? ""); +}); +``` ## defining your first command
@@ -1,4 +1,4 @@
-# [1.4.0] 2026-07-07 +# [1.4.0] 2026-07-08 in this release i added hirearchical unhandled exception handling, exceptions can now be caught, handled, or rethrown across three separate scopes:
@@ -0,0 +1,24 @@
+# [1.5.0] 2026-07-09 + +in this release i decoupled kitten from any opinionated logging library by +switching to an event-emitting telemetry model. the `Kitten` class now extends +Node's native `EventEmitter` and emits strongly-typed lifecycle events: + +- `debug`: diagnostics and routing traces, +- `info`: command registration and API sync notices, +- `warn`: non-fatal runtime warning telemetry, and +- `error`: fallback system telemetry for unhandled execution failures. + +the `logger` option has been removed from `KittenOptions`. you can now subscribe +to these events to pipe telemetry directly into your preferred logger. + +this event system operates strictly as a telemetry layer and remains separate from the +interaction `onError` propagation chain, which is reserved for active error recovery +and user communication. + +kitten will not log anything itself. + +you can find an example under the "logging" section of the [Getting Started] +guide. + +[Getting Started]: ../GET-STARTED.md
@@ -1,6 +1,6 @@
{ "name": "@purrkit/router", - "version": "1.4.0", + "version": "1.5.0", "license": "EUPL-1.2", "author": { "name": "apr",
@@ -3,7 +3,6 @@ export * from "./errors";
export * from "./components"; export * from "./commands"; export * from "./kitten"; -export * from "./logger"; // TODO)) // components
@@ -1,3 +1,4 @@
+import { EventEmitter } from "node:events"; import { type Client, type ChatInputCommandInteraction,@@ -28,29 +29,36 @@ type CommandResultType,
type Middleware, type ErrorHandler, } from "./commands"; -import { baseLogger, type KittenLogger } from "./logger"; export interface KittenOptions { - logger?: KittenLogger; onError?: ErrorHandler<BaseInteraction, any>; } -export class Kitten { +export interface KittenEvents { + debug: [message: string, data?: Record<string, any>]; + info: [message: string, data?: Record<string, any>]; + warn: [message: string, data?: Record<string, any>]; + error: [message: string, data?: Record<string, any>]; +} + +export class Kitten extends EventEmitter<KittenEvents> { private commands = new Map<string, Command<any> | SubcommandGroupBuilder<any>>(); private userContextMenus = new Map<string, ContextMenuCommand<any, any>>(); private messageContextMenus = new Map<string, ContextMenuCommand<any, any>>(); private components = new Map<string, KittenComponent<any, any, any>>(); - private logger: KittenLogger; private globalOnError?: ErrorHandler<BaseInteraction, any>; constructor( private client: Client, options: KittenOptions = {}, ) { + super(); this.client.on("interactionCreate", this.handleInteraction.bind(this)); - this.logger = options.logger ?? baseLogger; this.globalOnError = options.onError; - this.logger.debug("kitten instance initialized."); + + process.nextTick(() => { + this.emit("debug", "kitten instance initialized."); + }); } builder() {@@ -156,15 +164,15 @@ registry.commands?.forEach((cmd) => {
if (cmd instanceof ContextMenuCommand) { if (cmd.type === "user") { this.userContextMenus.set(cmd.name, cmd); - this.logger.debug(`registered user context menu: "${cmd.name}"`); + this.emit("debug", `registered user context menu: "${cmd.name}"`); } else if (cmd.type === "message") { this.messageContextMenus.set(cmd.name, cmd); - this.logger.debug(`registered message context menu: "${cmd.name}"`); + this.emit("debug", `registered message context menu: "${cmd.name}"`); } } else { this.commands.set(cmd.name, cmd); const subInfo = cmd instanceof SubcommandGroupBuilder ? " (subcommand group)" : ""; - this.logger.debug(`registered command: "${cmd.name}"${subInfo}`); + this.emit("debug", `registered command: "${cmd.name}"${subInfo}`); } }); registry.components?.forEach((comp) => {@@ -175,7 +183,8 @@ const staticOverhead = comp.name.length + keys.length;
const dynamicBudget = 100 - staticOverhead; if (dynamicBudget < 25) { - this.logger.warn( + this.emit( + "warn", [ `component "${comp.name}" has a high static ID overhead (${staticOverhead} chars).`, `This leaves only ${dynamicBudget} characters for actual runtime values before`,@@ -183,13 +192,13 @@ `hitting Discord's 100-character limit.`,
].join(" "), ); } else { - this.logger.debug(`registered component prefix: "${comp.name}"`); + this.emit("debug", `registered component prefix: "${comp.name}"`); } }); } private async handleInteraction(interaction: BaseInteraction) { - this.logger.debug(`received interaction ID: ${interaction.id}, Type: ${interaction.type}`); + this.emit("debug", `received interaction ID: ${interaction.id}, Type: ${interaction.type}`); switch (true) { // commands@@ -210,14 +219,17 @@ return await this.routeComponent(interaction);
case interaction.isModalSubmit(): return await this.routeComponent(interaction); default: - this.logger.debug(`interaction ID: ${interaction.id} could not be classified or handled.`); + this.emit("debug", `interaction ID: ${interaction.id} could not be classified or handled.`); } } private async routeCommand(interaction: ChatInputCommandInteraction) { const entry = this.commands.get(interaction.commandName); if (!entry) { - this.logger.debug(`command routing aborted: "${interaction.commandName}" is not registered.`); + this.emit( + "debug", + `command routing aborted: "${interaction.commandName}" is not registered.`, + ); return; }@@ -225,21 +237,24 @@ if (entry instanceof SubcommandGroupBuilder) {
const groupName = interaction.options.getSubcommandGroup(false); const subName = interaction.options.getSubcommand(false); - this.logger.debug( + this.emit( + "debug", `routing group-based command: "${interaction.commandName}", group: ${groupName ?? "none"}, subcommand: ${subName ?? "none"}`, ); if (groupName) { const group = entry.groups[groupName]; if (!group) { - this.logger.debug( + this.emit( + "debug", `subcommand group "${groupName}" not found under "${interaction.commandName}".`, ); return; } const subcommand = group.subcommands[subName ?? ""]; if (!subcommand) { - this.logger.debug( + this.emit( + "debug", `subcommand "${subName}" not found in group "${groupName}" under "${interaction.commandName}".`, ); return;@@ -259,7 +274,8 @@ );
} else if (subName) { const subcommand = entry.subcommands[subName]; if (!subcommand) { - this.logger.debug( + this.emit( + "debug", `subcommand "${subName}" not found under "${interaction.commandName}".`, ); return;@@ -278,7 +294,7 @@ },
); } } else { - this.logger.debug(`routing top-level chat command: "${interaction.commandName}"`); + this.emit("debug", `routing top-level chat command: "${interaction.commandName}"`); await this.executeWithMiddlewares( interaction, entry.middlewares,@@ -303,13 +319,14 @@ ? this.userContextMenus.get(interaction.commandName)
: this.messageContextMenus.get(interaction.commandName); if (!entry) { - this.logger.debug( + this.emit( + "debug", `context menu routing aborted: "${interaction.commandName}" (${type}) is not registered.`, ); return; } - this.logger.debug(`routing context menu command: "${interaction.commandName}" (${type})`); + this.emit("debug", `routing context menu command: "${interaction.commandName}" (${type})`); await this.executeWithMiddlewares( interaction, entry.middlewares,@@ -336,21 +353,22 @@ },
) { let context: Record<string, any> = {}; try { - this.logger.debug( + this.emit( + "debug", `Running ${middlewares.length} middleware(s) for interaction ${interaction.id}`, ); for (let i = 0; i < middlewares.length; i++) { const mw = middlewares[i]; if (!mw) continue; - this.logger.debug(`executing middleware [${i + 1}/${middlewares.length}]`); + this.emit("debug", `executing middleware [${i + 1}/${middlewares.length}]`); const result = await mw(interaction, context); if (result) context = { ...context, ...result }; } if (interaction.isChatInputCommand()) { const args = this.parseSlashOptions(interaction, optionsSchema); - this.logger.debug(`executing command handler for "${interaction.commandName}"`, { args }); + this.emit("debug", `executing command handler for "${interaction.commandName}"`, { args }); await run(interaction, args, context); } else if ( interaction.isButton() ||@@ -358,17 +376,17 @@ interaction.isAnySelectMenu() ||
interaction.isModalSubmit() ) { const customId = (interaction as any).customId; - this.logger.debug(`executing component handler for custom ID: "${customId}"`, { + this.emit("debug", `executing component handler for custom ID: "${customId}"`, { componentArgs, }); await run(interaction, componentArgs ?? {}, context); } else { - this.logger.debug(`executing generic fallback handler for interaction ${interaction.id}`); + this.emit("debug", `executing generic fallback handler for interaction ${interaction.id}`); await run(interaction, context); } } catch (err) { if (err instanceof HaltExecution) { - this.logger.debug(`execution halted intentionally for interaction ${interaction.id}.`); + this.emit("debug", `execution halted intentionally for interaction ${interaction.id}.`); if ( err.replyPayload && interaction.isRepliable() &&@@ -390,12 +408,12 @@
// 1. Try local error handler first try { if (extra?.localOnError) { - this.logger.debug(`executing local error handler for interaction ${interaction.id}`); + this.emit("debug", `executing local error handler for interaction ${interaction.id}`); await extra.localOnError(activeError, interaction, context); handled = true; } } catch (localErr) { - this.logger.debug(`local error handler threw an error, propagating...`); + this.emit("debug", `local error handler threw an error, propagating...`); activeError = localErr; }@@ -403,12 +421,13 @@ // 2. Fallback to builder-level error handlers
if (!handled && extra?.builderErrorHandlers) { for (const handler of extra.builderErrorHandlers) { try { - this.logger.debug(`executing builder error handler for interaction ${interaction.id}`); + this.emit("debug", `executing builder error handler for interaction ${interaction.id}`); await handler(activeError, interaction, context); handled = true; break; } catch (builderErr) { - this.logger.debug( + this.emit( + "debug", `builder error handler threw an error, propagating to next handler...`, ); activeError = builderErr;@@ -418,11 +437,12 @@ }
if (!handled && this.globalOnError) { try { - this.logger.debug(`executing global error handler for interaction ${interaction.id}`); + this.emit("debug", `executing global error handler for interaction ${interaction.id}`); await this.globalOnError(activeError, interaction, context); handled = true; } catch (globalErr) { - this.logger.debug( + this.emit( + "debug", `global error handler threw an error, falling back to default logging.`, ); activeError = globalErr;@@ -438,7 +458,7 @@ ? (interaction as any).customId
: "unknown"; const typeLabel = isCommand ? "command" : "component"; - this.logger.error(`unhandled error executing ${typeLabel} ${identifier}:`, { + this.emit("error", `unhandled error executing ${typeLabel} ${identifier}:`, { error: activeError instanceof Error ? activeError.stack : String(activeError), [typeLabel]: identifier, user: interaction.user.id,@@ -459,7 +479,8 @@
private async routeAutocomplete(interaction: AutocompleteInteraction) { const entry = this.commands.get(interaction.commandName); if (!entry) { - this.logger.debug( + this.emit( + "debug", `autocomplete aborted: command "${interaction.commandName}" not registered.`, ); return;@@ -481,13 +502,15 @@ }
const opt = optionsSchema?.[focused.name]; if (opt?.autocomplete) { - this.logger.debug( + this.emit( + "debug", `handling autocomplete for command "${interaction.commandName}" option "${focused.name}" with value "${focused.value}"`, ); const choices = await opt.autocomplete(interaction, focused.value); await interaction.respond(choices).catch(() => null); } else { - this.logger.debug( + this.emit( + "debug", `no autocomplete handler configured for option "${focused.name}" in command "${interaction.commandName}"`, ); }@@ -496,7 +519,8 @@
private async routeComponent(interaction: KittenComponentInteraction) { const [prefix] = interaction.customId.split(":"); if (!prefix) { - this.logger.debug( + this.emit( + "debug", `component routing skipped: unable to extract prefix from customId "${interaction.customId}".`, ); return;@@ -504,13 +528,15 @@ }
const component = this.components.get(prefix); if (!component) { - this.logger.debug( + this.emit( + "debug", `component routing skipped: no component handler registered for prefix "${prefix}".`, ); return; } - this.logger.debug( + this.emit( + "debug", `routing component for prefix "${prefix}" (customId:${interaction.customId})`, ); const args = component.parseArgs(interaction.customId);@@ -534,7 +560,7 @@ ): Record<string, any> {
if (!optionsSchema) return {}; const parsed: Record<string, any> = {}; - this.logger.debug(`parsing options for command "${interaction.commandName}"`); + this.emit("debug", `parsing options for command "${interaction.commandName}"`); for (const [name, opt] of Object.entries(optionsSchema)) { switch (opt.type) {@@ -566,7 +592,10 @@ case "attachment":
parsed[name] = interaction.options.getAttachment(name); break; default: - this.logger.debug(`skipping option "${name}" due to unmapped option type: "${opt.type}"`); + this.emit( + "debug", + `skipping option "${name}" due to unmapped option type: "${opt.type}"`, + ); } } return parsed;@@ -656,17 +685,17 @@ if (!this.client.application) {
throw new Error("client application not ready to sync. ensure you await client.login()"); } - this.logger.debug(`preparing to sync ${payloads.length} command(s) to Discord API...`); + this.emit("debug", `preparing to sync ${payloads.length} command(s) to Discord API...`); if (guildId) { await this.client.application.commands.set(payloads, guildId); - this.logger.info(`synced ${payloads.length} commands to guild ${guildId}.`, { + this.emit("info", `synced ${payloads.length} commands to guild ${guildId}.`, { guildId, commandCount: payloads.length, }); } else { await this.client.application.commands.set(payloads); - this.logger.info(`synced ${payloads.length} global commands.`, { + this.emit("info", `synced ${payloads.length} global commands.`, { commandCount: payloads.length, }); }
@@ -1,95 +0,0 @@
-export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal"; - -export interface LogEntry { - level: LogLevel; - msg: string; - time: number; - [key: string]: unknown; -} - -export interface LoggerOptions { - level?: LogLevel; - name?: string; - base?: Record<string, unknown>; - transport?: (entry: LogEntry) => void; -} - -export interface KittenLogger { - trace(msg: string, data?: Record<string, unknown>): void; - debug(msg: string, data?: Record<string, unknown>): void; - info(msg: string, data?: Record<string, unknown>): void; - warn(msg: string, data?: Record<string, unknown>): void; - error(msg: string, data?: Record<string, unknown>): void; - fatal(msg: string, data?: Record<string, unknown>): void; - child(bindings: Record<string, unknown>): KittenLogger; -} - -const LEVELS: Record<LogLevel, number> = { - trace: 10, - debug: 20, - info: 30, - warn: 40, - error: 50, - fatal: 60, -}; - -const defaultTransport = (entry: LogEntry) => { - const out = JSON.stringify(entry); - if (entry.level === "error" || entry.level === "fatal") console.error(out); - else console.log(out); -}; - -export class Logger implements KittenLogger { - private readonly minLevel: number; - private readonly base: Record<string, unknown>; - private readonly transport: (entry: LogEntry) => void; - - constructor(private readonly options: LoggerOptions = {}) { - this.minLevel = LEVELS[options.level ?? "info"]; - this.base = { - ...(options.name ? { name: options.name } : {}), - ...options.base, - }; - this.transport = options.transport ?? defaultTransport; - } - - private write(level: LogLevel, msg: string, data?: Record<string, unknown>) { - if (LEVELS[level] < this.minLevel) return; - this.transport({ - level, - msg, - time: Date.now(), - ...this.base, - ...data, - }); - } - - trace(msg: string, data?: Record<string, unknown>) { - this.write("trace", msg, data); - } - debug(msg: string, data?: Record<string, unknown>) { - this.write("debug", msg, data); - } - info(msg: string, data?: Record<string, unknown>) { - this.write("info", msg, data); - } - warn(msg: string, data?: Record<string, unknown>) { - this.write("warn", msg, data); - } - error(msg: string, data?: Record<string, unknown>) { - this.write("error", msg, data); - } - fatal(msg: string, data?: Record<string, unknown>) { - this.write("fatal", msg, data); - } - - child(bindings: Record<string, unknown>): KittenLogger { - return new Logger({ - ...this.options, - base: { ...this.base, ...bindings }, - }); - } -} - -const baseLogger = new Logger(); -export { baseLogger };