Audit logging
Introduction
Section titled “Introduction”Every moderation team needs an answer to “who did what, when, and to whom?” Whether it’s for accountability, debugging a disputed ban, or reviewing a moderator’s track record, a structured audit trail is essential.
@warden/audit provides a clean, adapter-based audit logging system. Drop a @audit() decorator on any handler to log it automatically, or use the AuditLog injectable for manual recording. Logs can be stored in a database, written to files, posted to a Discord channel, or all three simultaneously.
Installation
Section titled “Installation”pnpm add @warden/auditRegister the plugin in your bootstrap file:
import {Bot} from '@warden/core';import {AuditServiceProvider} from '@warden/audit';
new Bot() .discover(d => d .interactions('./src/{commands,buttons,modals,menus}/**/*.ts') .events('./src/events/**/*.ts')) .plugins([AuditServiceProvider]) .intents(i => i.defaults()) .partials(p => p.defaults()) .start();By default, the plugin logs to a rotating file (FileAdapter). Database storage and channel posts are opt-in — configure them in config/audit.ts.
Adapters
Section titled “Adapters”The plugin supports multiple output adapters, and you can enable several at once. Your audit entries can flow to multiple destinations simultaneously.
import {Config, env} from '@warden/core';import {AuditServiceProvider, ChannelAdapter, FileAdapter} from '@warden/audit';
export default Config.define(AuditServiceProvider, { adapters: [ new FileAdapter(), new ChannelAdapter(env.required('AUDIT_CHANNEL_ID')), ], database: true, // persist to warden_audit_log via drizzle});| Adapter | How to enable | Requirements |
|---|---|---|
FileAdapter | Listed in adapters (default) | Write access to the log directory |
ChannelAdapter | Listed in adapters with a channel ID | Discord channel ID |
| Database (drizzle) | Set database: true on config | @warden/drizzle registered + audit migration applied |
Enabling database storage
Section titled “Enabling database storage”Database storage is configured via the top-level database: true flag rather than a hand-constructed DatabaseAdapter. When enabled, AuditServiceProvider.boot() resolves your configured drizzle dialect, loads the matching schema (MySQL or SQLite), and wires a DatabaseAdapter automatically.
Before flipping the flag, make sure the warden_audit_log table
exists. warden make:migration auto-includes @warden/audit’s schema,
so a single run covers both your app tables and the audit log:
pnpm warden make:migration add_auditpnpm warden db:migrateThe starter ships with an initial migration that already includes the
audit table — if you’re scaffolding fresh, pnpm db:migrate is all
you need.
The plugin assumes the warden_audit_log table exists once database: true is set — no runtime CREATE TABLE.
The @audit() decorator
Section titled “The @audit() decorator”The fastest way to add audit logging is the @audit() decorator. Place it on any command, button, modal, context menu, or event handler, and Warden records an entry every time it executes:
import {command, permissions} from '@warden/core';import {audit} from '@warden/audit';
@command({parent: 'mod', name: 'warn', description: 'Warn a user'})@permissions({user: ['ModerateMembers']})@audit()export default class ModWarnCommand extends Subcommand { public async execute(interaction: CommandInteraction, params: Params): Promise<void> { // The audit entry is created automatically when this handler runs }}Without any options, @audit() records the handler name, the actor (who triggered it), the guild, and a timestamp. That’s often enough for simple tracking, but you can be much more specific.
Decorator options
Section titled “Decorator options”For richer audit entries, pass options to @audit():
@command({parent: 'mod', name: 'ban', description: 'Ban a user'})@permissions({user: ['BanMembers'], bot: ['BanMembers']})@audit({ action: 'moderation.ban', trackTarget: 'user', trackResult: true,})export default class ModBanCommand extends Subcommand { public async execute(interaction: CommandInteraction, params: Params): Promise<void> { const target = interaction.options.getUser('user')!; const reason = interaction.options.getString('reason')!;
await interaction.guild!.members.ban(target, {reason});
await interaction.followUp({embeds: [ Embed.success(`${target.username} has been banned.`) .addFields({name: 'Reason', value: reason}) .setTimestamp() ]}); }}| Option | Type | Default | Description |
|---|---|---|---|
action | string | Handler name | A structured action name (e.g. 'moderation.ban') |
trackTarget | 'user' | 'channel' | 'role' | 'message' | — | Automatically extract and record the target from interaction options |
trackResult | boolean | false | Record whether the handler completed successfully or threw an error |
metadata | Record<string, unknown> | — | Static metadata to include with every entry |
The trackTarget option is particularly convenient. When set to 'user', the decorator automatically looks for a user option on the interaction and records the target user’s ID in the audit entry — no manual extraction needed.
When trackResult is enabled, the audit entry includes a result field set to 'success' or 'error', along with the error message if one was thrown. The error still propagates normally to your error handler.
The AuditLog injectable
Section titled “The AuditLog injectable”For situations where the decorator isn’t enough — manual logging, conditional entries, or logging from services that aren’t handlers — inject the AuditLog service:
import {AuditLog} from '@warden/audit';
@command({parent: 'mod', name: 'mute', description: 'Mute a user'})@permissions({user: ['ModerateMembers']})export default class ModMuteCommand extends Subcommand { constructor(private auditLog: AuditLog, private muteService: MuteService) {}
public async execute(interaction: CommandInteraction, params: Params): Promise<void> { const target = interaction.options.getUser('user')!; const duration = interaction.options.getInteger('duration')!; const reason = interaction.options.getString('reason')!;
await this.muteService.mute(interaction.guildId!, target.id, duration);
await this.auditLog.log({ action: 'moderation.mute', actor: interaction.user.id, target: target.id, guild: interaction.guildId!, metadata: { duration, reason, expiresAt: new Date(Date.now() + duration * 60 * 1000).toISOString(), }, });
await interaction.followUp({embeds: [ Embed.success(`${target.username} has been muted for ${duration} minutes.`) .addFields({name: 'Reason', value: reason}) ]}); }}The log() method accepts:
| Field | Type | Required | Description |
|---|---|---|---|
action | string | Yes | Structured action name |
actor | string | Yes | User ID of the person who performed the action |
guild | string | Yes | Guild ID where the action took place |
target | string | No | User/channel/role ID that was acted upon |
metadata | Record<string, unknown> | No | Arbitrary data to store with the entry |
Querying the audit trail
Section titled “Querying the audit trail”The AuditLog injectable also provides a fluent query builder for searching the audit trail. This is backed by the database-backed DatabaseAdapter, so it requires database: true on the audit config and @warden/drizzle registered.
const entries = await this.auditLog .query() .actor(userId) .action('moderation.*') .since(days(7)) .limit(25) .get();The query builder supports the following methods:
auditLog.query() .actor(userId) // filter by who performed the action .target(targetId) // filter by who/what was acted upon .action('moderation.ban') // exact action match .action('moderation.*') // wildcard action match .guild(guildId) // filter by guild .since(days(7)) // entries from the last 7 days .before(new Date('2025-01-01')) // entries before a date .after(new Date('2024-12-01')) // entries after a date .limit(25) // maximum results .offset(50) // skip the first N results (for pagination) .get(); // execute and return resultsEach returned entry has the following shape:
interface AuditEntry { id: string; action: string; actor: string; target?: string; guild: string; metadata?: Record<string, unknown>; result?: 'success' | 'error'; error?: string; createdAt: Date;}Retention
Section titled “Retention”By default, audit entries are kept indefinitely. If you want automatic cleanup of old entries, enable retention in your config:
export default { channel: env('AUDIT_CHANNEL_ID'), retention: 90, // delete entries older than 90 days};Set retention to a number of days, or false to keep everything forever. When retention is enabled, the plugin runs a cleanup task daily (using @warden/jobs if available, or a simple interval otherwise).
Practical examples
Section titled “Practical examples”Audit all moderation commands
Section titled “Audit all moderation commands”Decorate every mod command with @audit() for a comprehensive trail:
@command({parent: 'mod', name: 'warn', description: 'Warn a user'})@permissions({user: ['ModerateMembers']})@audit({action: 'moderation.warn', trackTarget: 'user', trackResult: true})export default class ModWarnCommand extends Subcommand { ... }
@command({parent: 'mod', name: 'mute', description: 'Mute a user'})@permissions({user: ['ModerateMembers']})@audit({action: 'moderation.mute', trackTarget: 'user', trackResult: true})export default class ModMuteCommand extends Subcommand { ... }
@command({parent: 'mod', name: 'ban', description: 'Ban a user'})@permissions({user: ['BanMembers'], bot: ['BanMembers']})@audit({action: 'moderation.ban', trackTarget: 'user', trackResult: true})export default class ModBanCommand extends Subcommand { ... }
@command({parent: 'mod', name: 'config', description: 'Configure moderation'})@permissions({user: ['Administrator']})@audit({action: 'moderation.config', trackResult: true})export default class ModConfigCommand extends Subcommand { ... }Querying a moderator’s activity
Section titled “Querying a moderator’s activity”Build a command that lets admins review what a specific moderator has done recently:
@command({parent: 'mod', name: 'review', description: 'Review a moderator\'s recent actions'})@permissions({user: ['Administrator']})@gate(['audit.view'])export default class ModReviewCommand extends Subcommand { constructor(private auditLog: AuditLog) {}
public options = [ Options.user('moderator', 'Moderator to review').required(), Options.integer('days', 'Number of days to look back').min(1).max(90), ];
public async execute(interaction: CommandInteraction, params: Params): Promise<void> { const moderator = interaction.options.getUser('moderator')!; const lookback = interaction.options.getInteger('days') ?? 7;
const entries = await this.auditLog .query() .actor(moderator.id) .guild(interaction.guildId!) .action('moderation.*') .since(days(lookback)) .limit(25) .get();
if (entries.length === 0) { await interaction.followUp({embeds: [ Embed.info(`${moderator.username} has no recorded moderation actions in the last ${lookback} days.`) ]}); return; }
const embed = Embed.info(`Audit trail for ${moderator.username} (last ${lookback} days)`) .setDescription( entries.map(e => `**${e.action}** — <t:${Math.floor(e.createdAt.getTime() / 1000)}:R>${e.target ? ` — target: <@${e.target}>` : ''}` ).join('\n') ) .setFooter({text: `${entries.length} entries found`}) .setTimestamp();
await interaction.followUp({embeds: [embed]}); }}Manual logging from a service
Section titled “Manual logging from a service”You’re not limited to logging from handlers. Services can inject AuditLog too:
import {singleton} from 'tsyringe';import {AuditLog} from '@warden/audit';
@singleton()export class AutoModService { constructor(private auditLog: AuditLog) {}
public async handleBannedWord(message: Message): Promise<void> { await message.delete();
await this.auditLog.log({ action: 'automod.banned-word', actor: message.author.id, // the user who triggered auto-mod guild: message.guildId!, metadata: { content: message.content, channel: message.channelId, triggeredBy: 'word-filter', }, }); }}This gives you a unified audit trail that covers both manual moderation actions and automated ones — all queryable through the same API.