Every B2B SaaS eventually gets this message from a customer: "Someone changed
this record — who did it?" If you don't have audit logs, you have no answer.
I've seen this problem in fintech systems I've built, and every time the solution
was the same: spend two weeks writing the same boilerplate from scratch.
So I built trailkit — a lightweight,
zero-infrastructure audit logging SDK for TypeScript. Here's what I learned along
the way.
The problem with existing solutions
When I looked at the npm ecosystem, the situation was bleak. The audit-log
package was last published 13 years ago. Most ORM-specific plugins are abandoned
or locked to a single database. And commercial services like WorkOS charge
$5,000+/month for 100 organizations — which makes no sense for a small B2B SaaS
team that just needs to answer "who did what."
The gap was clear: there's no lightweight, TypeScript-native library that just
works out of the box.
The insight that made everything click: AsyncLocalStorage
The hardest part of audit logging isn't storing events — it's threading context
through your entire codebase. Every function that might record an audit event
needs to know who is making the request and which organization they belong to.
The naive approach looks like this:
// You end up passing actor everywhere — through every function call
async function updateInvoice(invoiceId: string, data: unknown, actor: Actor) {
await db.update(invoiceId, data);
await auditLog.record(actor, 'invoice.updated', invoiceId);
}
This gets messy fast. Node.js has a built-in solution for exactly this:
AsyncLocalStorage. It lets you store context once at the request boundary,
and read it from anywhere in the async call chain — no prop drilling required.
Here's how trailkit uses it:
// One middleware call sets the context for the entire request
app.use(audit.middleware());
// Now anywhere in your codebase, actor and tenant are automatic
await audit.record({
action: 'invoice.payment.initiated',
resource: { type: 'invoice', id: invoice.id },
metadata: { amount: invoice.total },
});
The middleware extracts actor and tenant from the request once, stores them in
AsyncLocalStorage, and every record() call in that request's async tree reads
them automatically. OpenTelemetry uses the same pattern for trace propagation —
trailkit applies it to audit context.
Tamper detection with SHA-256 hash chaining
For compliance use cases, it's not enough to just store events — you need to
prove they haven't been modified. trailkit implements hash chaining: each event's
hash is computed over its own fields plus the previous event's hash.
const hash = SHA256(canonicalJson({
id, timestamp, actorId, tenantId,
action, resourceType, resourceId,
previousHash // ← this is what makes it a chain
}));
If anyone modifies a stored event, the chain breaks. One call to
audit.verify() walks the entire chain and tells you exactly which event
was tampered with.
One subtle detail: I use canonical JSON (keys sorted alphabetically before
serializing) to ensure identical objects always produce identical hashes,
regardless of property insertion order. Without this, you'd get false
tamper alerts.
Zero infrastructure by default
The SDK ships with three storage adapters: in-memory (for tests), SQLite
(zero config, just a file), and PostgreSQL (for production). The interface
is simple enough that writing a custom adapter takes about 20 lines.
// Development: zero setup
const audit = createAuditLog({
storage: sqliteAdapter({ path: './audit.db' }),
actor: (req) => ({ id: req.user.id, type: 'user' }),
tenant: (req) => req.user.orgId,
});
// Production: swap one line
const audit = createAuditLog({
storage: postgresAdapter({ connectionString: process.env.DATABASE_URL }),
actor: (req) => ({ id: req.user.id, type: 'user' }),
tenant: (req) => req.user.orgId,
});
PII redaction built in
Financial and healthcare applications often can't store raw PII. trailkit lets
you configure redaction rules at initialization — before anything touches storage:
const audit = createAuditLog({
storage: sqliteAdapter({ path: './audit.db' }),
actor: (req) => ({ id: req.user.id, type: 'user', name: req.user.email }),
tenant: (req) => req.user.orgId,
redact: {
'metadata.cardNumber': 'mask', // → ************1111
'metadata.ssn': 'remove', // → field deleted entirely
'actor.name': 'hash', // → SHA-256 hash
},
});
Install and try it
npm install trailkit better-sqlite3
The full source is on GitHub,
including Express and Next.js examples. If you've ever had to build audit
logging from scratch, I'd love to hear what you think — especially what's
missing.
Top comments (0)