Activity Logs
Centralized audit logging system with tiered guarantees, PII sanitization, and compliance-ready data model.
The Activity Log system is a centralized, compliance-ready auditing engine. It captures the "Who, What, Where, When, and Why" of every significant action.
Core Philosophy
- Immutable History: Once written, logs are never altered.
- Loose Coupling: Logs do not use Foreign Keys. If a User or Tenant is deleted, the audit trail remains intact (orphaned logs are a feature, not a bug).
- Security First: Sensitive data is sanitized before it leaves application memory. Secrets are redacted; PII is either redacted or encrypted depending on sensitivity level.
- Performance Tiers: Critical actions (money movement) are logged synchronously; non-critical actions (views) are fire-and-forget.
Architecture
The Service (modules/audit-logs/service.ts)
AuditService: The central dispatcher. Determines whether to write synchronously, queue, or fire-and-forget based on tier.Sanitizer: A recursive engine that processes data based on a 3-level sensitivity system.Diff Engine: Usesmicrodiffto calculate deep JSON differences betweenchangeBeforeandchangeAfter, stored in dot-notation format.Crypto: AES-256-GCM implementation for encrypting PII at HIGH sensitivity.
The Plugin (plugins/audit.ts)
- Fastify Decorator: Adds
request.audit.log()to every request via theonRequesthook. - Context Auto-fill: Automatically captures
ipAddress,userAgent,requestId,traceId,sessionId,httpMethod,path,service,environment, andduration. - Actor Auto-population: Calls
populateUser(req)to extract user context. DefaultsactorTypeto"HUMAN"if user is authenticated,"SYSTEM"otherwise. DefaultsactorIdtouser.idor"ANONYMOUS". - Runtime Validation: Drops the log with a warning if
action,module,tier, orstatusare missing. - Safety: If a SYNC log fails, the error is re-thrown to abort the transaction. If a QUEUE/ASYNC log fails, it logs the error but allows the request to proceed.
The Helper (utils/audit-helper.ts)
Provides logSuccess() and logFailure() convenience functions that extract actor fields from request.user and combine them with route-specific config. Routes still provide all domain context (tags, metadata, tier, sensitivity) — the helpers only eliminate repetitive actor extraction.
await logSuccess(request, {
action: "license.create",
module: "LICENSES",
entityType: "license",
entityId: license.id,
changeAfter: license,
tier: "SYNC",
sensitivity: "HIGH",
tags: ["provisioning", "billing", "contract"],
metadata: { companyName: license.companyName, statusCode: 201 },
tenantId: "SYSTEM", // Override for system-level operations
});logFailure() uses getFriendlyErrorMessage() from modules/audit-logs/utils.ts to extract safe error messages (matching the global error handler's logic) and automatically captures the HTTP status code from AppError instances.
Tiered Logging Strategy
3-tier strategy balancing data integrity with system performance:
| Tier | Usage | Behavior | Failure Mode |
|---|---|---|---|
| SYNC | Money movement, auth changes, security alerts | Blocking. Awaits DB insert. | Throws error. Request/transaction aborts. |
| QUEUE | Profile updates, entity creation, settings changes | Non-blocking. Pushes to BullMQ. | Logs error. Request succeeds. |
| ASYNC | Dashboard views, search, report exports | Fire & forget. No await. | Logs error. Request succeeds. |
QUEUE Tier Fallback
The QUEUE tier currently falls back to SYNC behavior until BullMQ worker infrastructure is deployed. QUEUE-tier logs are blocking in the current implementation.
If no tier is provided, the service determines it from the action name using a built-in mapping (AUDIT_TIERS in service.ts). Routes should always set tier explicitly.
Sensitivity & Sanitization
The sensitivity field controls how the sanitizer handles data. It is applied to changeBefore, changeAfter, and metadata before anything is written to the database.
Sensitivity Levels
| Level | Secrets | PII | Use Case |
|---|---|---|---|
| LOW | [REDACTED] | [PII_REDACTED] | Read-only / GET logs |
| MEDIUM (default) | [REDACTED] | [PII_REDACTED] | Standard operations |
| HIGH | [REDACTED] | Encrypted (AES-256-GCM, recoverable) | Critical transactions with compliance needs |
At HIGH sensitivity, PII is encrypted and recoverable by authorized admins. At MEDIUM and LOW, PII is irreversibly redacted with [PII_REDACTED].
The isSensitive boolean on the database row is set to true when sensitivity === "HIGH", flagging that the row contains encrypted (rather than redacted) PII.
Field Classification
Always Redacted ([REDACTED]) — secrets that must never appear in logs:
password, passwordConfirmation, oldPassword, newPassword, currentPassword, confirmPassword, token, accessToken, refreshToken, verificationToken, pin, clientSecret, apiKey, otp
Any key containing "password" is also redacted, unless it matches the Password Policy Whitelist (config fields like passwordMinLength, passwordExpiryDays, passwordHistory, etc.).
PII Fields — redacted at MEDIUM/LOW, encrypted at HIGH:
ssn, socialSecurityNumber, nationalId, pan, cardNumber, cvv, cvc, email, phone, phoneNumber, mobile, address, street, dob, dateOfBirth, iban, accountNumber
Truncated — large binary/file data capped at 20 characters to prevent storage bloat:
base64, image, file, buffer, pdf
Encryption
- Algorithm: AES-256-GCM (authenticated encryption — verifies integrity).
- Key Derivation:
scryptusingENCRYPTION_KEY+ENCRYPTION_SALTfrom environment variables. - Output Format:
ENC:v1:<IV hex>:<AuthTag hex>:<Ciphertext hex> - Failure Handling: If encryption fails, the value is replaced with
[ENCRYPTION_FAILED]rather than crashing the logging pipeline.
Data Model
Table: remoteEaze_activity_logs
Core Fields
| Field | Type | Description |
|---|---|---|
id | String | Time-sorted ID: YYYYMMDD-HHMMSSms-RAND (4-digit random suffix) |
tenantId | String | Tenant isolation key (no FK — loose reference) |
timestamp | DateTime (Timestamptz) | When the action actually occurred (UTC) |
createdAt | DateTime (Timestamptz) | When the log was written to DB (differs for queued logs) |
Actor Context (Who)
| Field | Type | Description |
|---|---|---|
actorId | String? | User ID or "ANONYMOUS" (no FK) |
actorType | ActorType | HUMAN, SYSTEM, SERVICE, CRON, IMPERSONATION |
actorName | String? | Snapshot of name at time of action |
actorBranch | String? | Branch ID snapshot (from user.branchId) |
actorRole | String? | Role ID snapshot (from user.roleId) |
Event Details (What)
| Field | Type | Description |
|---|---|---|
action | String | Action identifier (e.g., license.create, auth.signin) |
entityType | String | Type of entity affected (e.g., license, role) |
entityId | String? | ID of the specific entity |
module | String? | Business module (e.g., AUTH, LICENSES, ACCESS_CONTROL) |
Data Changes
| Field | Type | Description |
|---|---|---|
changeBefore | Json? | State before mutation (sanitized) |
changeAfter | Json? | State after mutation (sanitized) |
diff | Json? | Calculated delta via microdiff — { "field.path": { from, to } } |
recordStatusBefore | String? | Workflow state before (e.g., pending_auth_2) |
recordStatusAfter | String? | Workflow state after (e.g., authorized) |
Context (Where and How)
| Field | Type | Description |
|---|---|---|
ipAddress | String? | Source IP address |
userAgent | String? | Browser/client user agent |
sessionId | String? | Better Auth session ID (from cookie or x-session-id header) |
requestId | String? | Fastify request ID for correlation |
traceId | String? | From x-trace-id header (OpenTelemetry) |
httpMethod | String? | HTTP method (GET, POST, etc.) |
path | String? | API endpoint path |
service | String? | Defaults to "api-core" |
environment | String? | From NODE_ENV |
Additional Context
| Field | Type | Description |
|---|---|---|
tags | String[] | Filterable tags (e.g., ["financial", "provisioning"]) |
metadata | Json? | Arbitrary extra data (also sanitized at the event's sensitivity level) |
customFields | Json? | Extensibility for organization-specific needs |
Status and Performance
| Field | Type | Description |
|---|---|---|
status | LogStatus | SUCCESS or FAILURE (PENDING is coerced to SUCCESS at write time) |
error | String? | Error message if status is FAILURE |
duration | Int? | Milliseconds from request start to audit log call |
Security and Compliance
| Field | Type | Description |
|---|---|---|
isSensitive | Boolean | true when sensitivity === "HIGH" — row contains encrypted PII |
retentionPolicy | String | Retention rule. Defaults to "90_days". Options: 90_days, 1_year, 2_years, 7_years |
Indexes
@@index([tenantId, timestamp]) // Tenant-scoped date range queries
@@index([tenantId, action, timestamp]) // Filter by action type
@@index([entityType, entityId]) // Entity audit history
@@index([actorId, timestamp]) // User activity timeline
@@index([traceId]) // Distributed tracing
@@index([status]) // Failure analysis
@@index([tags], type: Gin) // Tag filtering (PostgreSQL GIN)
@@index([customFields], type: Gin) // Custom field queries (PostgreSQL GIN)Enums
ActorType: HUMAN | SYSTEM | SERVICE | CRON | IMPERSONATION
LogStatus: SUCCESS | FAILURE | PENDING
Usage Guide
Use request.audit.log() in route handlers. The RequestAuditEvent type requires action, module, tier, and status — the plugin will drop the log if any are missing.
Create (SYNC — Critical)
await request.audit.log({
action: "transaction.create",
module: "TRANSACTIONS",
entityType: "transaction",
entityId: transaction.id,
changeAfter: transaction,
tier: "SYNC",
sensitivity: "HIGH",
status: "SUCCESS",
tags: ["financial", "high-value"],
});Update (With Diff)
await request.audit.log({
action: "user.update",
module: "USERS",
entityType: "user",
entityId: userId,
changeBefore: oldUser,
changeAfter: newUser,
tier: "QUEUE",
sensitivity: "MEDIUM",
status: "SUCCESS",
tags: ["profile"],
});View (ASYNC — Fire & Forget)
// No await — fire and forget
request.audit.log({
action: "dashboard.view",
module: "DASHBOARD",
entityType: "dashboard",
tier: "ASYNC",
sensitivity: "LOW",
status: "SUCCESS",
});Using the Helper (Recommended for Routes)
import { logSuccess, logFailure } from "../../utils/audit-helper";
try {
const result = await service.create(data);
await logSuccess(request, {
action: "role.create",
module: "ACCESS_CONTROL",
entityType: "role",
entityId: result.id,
changeAfter: result,
tier: "SYNC",
sensitivity: "MEDIUM",
tags: ["access-control", "rbac"],
metadata: { roleName: result.name, statusCode: 201 },
});
} catch (error) {
await logFailure(request, {
action: "role.create",
module: "ACCESS_CONTROL",
entityType: "role",
tier: "SYNC",
sensitivity: "LOW",
tags: ["access-control", "rbac"],
error,
additionalMetadata: { attemptedName: data.name },
});
throw error; // Let global error handler send the response
}