Tue Dec 19 2023
An audit trail is a record of all activities and transactions that occur within a system. It provides a chronological and detailed history of events, including who performed them, when they were performed, and what changes were made. Audit trails are commonly used in industries such as finance and government to ensure compliance and detect fraudulent activity. However, they’re also common in apps and services that support Organisations or Teams as it keeps a track of what every team member is up to.
In this article, I’ll explain how I added an audit trail to keep a track of User’s actions in my recent project; an e-commerce boilerplate. As it’s a small application, with potentially a handful of users interacting with it at one time, I implemented a simple logging system that records the following;
By default, MySQL will add a createdAt and updatedAt to every record too.
Firstly, we’ll start with database changes. Here we’ll want to create a migration and model both called audit
using node ace make:migration audit
and node ace make:model Audit
.
Inside the audit migration, we want string columns of the following; user_id
, action
, target
and target_id
.
import BaseSchema from "@ioc:Adonis/Lucid/Schema";
export default class extends BaseSchema {
protected tableName = 'audits'
public async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments("id").primary();
table.string("user_id")
table.string("action");
table.string("target");
table.string("target_id");
/**
* Uses timestamptz for PostgreSQL and DATETIME2 for MSSQL
*/
table.timestamp("created_at", { useTz: true });
table.timestamp("updated_at", { useTz: true });
});
}
public async down() {
this.schema.dropTable(this.tableName);
}
}
We’ll also add the same 4 string columns to our Audit model too. In addition, we’ll want to add a relationship to the Audit model so that we can fetch the related User’s information.
import { DateTime } from "luxon";
import { BaseModel, column } from "@ioc:Adonis/Lucid/Orm";
export default class Audit extends BaseModel {
/** Columns */
@column({ isPrimary: true })
public id: number;
@column()
public user_id: string;
@column()
public action: string;
@column()
public target: string;
@column()
public target_id: string;
@column.dateTime({ autoCreate: true })
public createdAt: DateTime;
@column.dateTime({ autoCreate: true, autoUpdate: true })
public updatedAt: DateTime;
/** Relationships */
@belongsTo(() => User, {
foreignKey: "user_id",
localKey: "id",
})
public user: BelongsTo<typeof User>;
}
We’ll then update our User model with this new relation. This way, when we query for a User, we can also return their actions from the Audits table.
...
/** relationships */
@hasMany(() => Audit, {
foreignKey: "user_id",
localKey: "id",
})
public actions: HasMany<typeof Audit>;
...
To record actions to our database, we’ll use Adonis’ event emitter module. For this, we’ll create a new events
file using node ace make:prldfile events
. In this new file, located in the /start folder, we’ll add the following Event listener that will trigger when “audit:new” is triggered within our app.
import Event from "@ioc:Adonis/Core/Event";
import Audit from "App/Models/Audit";
Event.on("audit:new", async ({ user_id, action, target, target_id }) => {
await Audit.create({
user_id: user_id,
action,
target,
target_id: String(target_id),
});
});
To make this typesafe, go to /contracts/events
and add the following interfaces
interface NewAudit {
user_id: string;
action: "CREATE" | "DELETE" | "UPDATE" | "SIGNED IN";
target?: "PRODUCT" | "ORDER" | "USER" | "COLLECTION ";
target_id?: string | number;
}
interface EventsList {
"audit:new": NewAudit;
}
By limiting action
and target
type to only accept a handful of strings, we can ensure that audits follow one pattern throughout our app. i.e
“user string CREATE PRODUCT 1”
or
“user string DELETE COLLECTION 3”
Finally, we want to emit audit:new
events when user’s do important actions such as creating and deleting products. To emit a new event, we import Event from "@ioc:Adonis/Core/Event" and then use Event.emit() where necessary. In the example below, when a new product is created, an event is emitted and a new audit log is stored in the DB with the relevant information on the newly created product.
Event.emit("audit:new", {
user_id: auth.user.id,
action: "CREATE",
target: "PRODUCT",
target_id: product.id,
});
And now we test; below I’m creating a new product, A simple Rocket Booster, in Merchant.
Once created, the “audit:new” event should trigger and we should see a new record in our Audits table.
Voila! and with that we have a working simple audit log to track actions users are taking around our application. We could expand this further in the future by adding a column for storing data changes i.e “before and after” changes, but that’s for a future article!
Thanks for reading and happy coding 😎