Audit Trails for CRUD Apps: Why You Need Them and How to Build Them in Hono + MongoDB

Imagine this scenario:

You’re the lead developer on a sleek new admin panel for a chain of resorts. One morning, the Operations Manager calls in a panic:

“Someone deleted the evening reservation for 50 guests… and we can’t figure out who did it!”

Yikes. 😬

This is exactly the kind of chaos audit trails are designed to prevent. In this post, we’ll explore:


Let’s dive in.


Why Audit CRUD Operations?

CRUD operations—Create, Read, Update, Delete—are the core of most applications. But if you’re only logging “user X did Y,” you’re missing the juicy details:

  • Who changed what?
  • What was the previous value?
  • When did it happen?

Auditing CRUD operations helps you:

  1. Track critical actions – Know exactly who deleted, updated, or created a resource.
  2. Debug faster – Restore previous states using snapshots.
  3. Meet compliance requirements – Many industries (finance, healthcare) demand audit logs.
  4. Detect suspicious activity – Spot anomalies like repeated deletes or strange updates.

Snapshots vs. Deltas

When storing audit logs, there are two main strategies:

Full Snapshots

  • Store the entire object before and/or after the operation.
  • Ideal for DELETE or POST where you care about the whole resource.

Example: Delete an outlet

before: {
  "name": "Sunset Bar",
  "location": "Maldives",
  "status": "active"
}
after: null

Deltas

  • Store only the fields that changed.
  • Ideal for PUT / UPDATE requests.

Example: Update outlet status

before: { "status": "active" }
after: { "status": { "before": "active", "after": "inactive" } }

This makes logs compact and readable while keeping the necessary detail.


Introducing the Audit Middleware

Here’s a Hono + MongoDB middleware we built to handle auditing automatically for CRUD operations.

import type { MiddlewareHandler } from "hono";
import Audit from "../models/AppAudit.js";
import type { ISessionContext } from "../types/Sessions.js";
import type { AuditOptions } from "../types/Audit.js";

export const auditMiddleware = (
  options: AuditOptions = {},
): MiddlewareHandler => {
  const { action: customAction, saveBody = true, model: ModelProvided, isCrud = true } = options;

  return async (c: ISessionContext, next: () => Promise<void>) => {
    const method = c.req.method;
    const pathSegments = c.req.path.split("/").filter(Boolean);
    const resourceType = pathSegments[0] || "unknown";
    const resourceId = c.req.param("id") || null;
    const user = c.get("user")?._id ?? null;

    const CRITICAL_METHODS = ["DELETE", "POST"];
    const isCritical = CRITICAL_METHODS.includes(method.toUpperCase());

    let before: any = null;
    if (isCrud && isCritical && resourceId) {
      try {
        const Model =
          ModelProvided ||
          (await import(`../models/${capitalize(resourceType)}.js`)).default;
        before = await Model.findById(resourceId).lean();
      } catch (err) {
        console.warn(`Audit: failed to fetch snapshot for ${resourceType} ${resourceId}`, err);
      }
    }

    await next(); // execute route handler

    let reqBody: any = null;
    if (saveBody) {
      try {
        reqBody = await c.req.json();
        const forbiddenKeys = ["password", "token", "apiKey", "secret"];
        const sanitize = (obj: any): any => {
          if (Array.isArray(obj)) return obj.map(sanitize);
          if (obj && typeof obj === "object") {
            return Object.fromEntries(
              Object.entries(obj)
                .filter(([key]) => !forbiddenKeys.includes(key.toLowerCase()))
                .map(([key, value]) => [key, sanitize(value)]),
            );
          }
          return obj;
        };
        reqBody = sanitize(reqBody);
      } catch (_) {
        reqBody = null;
      }
    }

    let after: any = null;
    if (isCrud) {
      if (isCritical) {
        after = reqBody || null;
      } else if (before && reqBody) {
        after = {};
        for (const key of Object.keys(reqBody)) {
          if (before[key] !== reqBody[key]) {
            after[key] = { before: before[key], after: reqBody[key] };
          }
        }
        if (Object.keys(after).length === 0) after = null;
      } else {
        after = reqBody || null;
      }
    } else {
      after = reqBody || null;
    }

    const action =
      customAction || (isCrud ? `${method.toLowerCase()}${capitalize(resourceType)}` : "custom");

    try {
      await Audit.create({
        action,
        resourceType,
        resourceId,
        user,
        isCrud,
        timestamp: new Date(),
        before,
        after,
      });
    } catch (err) {
      console.error("Audit save failed", err);
    }
  };
};

function capitalize(str: string) {
  return str.charAt(0).toUpperCase() + str.slice(1);
}

Middleware Breakdown

  • Options & Defaults – Customize the action name, saveBody, model, and whether it’s a CRUD operation.
  • Identify Resource & User – Extract resource type, ID, and current user:
const user = c.get("user")?._id ?? null;

Note: This assumes that the request context (c) includes authentication details. For authenticated routes, the user variable should be present in the context. If the route is unauthenticated, user will be null.

  • Determine Critical Operations – DELETE always critical, POST optionally critical.
  • Before Snapshot – Captures document from MongoDB before critical actions.
  • Execute Route Handler – Proceed with the database operation.
  • Request Body Capture & Sanitization – Avoid logging sensitive fields.
  • After Snapshot / Delta – Full snapshot for critical, delta for updates.
  • Action Name – Defaults to method + resourceType.
  • Save Audit Log – Stores everything in MongoDB’s Audit collection.

Example Routes

CRUD Example: Outlets

import { Hono } from "hono";
import { auditMiddleware } from "./middleware/audit.js";
import Outlet from "./models/Outlet.js";

const app = new Hono();

// Create Outlet (full snapshot)
app.post("/outlets", auditMiddleware(), async (c) => {
  const body = await c.req.json();
  const outlet = await Outlet.create(body);
  return c.json(outlet);
});

// Update Outlet (delta)
app.put("/outlets/:id", auditMiddleware(), async (c) => {
  const id = c.req.param("id");
  const body = await c.req.json();
  const outlet = await Outlet.findByIdAndUpdate(id, body, { new: true });
  return c.json(outlet);
});

// Delete Outlet (full snapshot)
app.delete("/outlets/:id", auditMiddleware(), async (c) => {
  const id = c.req.param("id");
  await Outlet.findByIdAndDelete(id);
  return c.json({ success: true });
});

Non-CRUD Example: User Login

app.post("/login", auditMiddleware({ isCrud: false, action: "userLogin" }), async (c) => {
  const { email, password } = await c.req.json();
  const user = await User.findOne({ email });
  return c.json({ success: !!user });
});

Example Audit Documents

Full Snapshot (POST / DELETE)

{
  "action": "postOutlets",
  "resourceType": "outlets",
  "resourceId": "653ab123cd45ef6789012345",
  "user": "64fa123bcdef456789012345",
  "isCrud": true,
  "timestamp": "2025-10-18T00:00:00.000Z",
  "before": null,
  "after": {
    "name": "Sunset Bar",
    "location": "Maldives",
    "status": "active"
  }
}

Delta (PUT / UPDATE)

{
  "action": "putOutlets",
  "resourceType": "outlets",
  "resourceId": "653ab123cd45ef6789012345",
  "user": "64fa123bcdef456789012345",
  "isCrud": true,
  "timestamp": "2025-10-18T01:00:00.000Z",
  "before": {
    "name": "Sunset Bar",
    "location": "Maldives",
    "status": "active"
  },
  "after": {
    "status": { "before": "active", "after": "inactive" }
  }
}

Non-CRUD Action (Login)

{
  "action": "userLogin",
  "resourceType": "unknown",
  "resourceId": null,
  "user": null,
  "isCrud": false,
  "timestamp": "2025-10-18T02:00:00.000Z",
  "before": null,
  "after": { "email": "[email protected]" }
}

Best Practices

  1. Indexing – Index resourceType, resourceId, and timestamp for fast lookups.
  2. Archive Old Logs – Move old audits to another collection or storage to avoid bloating.
  3. Sanitize Sensitive Data – Never store passwords, tokens, or secrets.
  4. Handle Large Payloads – Consider truncating or summarizing extremely large bodies.
  5. Attach Middleware Selectively – You don’t need auditing on GET routes unless needed.

Visualizing Snapshots vs Deltas

[PUT /outlets/:id]
Before: { name: "Sunset Bar", status: "active" }
After:  { name: "Sunset Bar", status: "inactive" }

Audit Delta:
{ status: { before: "active", after: "inactive" } }
[DELETE /outlets/:id]
Before: { name: "Sunset Bar", status: "active" }
After:  null

Audit Log: full snapshot

Conclusion

Auditing CRUD operations is not optional if you care about:

  • Data integrity
  • Debugging & troubleshooting
  • Security & compliance

With this middleware, your Hono + MongoDB apps gain transparent, reliable audit trails with minimal effort. You can track every change, recover from mistakes, and even detect suspicious behavior.

Next time someone panics about a missing record, you can calmly say:

“No worries, we’ve got the audit logs!” 😎

Popular posts from this blog

Zapping Through Multicast Madness: A Fun Python Script to Keep Your IPTV Streams Rocking!

Turning a Joke into Innovation: AI Integration in our Daily Task Manager