@geekmidas/audit
v2.0.0
Published
> Type-safe audit logging with database integration for tracking application events and user actions
Readme
@geekmidas/audit
Type-safe audit logging with database integration for tracking application events and user actions
Overview
@geekmidas/audit provides a comprehensive solution for recording and persisting audit trails in your application. It supports type-safe audit actions, transactional writes, and flexible storage backends.
Features
- Type-safe Audit Actions: Define audit types with TypeScript for compile-time safety
- Transactional Support: Flush audits atomically within database transactions
- Flexible Storage: Pluggable storage interface (Kysely, in-memory, cache)
- Actor Tracking: Record who performed each action (users, services, systems)
- Rich Metadata: Attach request context, entity references, and custom data
- Query Support: Query audit logs with filters, pagination, and sorting
Installation
npm install @geekmidas/audit
# or
pnpm add @geekmidas/auditQuick Start
1. Define Your Audit Actions
import type { AuditableAction } from '@geekmidas/audit';
// Define type-safe audit actions
type AppAuditAction =
| AuditableAction<'user.created', { userId: string; email: string }>
| AuditableAction<'user.updated', { userId: string; changes: string[] }>
| AuditableAction<'order.placed', { orderId: string; total: number }>;2. Set Up Storage
For Development/Testing (InMemoryAuditStorage):
import { InMemoryAuditStorage } from '@geekmidas/audit/memory';
const storage = new InMemoryAuditStorage<AppAuditAction>();
// Query stored records
const records = await storage.query({ type: 'user.created' });
// Clear all records (useful in tests)
storage.clear();For Production (KyselyAuditStorage):
import { KyselyAuditStorage } from '@geekmidas/audit/kysely';
// Define your database schema
interface Database {
audit_logs: AuditLogTable;
// ... other tables
}
const storage = new KyselyAuditStorage<Database>({
db: kyselyDb,
tableName: 'audit_logs',
});3. Create an Auditor
import { DefaultAuditor } from '@geekmidas/audit';
const auditor = new DefaultAuditor<AppAuditAction>({
actor: { id: 'user-123', type: 'user' },
storage,
metadata: {
requestId: 'req-456',
endpoint: '/api/users',
},
});4. Record Audits
// Type-safe audit calls - TypeScript enforces correct payload shapes
auditor.audit('user.created', {
userId: '789',
email: '[email protected]',
}); // OK
auditor.audit('user.created', {
orderId: '123', // Type error - wrong payload shape
});
// Flush to storage
await auditor.flush();Transactional Audits
Use withAuditableTransaction to ensure audits are atomic with your database operations:
import { withAuditableTransaction } from '@geekmidas/audit/kysely';
const result = await withAuditableTransaction(
db,
auditor,
async (trx) => {
// Database operations
const user = await trx
.insertInto('users')
.values({ name: 'John', email: '[email protected]' })
.returningAll()
.executeTakeFirstOrThrow();
// Audit is recorded
auditor.audit('user.created', {
userId: user.id,
email: user.email,
});
return user;
},
);
// Audits are automatically flushed before transaction commits
// If flush fails, the entire transaction rolls backAPI Reference
Core Types
AuditableAction<TType, TPayload>
Defines an auditable action with a type and payload:
type UserAction = AuditableAction<'user.created', { userId: string }>;AuditRecord
Complete audit record with all metadata:
interface AuditRecord<TPayload = unknown> {
id: string;
type: string;
operation: AuditOperation;
table?: string;
entityId?: string | Record<string, unknown>;
oldValues?: Record<string, unknown>;
newValues?: Record<string, unknown>;
payload?: TPayload;
timestamp: Date;
actor?: AuditActor;
metadata?: AuditMetadata;
}AuditActor
Represents who performed the action:
interface AuditActor {
id?: string;
type?: string;
[key: string]: unknown;
}AuditMetadata
Request context and additional data:
interface AuditMetadata {
requestId?: string;
endpoint?: string;
method?: string;
ip?: string;
userAgent?: string;
[key: string]: unknown;
}Auditor Interface
interface Auditor<TAuditAction, TTransaction> {
readonly actor: AuditActor;
// Type-safe audit recording
audit<TType extends ExtractAuditType<TAuditAction>>(
type: TType,
payload: ExtractAuditPayload<TAuditAction, TType>,
options?: AuditOptions,
): void;
// Raw record insertion
record(record: Omit<AuditRecord, 'id' | 'timestamp' | 'actor'>): void;
// Get collected records
getRecords(): AuditRecord[];
// Flush to storage
flush(trx?: TTransaction): Promise<void>;
// Clear without flushing
clear(): void;
// Add metadata to future records
addMetadata(metadata: AuditMetadata): void;
// Transaction management
setTransaction(trx: TTransaction): void;
getTransaction(): TTransaction | undefined;
}AuditStorage Interface
Implement this interface for custom storage backends:
interface AuditStorage<TAuditAction> {
// Required: Write records
write(records: AuditRecord[], trx?: unknown): Promise<void>;
// Optional: Query records
query?(options: AuditQueryOptions): Promise<AuditRecord[]>;
// Optional: Count records
count?(options: Omit<AuditQueryOptions, 'limit' | 'offset'>): Promise<number>;
// Optional: Get database for transactions
getDatabase?(): unknown;
}KyselyAuditStorage
Built-in Kysely storage implementation:
const storage = new KyselyAuditStorage({
db: kyselyDb,
tableName: 'audit_logs',
databaseServiceName: 'database', // Optional: for automatic transaction injection
autoId: false, // Optional: let database generate IDs
});
// Query audits
const audits = await storage.query({
type: 'user.created',
actorId: 'user-123',
from: new Date('2024-01-01'),
limit: 100,
orderBy: 'timestamp',
orderDirection: 'desc',
});
// Count audits
const count = await storage.count({
type: ['user.created', 'user.updated'],
actorId: 'user-123',
});InMemoryAuditStorage
Convenience wrapper around CacheAuditStorage with InMemoryCache. Useful for testing and development:
import { InMemoryAuditStorage } from '@geekmidas/audit/memory';
const storage = new InMemoryAuditStorage<AppAuditAction>();
// Query audits (same API as other storages)
const audits = await storage.query({
type: 'user.created',
limit: 10,
});
// Get all records (for assertions in tests)
const allRecords = await storage.getRecords();
// Clear all records (reset for next test)
await storage.clear();CacheAuditStorage
Cache-based storage using any @geekmidas/cache implementation:
import { CacheAuditStorage } from '@geekmidas/audit/cache';
import { InMemoryCache } from '@geekmidas/cache/memory';
import { UpstashCache } from '@geekmidas/cache/upstash';
// With in-memory cache (development/testing)
const storage = new CacheAuditStorage({
cache: new InMemoryCache(),
ttl: 86400, // 24 hours
});
// With Upstash Redis (production - distributed systems)
const storage = new CacheAuditStorage({
cache: new UpstashCache({
url: process.env.UPSTASH_REDIS_URL,
token: process.env.UPSTASH_REDIS_TOKEN,
}),
prefix: 'audit', // Optional key prefix
ttl: 604800, // 7 days
});
// Query and count work the same as other storages
const audits = await storage.query({ type: 'user.created' });
const count = await storage.count({ actorId: 'user-123' });
// Clear all records
await storage.clear();Database Schema
Create an audit_logs table for KyselyAuditStorage:
CREATE TABLE audit_logs (
id VARCHAR(21) PRIMARY KEY, -- or use gen_random_uuid() with autoId: true
type VARCHAR(255) NOT NULL,
operation VARCHAR(20) NOT NULL,
"table" VARCHAR(255),
"entityId" VARCHAR(255),
"oldValues" JSONB,
"newValues" JSONB,
payload JSONB,
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
"actorId" VARCHAR(255),
"actorType" VARCHAR(50),
"actorData" JSONB,
metadata JSONB
);
-- Recommended indexes
CREATE INDEX idx_audit_logs_type ON audit_logs(type);
CREATE INDEX idx_audit_logs_timestamp ON audit_logs(timestamp);
CREATE INDEX idx_audit_logs_actor_id ON audit_logs("actorId");
CREATE INDEX idx_audit_logs_entity_id ON audit_logs("entityId");Integration with @geekmidas/constructs
The audit package integrates seamlessly with @geekmidas/constructs endpoints:
import { e } from '@geekmidas/constructs/endpoints';
const endpoint = e
.post('/users')
.body(UserSchema)
.output(UserResponseSchema)
.audit([
{
type: 'user.created',
payload: (response) => ({
userId: response.id,
email: response.email,
}),
},
])
.handle(async ({ body, auditor }) => {
// Audits are automatically recorded and flushed
return { id: '123', ...body };
});Best Practices
- Define all audit actions upfront: Create a union type of all possible audit actions for your application
- Use transactions: Wrap database operations and audits in transactions for consistency
- Include entity references: Use
entityIdandtableoptions for easier querying - Add request context: Include request IDs, endpoints, and IPs in metadata
- Use meaningful types: Name audit types with domain context (e.g.,
order.shippednotupdate)
License
MIT
