@nestbolt/audit-log
Manual Logging
Use AuditLogService.log() to create audit log entries for operations that bypass the TypeORM subscriber.
The automatic TypeORM subscriber covers .save() and .remove() operations, but many real-world scenarios require logging changes that bypass the ORM entirely. Use AuditLogService.log() for these cases.
When to Use Manual Logging
- Bulk updates --
repository.update(),QueryBuilder.update(), and raw SQL do not trigger TypeORM entity lifecycle events. - External API calls -- logging changes made to third-party systems (payment providers, email services, etc.).
- Business events -- recording logical actions like "user banned", "subscription cancelled", or "report generated" that may not map to a single entity update.
- Multi-step operations -- logging the outcome of a complex workflow that spans multiple entities or services.
- Soft deletes -- if your soft delete implementation uses
repository.update()to set adeletedAttimestamp, it will not trigger the subscriber.
Basic Usage
Inject AuditLogService and call log():
import { Injectable } from "@nestjs/common";
import { AuditLogService } from "@nestbolt/audit-log";
@Injectable()
export class UserService {
constructor(private readonly auditLogService: AuditLogService) {}
async banUser(userId: string, adminId: string, reason: string): Promise<void> {
// Perform the ban using a bulk update (does not trigger subscriber)
await this.userRepo.update(userId, { status: "banned", bannedAt: new Date() });
// Manually log the change
await this.auditLogService.log({
action: "updated",
entityType: "User",
entityId: userId,
oldValues: { status: "active" },
newValues: { status: "banned" },
actor: { type: "Admin", id: adminId },
metadata: { reason },
});
}
}Parameter Reference
The log() method accepts a ManualAuditLogParams object:
interface ManualAuditLogParams {
action: AuditAction;
entityType: string;
entityId: string;
oldValues?: Record<string, any> | null;
newValues?: Record<string, any> | null;
actor?: AuditActor;
metadata?: Record<string, any>;
ipAddress?: string;
userAgent?: string;
}Required Parameters
| Parameter | Type | Description |
|---|---|---|
action | "created" | "updated" | "deleted" | The type of action being logged |
entityType | string | The logical entity type name (e.g., "User", "Order") |
entityId | string | The unique identifier of the entity |
Optional Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
oldValues | Record<string, any> | null | null | Previous field values (typically set for "updated" and "deleted" actions) |
newValues | Record<string, any> | null | null | New field values (typically set for "created" and "updated" actions) |
actor | { type: string; id: string } | Resolved via ActorResolver or defaultActor | The user or system that performed the action |
metadata | Record<string, any> | Module-level metadata | Extra context stored with the audit log entry |
ipAddress | string | null | The IP address of the request |
userAgent | string | null | The user agent string of the request |
Actor Resolution in Manual Logging
When actor is not provided to log(), the service resolves the actor automatically:
- If an
actorResolveris configured, it callsresolve()on the resolver. - If no resolver is configured (or it returns
null), thedefaultActorfrom module options is used. - If neither is available,
actorTypeandactorIdare set tonull.
When actor is explicitly provided, it takes precedence over all automatic resolution.
Metadata Precedence
When metadata is provided to log(), it replaces the module-level metadata entirely (no merging). If metadata is not provided, the module-level metadata from module options is used. If neither is set, metadata is null.
Return Value
The log() method returns the saved AuditLogEntity:
const auditLog = await this.auditLogService.log({
action: "updated",
entityType: "User",
entityId: userId,
oldValues: { status: "active" },
newValues: { status: "banned" },
});
console.log(auditLog.id); // UUID of the audit log entry
console.log(auditLog.createdAt); // Timestamp of when the entry was createdExamples
Logging a Bulk Update
@Injectable()
export class ProductService {
constructor(
@InjectRepository(Product)
private readonly productRepo: Repository<Product>,
private readonly auditLogService: AuditLogService,
) {}
async deactivateAllByCategory(category: string, adminId: string): Promise<void> {
const products = await this.productRepo.find({
where: { category, active: true },
});
// Bulk update -- does not trigger the subscriber
await this.productRepo.update(
{ category, active: true },
{ active: false },
);
// Log each affected entity
for (const product of products) {
await this.auditLogService.log({
action: "updated",
entityType: "Product",
entityId: product.id,
oldValues: { active: true },
newValues: { active: false },
actor: { type: "Admin", id: adminId },
metadata: { bulkOperation: "deactivateByCategory", category },
});
}
}
}Logging an External API Operation
@Injectable()
export class PaymentService {
constructor(private readonly auditLogService: AuditLogService) {}
async refundPayment(
orderId: string,
paymentId: string,
amount: number,
adminId: string,
): Promise<void> {
// Call external payment provider
const result = await this.stripeClient.refunds.create({
payment_intent: paymentId,
amount: Math.round(amount * 100),
});
// Log the refund as an audit event
await this.auditLogService.log({
action: "updated",
entityType: "Order",
entityId: orderId,
oldValues: { paymentStatus: "paid" },
newValues: { paymentStatus: "refunded" },
actor: { type: "Admin", id: adminId },
metadata: {
refundId: result.id,
refundAmount: amount,
provider: "stripe",
},
});
}
}Logging a Business Event
@Injectable()
export class SubscriptionService {
constructor(private readonly auditLogService: AuditLogService) {}
async cancelSubscription(
subscriptionId: string,
userId: string,
reason: string,
): Promise<void> {
// ... cancellation logic ...
await this.auditLogService.log({
action: "updated",
entityType: "Subscription",
entityId: subscriptionId,
oldValues: { status: "active" },
newValues: { status: "cancelled" },
actor: { type: "User", id: userId },
metadata: {
cancellationReason: reason,
effectiveDate: new Date().toISOString(),
},
});
}
}Logging with Request Context
@Controller("users")
export class UserController {
constructor(private readonly auditLogService: AuditLogService) {}
@Post(":id/role")
async changeRole(
@Param("id") userId: string,
@Body() body: ChangeRoleDto,
@Req() req: Request,
): Promise<void> {
const oldRole = await this.getRole(userId);
await this.userRepo.update(userId, { role: body.role });
await this.auditLogService.log({
action: "updated",
entityType: "User",
entityId: userId,
oldValues: { role: oldRole },
newValues: { role: body.role },
actor: { type: "Admin", id: req.user.id },
ipAddress: req.ip,
userAgent: req.headers["user-agent"],
metadata: { endpoint: "POST /users/:id/role" },
});
}
}Logging a Soft Delete
@Injectable()
export class ArticleService {
constructor(
@InjectRepository(Article)
private readonly articleRepo: Repository<Article>,
private readonly auditLogService: AuditLogService,
) {}
async softDelete(articleId: string, userId: string): Promise<void> {
const article = await this.articleRepo.findOneByOrFail({ id: articleId });
// Soft delete via update -- does not trigger the subscriber
await this.articleRepo.update(articleId, { deletedAt: new Date() });
await this.auditLogService.log({
action: "deleted",
entityType: "Article",
entityId: articleId,
oldValues: { title: article.title, status: article.status },
newValues: null,
actor: { type: "User", id: userId },
});
}
}Manual Logging vs. Automatic Logging
| Aspect | Automatic (Subscriber) | Manual (log()) |
|---|---|---|
| Trigger | .save() and .remove() | Explicit call to log() |
| Diff computation | Automatic (old vs. new values) | You provide oldValues and newValues |
| Field filtering | Applied (via only, except, globalExcludedFields) | Not applied -- you control what is logged |
disabledActions | Respected | Not enforced |
| Actor resolution | Automatic via resolver | Automatic unless actor is explicitly provided |
| Metadata | Uses module-level metadata | Uses provided metadata, falls back to module-level |
ipAddress / userAgent | Not captured | Must be provided explicitly |
Next Steps
- Querying -- retrieve and filter audit log entries.
- Actor Resolution -- learn how actor resolution works for both automatic and manual logging.