Fourteen SystemsFourteen Systems
← Back to blog
/
architectureauditmulti-tenant

Your SaaS Has No Memory

A customer emails you: "Someone deleted our production project. Who was it? When did it happen?"

You open your database. The project row is gone. Or maybe it's soft-deleted. Either way, there's no record of who did it. No timestamp on the action. No actor. No context. You check your application logs, but they're structured around errors, not actions. You search for the project ID and find nothing useful.

You reply: "We're looking into it."

You are not looking into it. You have nothing to look into.

The feature nobody builds

Audit logging is one of those features that every production SaaS needs and almost no starter kit includes. It's not glamorous. It doesn't show up in demos. Investors don't ask about it. So it gets skipped.

Then a customer on your Team plan asks who changed a setting. Or your own team needs to debug why a subscription downgraded. Or you get a security questionnaire that asks "do you maintain audit trails for privileged actions?" and the honest answer is no.

Most teams bolt on logging after the first incident. By then, the damage is done. Not just technically. The customer already lost trust.

Application logs are not audit logs

This is where teams get confused. They have structured logging. They pipe to Datadog or CloudWatch. They assume that covers it.

Application logs record system behavior. Errors, request durations, stack traces. They answer "what went wrong?"

Audit logs record user behavior. Who did what, to which resource, in which organization, at what time. They answer "what happened?"

These are different questions. Solving one does not solve the other.

If you grep your application logs for "project deleted," you might find a log line. Maybe. If the developer who wrote the delete handler remembered to add one. And if it includes the actor ID, the org context, the target resource, and a timestamp. It almost never does.

What an audit log actually needs

A useful audit log has five properties:

  1. Actor. Which user performed the action. Not "the system." A specific user ID.
  2. Organization scope. Which tenant this happened in. Critical for multi-tenant SaaS.
  3. Action. A typed, consistent identifier. Not freeform strings. Something you can filter and aggregate.
  4. Target. The resource that was affected. Type and ID.
  5. Metadata. Context that varies by action. The old value and the new value. The name of the thing that was created. The role that was changed.

If any of these are missing, the log is incomplete. If the action names are inconsistent, the log is unsearchable. If there's no org scope, the log is useless in a multi-tenant system.

Why most implementations fail

The typical approach is to add logging calls ad hoc:

console.log(`User ${userId} deleted project ${projectId}`);

This fails for predictable reasons:

Inconsistency. Ten developers write ten different log formats. Some include the org. Some don't. Some use past tense. Some use present. Some forget entirely.

No structure. Freeform strings can't be filtered, aggregated, or displayed in a UI. You can't build a "recent activity" feed from console.log.

No guarantee of execution. If the audit call is after the mutation and the server crashes between them, the action happened but the record didn't. The audit log now disagrees with reality.

No retention policy. Logs rotate. Application logs get pruned after 30 days. If the audit data lives in the same stream, it disappears with everything else.

How SaaSCoreX handles it

Every mutation in SaaSCoreX calls the same audit() function immediately after the operation succeeds:

await db.project.delete({
  where: { id: projectId, orgId: session.activeOrgId },
});

await audit({
  actorId: session.user.id,
  orgId: session.activeOrgId,
  action: "resource.delete",
  targetType: "project",
  targetId: projectId,
});

The audit() function writes to a dedicated AuditLog table with typed actions, indexed by org and actor. The action field is a TypeScript union, not a string. Typos fail the build.

The audit log is separate from application logs. It has its own retention. It's queryable through the app UI. Organization owners can see exactly what happened in their workspace, when, and who did it.

export type AuditAction =
  | "org.create"
  | "org.update"
  | "org.delete"
  | "member.invite"
  | "member.remove"
  | "member.change_role"
  | "member.leave"
  | "resource.create"
  | "resource.update"
  | "resource.delete"
  | "billing.subscribe"
  | "billing.cancel"
  | "billing.change_plan"
  | "auth.login"
  | "auth.impersonate"
  | "user.delete";

Every action in that union has a corresponding server action that calls audit(). If you add a new action type, TypeScript forces you to handle it.

The trust question

Audit logging isn't really about compliance. Compliance is a side effect.

It's about trust. When a customer asks "what happened to our data?", you either have an answer or you don't. When your own team needs to debug a billing discrepancy, you either have a trail or you're guessing.

SaaS products that handle other people's data have an obligation to know what happened to it. Not approximately. Not "we think." Precisely.

The pattern

The pattern is simple. Every mutation follows the same shape:

  1. Authenticate
  2. Resolve tenant context
  3. Check permissions
  4. Perform the operation
  5. Record the audit entry

If step 5 is missing, the operation is invisible. It happened, but your system has no memory of it.

Most apps skip step 5 because it feels optional. It's optional until the first time someone asks "who did that?" Then it becomes the most important thing you never built.

SaaSCoreX includes typed audit logging across every mutation, with an org-scoped activity feed, actor attribution, and structured metadata. See the full implementation on the architecture page.

See the full architecture

Every system discussed here is production code inside SaaSCoreX.