Overview
Audit logging captures every data mutation with full context - who performed the action, what changed, and when. This enables customer compliance, support investigations, and platform accountability.
What it is
An append-only record of all create, update, and delete operations across the platform, stored in the AuditLog table.
Why we use it
Customer compliance requirements, support investigations, security monitoring, and AI agent accountability.
When to use
Every mutation in a repository function must include an audit.log() call with the Actor who performed it.
Key Features
- Automatic actor tracking with PII masking (J. Smith instead of John Smith)
- Scope-aware logging (platform, program, project)
- TypeScript-enforced - functions require Actor parameter, won't compile without it
- ESLint blocks direct Prisma imports outside repositories
Quick Start
Adding Audit to a Repository
Every repository mutation follows this pattern: perform operation, then log to audit trail.
// Adding audit logging to a repository function
import { prisma } from '@/lib/db';
import { audit, type Actor } from '@/lib/audit';
export async function createProject(
data: CreateProjectInput,
actor: Actor
): Promise<Project> {
const project = await prisma.project.create({ data });
await audit.log({
action: 'project.create',
category: 'DATA',
actorId: actor.id,
actorDisplayHint: actor.displayHint,
actorType: actor.type,
scopeType: 'PROGRAM',
scopeId: project.programId,
targetType: 'Project',
targetId: project.id,
targetDisplayHint: project.name,
});
return project;
}Patterns
The Actor Pattern
Create actors from users, agents, or system operations.
// Creating actors from different sources
import { toActor, agentActor, SYSTEM_ACTOR } from '@/lib/audit';
// From authenticated user (most common)
const actor = toActor({
id: session.user.id,
name: session.user.name
});
// Result: { id: "usr_123", displayHint: "J. Smith", type: "USER" }
// From AI agent
const actor = agentActor(agentId, projectName);
// Result: { id: "agent_123", displayHint: "Agent: Project X", type: "AGENT" }
// For system operations (cron jobs, migrations)
const actor = SYSTEM_ACTOR;
// Result: { id: "system", displayHint: "System", type: "SYSTEM" }Cascade Delete
Audit parent and all children when deleting - database cascades are invisible.
// Handling cascade deletes with audit trail
export async function deleteProject(id: string, actor: Actor): Promise<void> {
const project = await prisma.project.findUnique({
where: { id },
include: { resources: { select: { id: true, title: true } } },
});
// Soft delete children first
await prisma.resource.updateMany({
where: { projectId: id },
data: { deletedAt: new Date() },
});
// Audit each child deletion
for (const resource of project.resources) {
await audit.log({
action: 'resource.delete',
category: 'DATA',
actorId: actor.id,
actorDisplayHint: actor.displayHint,
actorType: actor.type,
scopeType: 'PROJECT',
scopeId: id,
targetType: 'Resource',
targetId: resource.id,
targetDisplayHint: resource.title,
metadata: { reason: 'parent_project_deleted' },
});
}
// Soft delete parent and audit
await prisma.project.update({
where: { id },
data: { deletedAt: new Date() },
});
await audit.log({
action: 'project.delete',
category: 'DATA',
actorId: actor.id,
actorDisplayHint: actor.displayHint,
actorType: actor.type,
scopeType: 'PROGRAM',
scopeId: project.programId,
targetType: 'Project',
targetId: id,
targetDisplayHint: project.name,
metadata: {
childrenDeleted: { resources: project.resources.length },
},
});
}Bulk Operations
Use audit.logMany() for efficient batch logging of multiple operations.
// Efficient batch logging with audit.logMany()
import { audit } from '@/lib/audit';
await audit.logMany(
items.map((item) => ({
action: 'item.create',
category: 'DATA',
actorId: actor.id,
actorDisplayHint: actor.displayHint,
actorType: actor.type,
scopeType: 'PROJECT',
scopeId: projectId,
targetType: 'Item',
targetId: item.id,
targetDisplayHint: item.name,
}))
);Watch Out
Never import prisma directly outside repositories - bypasses audit trail
Don't
// Direct Prisma import (bypasses audit)
import { prisma } from '@/lib/db';
// This mutation is invisible to audit trail!
await prisma.project.update({
where: { id },
data: { name: 'New Name' },
});Do
// Through repository (audited)
import * as repository from './repository';
import { toActor } from '@/lib/audit';
const actor = toActor({
id: session.user.id,
name: session.user.name
});
await repository.updateProject(id, { name: 'New Name' }, actor);Actor parameter is required - TypeScript will error without it
Don't
// Missing actor parameter
export async function updateProject(id: string, data: UpdateInput) {
return prisma.project.update({ where: { id }, data });
// No audit - who did this?
}Do
// Actor parameter required
export async function updateProject(
id: string,
data: UpdateInput,
actor: Actor
) {
const result = await prisma.project.update({ where: { id }, data });
await audit.log({
action: 'project.update',
actorId: actor.id,
actorDisplayHint: actor.displayHint,
actorType: actor.type,
// ... rest of audit entry
});
return result;
}- Never log full names or emails - use toActor() helper for automatic masking
- Database cascades are invisible - handle deletes explicitly in repository code