Audit User Actions
Learn how to comprehensively track user activity in your application, from authentication to data modifications.
Overview
Auditing user actions is critical for security, compliance, and debugging. This guide shows you how to instrument your application to capture meaningful audit events without impacting performance or user experience.
What to Audit
Not every action needs to be logged. Focus on security-relevant and business-critical operations:
Authentication Events
- Login attempts (success and failure)
- Logout actions
- Password changes and resets
- MFA enrollment and verification
- Session creation and revocation
Authorization Events
- Permission grants and revocations
- Role assignments
- Access denials (especially important for compliance)
- Resource sharing actions
Data Modification Events
- Create, update, delete operations on critical resources
- Bulk operations (e.g., batch delete)
- Data exports and downloads
- Configuration changes
Administrative Actions
- User account creation, suspension, deletion
- Billing and subscription changes
- API key generation and revocation
- System configuration updates
Implementation Patterns
1. Middleware Pattern (Recommended)
For web applications, use middleware to automatically audit HTTP requests:
// middleware/audit.ts
import { TrailbaseClient } from '@trailbase/sdk';
import { NextRequest, NextResponse } from 'next/server';
const trailbase = new TrailbaseClient({
apiKey: process.env.TRAILBASE_API_KEY!,
tenantId: process.env.TRAILBASE_TENANT_ID!,
});
export async function auditMiddleware(
req: NextRequest,
context: { user: { id: string; email: string } }
) {
const startTime = Date.now();
const response = await NextResponse.next();
const duration = Date.now() - startTime;
// Only audit state-changing operations
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) {
await trailbase.log({
action: `api.${req.method.toLowerCase()}`,
actor: {
id: context.user.id,
email: context.user.email,
ip: req.headers.get('x-forwarded-for') || req.ip || 'unknown',
user_agent: req.headers.get('user-agent') || 'unknown',
},
resource: {
type: 'api_endpoint',
id: req.nextUrl.pathname,
},
outcome: response.status < 400 ? 'success' : 'failure',
metadata: {
method: req.method,
path: req.nextUrl.pathname,
status_code: response.status,
duration_ms: duration,
},
});
}
return response;
}2. Service Layer Pattern
For complex business logic, audit at the service layer for maximum context:
// services/document.service.ts
import { trailbase } from '@/lib/trailbase';
export class DocumentService {
async shareDocument(
documentId: string,
actorId: string,
actorEmail: string,
recipientEmail: string,
permission: 'read' | 'write'
) {
try {
// Perform the share operation
await db.documentShare.create({
data: {
documentId,
recipientEmail,
permission,
},
});
// Audit the successful share
await trailbase.log({
action: 'document.share',
actor: {
id: actorId,
email: actorEmail,
},
resource: {
type: 'document',
id: documentId,
},
outcome: 'success',
metadata: {
shared_with: recipientEmail,
permission,
expires_at: null,
},
});
return { success: true };
} catch (error) {
// Audit the failure
await trailbase.log({
action: 'document.share',
actor: {
id: actorId,
email: actorEmail,
},
resource: {
type: 'document',
id: documentId,
},
outcome: 'failure',
metadata: {
error: error.message,
shared_with: recipientEmail,
},
});
throw error;
}
}
}3. Decorator Pattern
Use TypeScript decorators for clean, declarative auditing:
// decorators/audit.ts
import { trailbase } from '@/lib/trailbase';
export function Audit(action: string, resourceType: string) {
return function (
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
const originalMethod = descriptor.value;
descriptor.value = async function (...args: any[]) {
const context = args[0]; // Assume first arg contains user context
try {
const result = await originalMethod.apply(this, args);
await trailbase.log({
action,
actor: {
id: context.userId,
email: context.userEmail,
},
resource: {
type: resourceType,
id: result.id || 'unknown',
},
outcome: 'success',
metadata: { result },
});
return result;
} catch (error) {
await trailbase.log({
action,
actor: {
id: context.userId,
email: context.userEmail,
},
resource: {
type: resourceType,
id: 'unknown',
},
outcome: 'failure',
metadata: { error: error.message },
});
throw error;
}
};
return descriptor;
};
}
// Usage:
class UserService {
@Audit('user.delete', 'user')
async deleteUser(context: Context, userId: string) {
// Implementation
}
}Metadata Best Practices
The metadata field is your opportunity to add context. Here's what to include:
Changed Fields (for Updates)
await trailbase.log({
action: 'profile.update',
actor: { id: userId, email: userEmail },
resource: { type: 'user', id: userId },
outcome: 'success',
metadata: {
changed_fields: ['email', 'phone'],
old_values: {
email: 'old@example.com',
phone: '+1234567890',
},
new_values: {
email: 'new@example.com',
phone: '+0987654321',
},
},
});Request Context
metadata: {
request_id: req.headers.get('x-request-id'),
session_id: req.cookies.get('session_id'),
referrer: req.headers.get('referer'),
country: geoip.lookup(ip)?.country,
}Business Context
metadata: {
tenant_plan: 'enterprise',
feature_flag: 'new_editor_enabled',
experiment_variant: 'control',
impersonated_by: adminId, // If admin is acting as user
}Handling Sensitive Data
Never log passwords, API keys, or personally identifiable information (PII) unless required:
// ❌ BAD: Logging password
await trailbase.log({
action: 'user.login',
metadata: {
password: userPassword, // NEVER DO THIS
},
});
// ✅ GOOD: Redact sensitive data
await trailbase.log({
action: 'user.login',
metadata: {
password_length: userPassword.length,
password_strength: 'strong',
},
});GDPR Compliance
If you're subject to GDPR, avoid logging email addresses or names in metadata. Use pseudonymous user IDs instead and maintain a separate mapping table that can be deleted upon user request.
Performance Optimization
1. Use Automatic Batching
The SDK batches events automatically, but you can tune the settings:
const trailbase = new TrailbaseClient({
apiKey: process.env.TRAILBASE_API_KEY!,
tenantId: process.env.TRAILBASE_TENANT_ID!,
flushInterval: 500, // Flush every 500ms (default: 1000)
maxBatchSize: 100, // Max events per batch (default: 50)
});2. Fire-and-Forget Pattern
Don't await audit logging in critical paths:
// ❌ BAD: Blocks user response
async function updateDocument(data) {
const result = await db.update(data);
await trailbase.log({ ... }); // User waits for this
return result;
}
// ✅ GOOD: Non-blocking
async function updateDocument(data) {
const result = await db.update(data);
trailbase.log({ ... }); // Fire and forget (queued for batching)
return result;
}3. Background Worker Pattern
For extremely high-volume scenarios, use a message queue:
// In your API handler
await queue.publish('audit', {
action: 'document.create',
actor: { id: userId, email: userEmail },
resource: { type: 'document', id: docId },
outcome: 'success',
});
// In a separate worker process
queue.subscribe('audit', async (event) => {
await trailbase.log(event);
});Testing Your Implementation
Unit Testing
Mock the Trailbase client in your tests:
// __tests__/document.service.test.ts
import { jest } from '@jest/globals';
import { DocumentService } from './document.service';
jest.mock('@/lib/trailbase', () => ({
trailbase: {
log: jest.fn(),
},
}));
describe('DocumentService', () => {
it('should audit successful share', async () => {
const service = new DocumentService();
await service.shareDocument(
'doc_123',
'user_alice',
'alice@example.com',
'bob@example.com',
'read'
);
expect(trailbase.log).toHaveBeenCalledWith(
expect.objectContaining({
action: 'document.share',
outcome: 'success',
})
);
});
});Integration Testing
Test against the Trailbase API in staging:
// e2e/audit.test.ts
import { TrailbaseClient } from '@trailbase/sdk';
const trailbase = new TrailbaseClient({
apiKey: process.env.TRAILBASE_TEST_API_KEY!,
tenantId: 'test_tenant',
});
test('end-to-end audit logging', async () => {
const eventId = `test_${Date.now()}`;
await trailbase.log({
action: 'test.event',
actor: { id: 'test_user', email: 'test@example.com' },
resource: { type: 'test', id: eventId },
outcome: 'success',
});
// Wait for ingestion
await new Promise((r) => setTimeout(r, 2000));
// Verify via search API
const results = await trailbase.searchEvents({
resource_id: eventId,
});
expect(results.events).toHaveLength(1);
expect(results.events[0].action).toBe('test.event');
});Monitoring and Alerts
Set up alerts to monitor audit log health:
// Create alert for failed login attempts
await trailbase.createAlertRule({
name: 'Excessive Failed Logins',
type: 'threshold',
condition: {
action: 'user.login',
outcome: 'failure',
count: 5,
window_seconds: 300, // 5 minutes
},
severity: 'high',
webhookIds: ['webhook_slack'],
});Next Steps
Now that you're auditing user actions, learn how to control access with RBAC implementation.