audit-log-lib
v2.0.0
Published
Browser-based audit logging: IndexedDB storage, batching + offline retry queue, PII redaction, Excel/JSON export, and a real-time backend channel (SignalR) the UI can listen to.
Maintainers
Readme
audit-log-lib
Browser audit logging done right. Stores every event in IndexedDB, then batches them to your backend with an offline retry queue, redacts PII before anything leaves the browser, and opens a real-time channel so your backend can push commands the UI listens to (e.g. "send me your logs now").
- 📦 IndexedDB storage — no server required to get started
- 🚚 Batching + offline queue — logs survive reloads & network drops, retried with backoff
- 🔒 PII redaction — passwords, tokens, emails, card numbers masked before store/send
- 🔁 Backend → UI push — built-in SignalR transport; backend fires a command, your UI reacts
- 🧯 Global capture —
console.*and uncaught errors logged automatically - 📤 Export — download logs as JSON, Excel (.xlsx), or both in a ZIP
Installation
npm install audit-log-libTwo features have optional peer dependencies — install them only if you use them.
# only if you use SignalRTransport (real-time backend channel)
npm install @microsoft/signalr
# only if you export to Excel — downloadLogs('excel' | 'both')
npm install exceljs
downloadLogs('json')works with no extra packages. Calling'excel'/'both'withoutexceljsinstalled throws a clear, catchable error.
Quick start
import { AuditLog, setupGlobalLogging } from 'audit-log-lib';
const audit = new AuditLog();
// Optional: mirror console.* and uncaught errors into the audit log
setupGlobalLogging(audit);
await audit.log('user.login', { userId: 123 });
await audit.log('payment.failed', { orderId: 456 }, 'error');That's it — logs are persisted locally in IndexedDB. Add a backend below when you're ready.
Send logs to your backend
Option A — real-time channel (SignalR, recommended)
Two-way: the library uploads batches to your hub, and your backend can push commands back down to the browser.
import { AuditLog, SignalRTransport } from 'audit-log-lib';
const audit = new AuditLog({
transport: new SignalRTransport({
url: 'https://your-api.com/hubs/audit',
accessTokenFactory: () => localStorage.getItem('token') ?? '',
}),
// tune batching (optional)
shipper: { batchSize: 50, flushIntervalMs: 5000 },
});
await audit.log('user.login', { userId: 123 }); // queued, batched, shipped automaticallyYour .NET hub receives UploadLogs and can push an AuditCommand:
public class AuditHub : Hub
{
// Client calls this with a batch of entries
public async Task UploadLogs(List<AuditLogEntry> entries)
{
// ...persist / forward to your sink...
}
}
// From anywhere on the server — ask a connected client to flush right now:
await hub.Clients.User(userId).SendAsync("AuditCommand", new { type = "pull" });Option B — simple HTTP sink (no extra deps)
If you just want to POST each entry yourself, use the onLog hook:
const audit = new AuditLog({
onLog: async (entry) => {
await fetch('https://your-api.com/api/logs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(entry),
});
},
});With a
transport, delivery is automatic and batched (use this).onLogis a low-level escape hatch for one-off custom sinks and is ignored when a transport is set.
Backend → UI: listen for commands
The backend can fire a command and your UI reacts to it. Known commands are also handled by the library automatically:
| Command | Auto behavior | Use case |
|---|---|---|
| pull / flush | flushes the queue now | backend wants the latest logs |
| clear | clears local logs | wipe a client after investigation |
| pause / resume | pauses/resumes shipping | back off a noisy client |
| setLevel (payload: LogLevel) | raises the min level | quiet a client remotely |
| anything custom | — | you handle it in on('command', …) |
audit.on('command', (cmd) => {
if (cmd.type === 'pull') showToast('Backend requested your logs');
if (cmd.type === 'toast') showToast(String(cmd.payload)); // your own command type
});PII redaction
Sensitive data is replaced before it is stored or sent — so nothing leaks to
IndexedDB or the network. Opt in by passing redaction:
const audit = new AuditLog({
redaction: {
// defaults cover password, token, secret, apiKey, card, ssn, ... + email/card patterns
strategy: 'mask', // 'mask' | 'remove' | 'hash'
replacement: '[REDACTED]', // for 'mask'
},
});
await audit.log('user.signup', { email: '[email protected]', password: 'hunter2' });
// stored & shipped as: { email: '[REDACTED]', password: '[REDACTED]' }| Strategy | Result |
|---|---|
| mask | replaced with replacement (default [REDACTED]) |
| remove | key dropped entirely |
| hash | deterministic hash — lets you correlate without exposing the value |
You can override keys and patterns:
redaction: {
keys: ['password', 'cardNumber', 'authToken'],
patterns: [/\b\d{3}-\d{2}-\d{4}\b/g],
strategy: 'hash',
}API
new AuditLog(options?)
| Option | Type | Default | Description |
|---|---|---|---|
| dbName | string | 'audit-log-db' | IndexedDB database name |
| storeName | string | 'logs' | IndexedDB object store name |
| maxDays | number | 30 | Prune entries older than this (runs on startup + daily) |
| maxEntries | number | undefined | Cap stored entries; oldest synced ones trimmed past it (never drops un-sent logs) |
| minLevel | LogLevel | 'debug' | Drop entries below this level |
| redaction | RedactionOptions | undefined | Redact PII before store/send |
| transport | RemoteTransport | undefined | Backend channel (e.g. SignalRTransport); enables batched delivery |
| shipper | ShipperOptions | see below | Batching/retry tuning |
| getContext | () => object | undefined | Injected into every entry's context (userId, version, …) |
| onLog | (entry) => void \| Promise | undefined | One-off sink; ignored when transport is set |
ShipperOptions: batchSize (50), flushIntervalMs (5000), retryBaseMs (2000), retryMaxMs (60000).
Methods
await audit.log(action, payload?, level?, context?); // level: 'debug'|'info'|'warn'|'error'
await audit.flush(); // force-send queued entries now
await audit.getLogs(); // all stored entries, oldest→newest
await audit.downloadLogs('json' | 'excel' | 'both');
await audit.clearLogs();
audit.getSessionId(); // auto-attached to every entry for correlation
audit.destroy(); // stop timers + close transport (call on unmount)Events — audit.on(event, handler) → returns an unsubscribe fn
| Event | Payload | Fired when |
|---|---|---|
| command | AuditCommand | backend pushes a command |
| log | AuditLogEntry | an entry is written locally |
| shipped | AuditLogEntry[] | a batch is delivered |
| shipError | Error | a send attempt fails (will retry) |
| state | TransportState | connection state changes |
const off = audit.on('shipped', (batch) => console.log('sent', batch.length));
off(); // unsubscribesetupGlobalLogging(audit, options?)
Mirrors console.log/warn/error and window errors into the log. Returns a
teardown function.
const stop = setupGlobalLogging(audit, { console: true, errors: true });
// later: stop();Custom transports
SignalRTransport is just one implementation of RemoteTransport. Bring your
own (SSE, WebSocket, fetch) by implementing the interface:
interface RemoteTransport {
start(): Promise<void>;
stop(): Promise<void>;
send(entries: AuditLogEntry[]): Promise<void>; // MUST throw on failure so it's retried
onCommand(handler: (cmd: AuditCommand) => void): void;
onStateChange?(handler: (state: TransportState) => void): void;
}Types
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
interface AuditLogEntry {
id?: number;
action: string;
payload?: unknown;
timestamp: number;
level: LogLevel;
context?: Record<string, unknown>;
sessionId?: string;
synced?: 0 | 1;
}
interface AuditCommand {
type: 'flush' | 'pull' | 'clear' | 'pause' | 'resume' | 'setLevel' | (string & {});
payload?: unknown;
id?: string;
}Migrating from v1
v2 is a major release. Because of semver, npm will not upgrade existing ^1
installs automatically — you stay on v1 until you opt in with
npm install audit-log-lib@2. When you do upgrade, note these changes:
- IndexedDB is migrated automatically. v1 stored entries without an
idkey; v2 useskeyPath: 'id'. On first run, v2 reads your existing logs, rebuilds the store with ids, and marks the old entries as already-sent so they are not re-shipped to your backend. No action needed, no data loss. onStorageFullwas removed. It cleared all logs whenmaxEntrieswas hit. v2 replaces it with safer trimming: pastmaxEntries, only the oldest already-synced entries are dropped, so un-sent logs are never lost. To flush to a backend, use atransport(auto-batched) oraudit.flush().onLogis now an escape hatch. It still works when you have notransport, but it is ignored once atransportis set (the shipper handles delivery). It now also receives a complete entry — includingtimestamp, which v1 omitted.AuditLogEntryis stricter.timestampandlevelare now required (they were optional). Only relevant if you build entries by hand in TypeScript.
Everything else — log(), getLogs(), downloadLogs(), clearLogs(),
destroy(), setupGlobalLogging() — is unchanged.
Browser support
Requires IndexedDB — all modern browsers (Chrome, Firefox, Edge, Safari).
License
Apache 2.0 — see LICENSE.
Runtime dependencies are file-saver (MIT) and jszip (used under its MIT
option). @microsoft/signalr (MIT) and exceljs (MIT) are optional peer
dependencies, pulled in only when you use the SignalR channel or Excel export.
