Building an Audit Log That Doesn't Suck: AOP, Pointcuts, and Meaningful Events
How we used Spring AOP to auto-log every admin action — and why we had to exclude read-only endpoints to avoid noise.
Every admin action at hrva.cc is automatically logged to the audit trail. Here's how we built it with Spring AOP, what went wrong on the first attempt, and the final design that captures meaningful events without noise.
Why an Audit Log?
When multiple admins manage a system, you need to know who did what. Did an admin delete a URL or did the scheduler deactivate it? Was a user account modified by someone or by the inactivity cleanup? An audit log answers these questions. It's also essential for compliance, debugging, and security investigations.
First Attempt: Wildcard Pointcut
Our first version used a broad pointcut: execution(* AdminController.*(..)).
This intercepted every method on the admin controller — including read-only GET endpoints
like getDashboardStats() and getLoginAttempts(). Within an hour,
the audit log was flooded with "getDashboardStats" entries from the dashboard's auto-refresh
(it polls every 30 seconds). The meaningful events were buried in noise.
Second Attempt: Explicit Mutations Only
We narrowed the pointcut to only match methods that actually change state. The final version
explicitly lists every mutating method across all admin-relevant controllers:
clearLoginAttempts, deleteUser, updateUser,
revokeUrl, activateUrl, deleteUrl, updateUrl.
Read-only endpoints are excluded entirely.
The AOP Aspect
The AdminAuditAspect uses @Around advice. Before the method executes,
it captures the user's email, method name, target type (extracted from the controller class name),
and method arguments. The method proceeds normally, and on success the audit entry is saved.
If the method throws, a separate entry is logged with "FAILED" appended to the action name
and the exception message as details.
The Audit Log Table
The Liquibase migration creates an audit_log table with: performedBy
(admin email), action (method name), targetType (controller name),
targetIdentifier (first argument, usually the ID), details
(serialized arguments), and performedAt (auto-set via @PrePersist).
The admin UI at /admin/audit-log shows all entries in a paginated table with
color-coded action badges — red for deletions, amber for updates, green for activations.