The Activity log provides a chronological record of security, authentication, account management, and administrative events across the application. It serves as the primary audit trail for administrators and helps answer three questions.
Such audit logs are a foundational compliance and security requirement for any SaaS product. They surface suspicious activity, satisfy enterprise procurement reviews, and provide an irrefutable record when something goes wrong. Oolvay comes preconfigured with fifteen audit events covering sign-in activity, session management, passkey lifecycle, role changes, API key operations, and administrative actions. Every event is stored in the database, rendered through a paginated interface, and can be filtered by event type or date range.
Each audit record contains four core pieces of information.
| Field | Purpose |
|---|---|
| Actor | The user who performed the action |
| Event | The action that occurred |
| Metadata | Structured context associated with the action |
| Timestamp | When the event was recorded |
If the actor or target account is later deleted, the audit record remains intact. Foreign key references are set to NULL, preserving historical audit data while allowing account deletion.
The table below lists events currently rendered by the Activity interface. Additional event types may exist in lib/audit/events.ts and can be surfaced by registering labels, styles, and metadata formatters in the activity table component.
| Event Key | Label | Example Detail |
|---|---|---|
user_signed_in | Signed In | From 182.48.221.193 |
user_signed_up | Signed Up | Joined as user@example.com |
user_signed_out | Signed Out | Signed out |
member_role_updated | Role Updated | Changed Jane from admin to user |
user_deleted | User Deleted | Deleted Jane (jane@example.com) |
0 / 2,000 characters
failed_login_attempt| Failed Login |
| Attempted email x@y.com (IP 1.2.3.4) |
api_key_created | API Key Created | Created API key |
api_key_revoked | API Key Revoked | Revoked API key |
sessions_revoked_all | Sessions Revoked | Logged out from all devices |
passkey_registered | Passkey Added | Registered passkey MacBook Touch ID |
passkey_removed | Passkey Removed | Removed passkey MacBook Touch ID |
user_data_exported | Data Exported | Exported personal data |
account_unlinked | Account Unlinked | Disconnected github |
user_suspended | Suspended | Suspended Jane for 7 days |
user_unsuspended | Unsuspended | Unsuspended Jane |
The activity interface only displays events that have corresponding labels, badge styles, and metadata formatters registered in app/(protected)/admin/activity/components/activity-table.tsx.
Administrators can narrow results without leaving the page.
The event filter dropdown updates the event query parameter and re-runs the server query with the selected event type.
Selecting “All events” removes the filter and displays the complete audit history.
The date range picker updates the from and to query parameters.
The ending date is treated as inclusive and is normalized to the final millisecond of the selected day before the query executes.
Results are paginated using usersPageSize from siteConfig.
pagination: {
admin: {
usersPageSize: 10,
},
},Changing filters automatically resets pagination to page 1 to prevent invalid page numbers after result counts change.
Audit records are stored in the audit_log table.
export const auditLog = pgTable(
"audit_log",
{
id: text("id").primaryKey(),
actorUserId: text("actor_user_id").references(() => user.id, {
onDelete: "set null",
}),
targetUserId: text("target_user_id").references(() => user.id, {
onDelete: "set null",
}),
event: text("event").notNull(),
metadata: t.jsonb("metadata"),
createdAt: timestamp("created_at").defaultNow().notNull(),
expiresAt: timestamp("expires_at").notNull(),
},
(table) => [
index("audit_log_actor_user_id_idx").on(table.actorUserId),
index("audit_log_target_user_id_idx").on(table.targetUserId),
index("audit_log_event_idx").on(table.event),
index("audit_log_createdAt_idx").on(table.createdAt),
index("audit_log_expiresAt_idx").on(table.expiresAt),
]
)Both actorUserId and targetUserId use foreign keys configured with onDelete: "set null".
This allows audit records to survive account deletion while preventing orphaned references.
The expiresAt column stores the intended retention deadline for an event.
The schema itself does not automatically remove records. Cleanup is expected to be performed by a scheduled task or maintenance job.
Audit logging is built around two shared components.
| Component | Responsibility |
|---|---|
lib/audit/events.ts | Canonical event registry |
lib/audit/log-event.ts | Audit record creation |
Application features emit events through logEvent(), which writes structured records into the audit_log table. It lives in lib/ rather than actions/ because it is a server-side utility called by other server code, not a user-initiated operation invoked from the client. The Activity page reads and renders those records through a paginated administrative interface.
Every audit record is created by calling logEvent() after an operation succeeds. All call sites are in actions/ except for lib/auth/auth.ts and lib/auth/hooks/failed-login.ts, which handle auth lifecycle events that fire inside Better Auth before any server action is involved.
export async function logEvent({
actorUserId = null,
targetUserId = null,
event,
metadata = null,
}: LogEventParams) {
const retentionMs = siteConfig.auditLogs.retentionDays * 24 * 60 * 60 * 1000
return db.insert(auditLog).values({
id: crypto.randomUUID(),
actorUserId,
targetUserId,
event,
metadata,
createdAt: new Date(),
expiresAt: new Date(Date.now() + retentionMs),
})
}expiresAt is computed from retentionDays at call time, so changing the value takes effect immediately for all new records without a schema migration.
auditLogs: {
retentionDays: 90,
activityFeedLimit: 10,
},The example below is taken from actions/suspend-user.ts. It runs after Better Auth bans the user and revokes their sessions, so the audit record is only written on a successful outcome.
await logEvent({
actorUserId: currentUser.id,
targetUserId: userId,
event: AUDIT_EVENTS.USER_SUSPENDED,
metadata: {
suspendedUserId: userId,
suspendedUserName: targetUser.name,
suspendedUserEmail: targetUser.email,
suspendedUserRole: targetUser.role,
duration,
reason: reason ?? null,
banExpires: banExpires?.toISOString() ?? null,
},
})An administrator visits /admin/activity. Filters and pagination are driven
by URL search parameters (page, event, from, to). When no filters
are active the URL remains clean; selecting an event or date range updates
it to something like /admin/activity?event=user_signed_in&from=2026-06-01.
getServerSession() retrieves the current session. If no session exists,
the user is redirected to /login.
isAdmin() checks the session role. If the check fails, Next.js throws an
unauthorized() response.
Query parameters are parsed. page is clamped to a minimum of 1. The to
date is normalized to 23:59:59.999 so the final day is included in
results.
getActivity() runs two Drizzle queries in parallel via Promise.all. The
first retrieves the paginated page of audit records with left joins on both
actorUser and targetUser. The second counts total matching rows to
compute totalPages.
Results are passed to ActivitySection, which renders the toolbar and
table. Both actorUser and targetUser are nullable. The joins are left
joins, so deleted accounts do not break the query or the rendered row.
Add a new constant to the AUDIT_EVENTS object in lib/audit/events.ts.
export const AUDIT_EVENTS = {
// existing events...
YOUR_NEW_EVENT: "your_new_event",
} as constUsing a shared constant rather than a raw string literal ensures the event key stays consistent across the server action, the audit table, and the toolbar filter.
Call logEvent() at the end of the server action, after the operation has succeeded.
await logEvent({
actorUserId: currentUser.id,
targetUserId: userId, // omit if no target user
event: AUDIT_EVENTS.YOUR_NEW_EVENT,
metadata: {
// include only what parseDetails() will need to render a description
},
})Register a badge style in EVENT_STYLES and a display label in EVENT_LABELS inside activity-table.tsx.
const EVENT_STYLES: Record<string, string> = {
// existing entries...
your_new_event: "bg-violet-100 text-violet-700 hover:bg-violet-100",
}
const EVENT_LABELS:
Add a case to parseDetails() in the same file so the metadata renders as a human-readable description in the Details column.
case "your_new_event":
return `Did something to ${metadata.targetName as string}`Optionally add the event to EVENT_TYPES in activity-toolbar.tsx if administrators should be able to filter by it.
const EVENT_TYPES = [
// existing entries...
{ value: "your_new_event", label: "Your Label" },
]The Activity page surfaces the new event automatically once the record is written to the database and the display configuration is registered.
Audit logs may contain personally identifiable information such as names, email addresses, and IP addresses. This is intentional. A useful audit trail requires enough context to identify who did what. Treat the audit_log table with the same access controls as your user table, and ensure your retentionDays value in siteConfig reflects your privacy policy and any applicable regulatory requirements.