Cómo implementar audit logs útiles en una app Next.js con Prisma: schema, helper, retención y errores comunes.
Los audit logs son el registro de quién hizo qué y cuándo en tu app. Suenan aburridos hasta el día que un cliente B2B te pregunta "¿quién borró mi proyecto?" o un investigador de seguridad detecta un acceso sospechoso. Sin audit log: no tienes respuesta. Con audit log: ya sabes.
Esta guía explica cómo implementar audit logs en una app Next.js con Prisma de forma que sea útil en producción y no se convierta en una tabla que crece sin control.
No registres todo. Eso satura la DB y mata el rendimiento. Registra:
NO registres:
model AuditLog {
id String @id @default(cuid())
createdAt DateTime @default(now())
userId String?
organizationId String?
action String // p.ej. 'user.banned', 'project.deleted'
resource String? // p.ej. 'project:abc-123'
metadata Json? // datos adicionales
ip String?
userAgent String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull)
@@index([organizationId, createdAt])
@@index([userId, createdAt])
@@index([action, createdAt])
}onDelete: SetNull es importante: si el usuario se borra, el log queda. El audit log debe
sobrevivir al borrado de los actores.
src/lib/audit/log.ts:
import { db } from '@/lib/db/client';
import { headers } from 'next/headers';
export async function logAudit(params: {
userId?: string;
organizationId?: string;
action: string;
resource?: string;
metadata?: Record<string, unknown>;
}) {
const h = await headers();
const ip = h.get('x-forwarded-for') ?? h.get('x-real-ip') ?? null;
const userAgent = h.get('user-agent') ?? null;
await db.auditLog.create({
data: {
...params,
metadata: params.metadata as never,
ip,
userAgent,
},
});
}Y se usa así:
await logAudit({
userId: session.user.id,
organizationId: orgId,
action: 'project.deleted',
resource: `project:${projectId}`,
metadata: { projectName: project.name },
});Usa siempre recurso.verbo:
user.created, user.banned, user.deletedproject.created, project.deleted, project.transferredsubscription.created, subscription.canceledauth.login.success, auth.login.failedCoherencia desde el día 1. Si cada dev nombra a su gusto, en 6 meses no encuentras nada.
El audit log NO debe bloquear la operación principal. Si el log falla, la operación sigue.
Mal:
await db.project.delete({ where: { id } });
await logAudit({ action: 'project.deleted', resource: `project:${id}` });Si logAudit falla, lanza excepción y la respuesta al cliente es error → pero el proyecto YA se
borró. Cliente confundido.
Bien:
await db.project.delete({ where: { id } });
logAudit({ action: 'project.deleted', resource: `project:${id}` }).catch(console.error);O mejor: cola asíncrona (Inngest, BullMQ) para audit logs.
'use client';
export function AuditLogTable({ logs }: { logs: AuditLog[] }) {
return (
<table>
<thead>
<tr>
<th>Fecha</th>
<th>Usuario</th>
<th>Acción</th>
<th>Recurso</th>
<th>IP</th>
</tr>
</thead>
<tbody>
{logs.map((log) => (
<tr key={log.id}>
<td>{log.createdAt.toLocaleString()}</td>
<td>{log.user?.email ?? 'system'}</td>
<td>
<code>{log.action}</code>
</td>
<td>{log.resource}</td>
<td>{log.ip}</td>
</tr>
))}
</tbody>
</table>
);
}Con filtros por usuario, fecha, acción y org.
Los logs crecen. Decide cuánto guardas:
Job programado que borra logs viejos:
await db.auditLog.deleteMany({
where: { createdAt: { lt: subDays(new Date(), 365) } },
});Si necesitas guardar todo por compliance, exporta a S3 antes de borrar.
1. Loggear contraseñas o tokens: nunca metas en metadata campos sensibles. Filtra antes de
loggear.
2. Tabla sin índices: en producción con millones de filas, una query sin índice tarda minutos.
Indexa por organizationId, createdAt.
3. Loggear desde middlewares ruidosos: si loggeas cada request, en 1 mes tienes una tabla de millones de filas inútiles. Selectivo.
Audit logs son 1-2 horas de implementación si lo haces desde el principio, o un dolor enorme si lo añades cuando ya hay datos en producción. Vale la pena hacerlo el día 1.
Y cuando un cliente pregunte "¿quién hizo X?", tendrás respuesta. Eso, en B2B, es oro.
Suscríbete para más tutoriales y tips sobre crear productos con IA
La integración real de Tailwind v4 + shadcn 2.3.0 en Next.js 16 App Router. OKLCH, dark mode sin FOUC, Geist con next/font y el bug silencioso de hsl(var()) que rompe tus estilos sin avisar.