Audit trail adalah catatan setiap perubahan data: tabel mana, baris mana, aksinya apa (INSERT, UPDATE atau DELETE), isinya sebelum dan sesudah, siapa yang mengubah, dan kapan. Mirip buku mutasi rekening. Aplikasi bank cuma menampilkan saldo terakhir, tapi buku mutasi mencatat tiap uang masuk dan keluar, jadi kamu bisa menelusuri dari mana angka terakhir itu datang. Tabel products itu saldonya, tabel audit itu buku mutasinya.
🧪 Tebakan cepat: Kedai Senja mencatat audit dari aplikasi Laravel, lewat event model Eloquent. Suatu malam admin menjalankan
DB::table('products')->increment('price', 2000), yang menaikkan harga 4 produk sekaligus. Berapa baris audit yang tercatat: 4, 1, atau 0? Tebak dulu, jawabannya ada di bagian "Cara 2: log dari aplikasi".
Bentuk satu baris audit
Kita lanjut dengan Kedai Senja. Tabel products berisi Kopi Susu (Rp18.000), Kopi Susu Aren (Rp22.000), Teh Tarik (Rp15.000) dan Roti Bakar (Rp20.000). Di sebelahnya kita buat tabel audit_logs:
CREATE TABLE audit_logs (
id INTEGER PRIMARY KEY,
table_name TEXT NOT NULL, -- tabel yang berubah
record_id INTEGER NOT NULL, -- id baris yang berubah
action TEXT NOT NULL, -- 'INSERT', 'UPDATE' atau 'DELETE'
old_values TEXT, -- isi sebelum berubah, NULL untuk INSERT
new_values TEXT, -- isi sesudah berubah, NULL untuk DELETE
user_id INTEGER, -- siapa yang mengubah
created_at TEXT DEFAULT CURRENT_TIMESTAMP -- kapan
);
Contohnya, satu baris per aksi:
| action | table_name | record_id | old_values | new_values |
|---|---|---|---|---|
| INSERT | products | 5 | NULL | {"name":"Es Kopi Hitam","price":16000} |
| UPDATE | products | 1 | {"name":"Kopi Susu","price":18000} | {"name":"Kopi Susu","price":19000} |
| DELETE | products | 4 | {"name":"Roti Bakar","price":20000} | NULL |
- INSERT belum punya isi lama, jadi
old_valuesdiisiNULL. - DELETE gak punya isi baru, jadi
new_valuesdiisiNULL. - UPDATE punya dua-duanya, jadi kelihatan persis apa yang berubah: harga 18000 jadi 19000.
- Kolom minimal sebuah audit: siapa (
user_id), apa (action), sebelum dan sesudah (old_values,new_values), dan kapan (created_at). Isi lama dan barunya disimpan sebagai teks JSON. Di PostgreSQL, kolomnya bertipeJSONB.
Ada dua cara mengisi tabel ini: database yang mencatat sendiri, atau aplikasi yang mencatat.
Cara 1: trigger di database
Trigger adalah perintah SQL yang dijalankan database secara otomatis setiap kali sesuatu terjadi di sebuah tabel. Di SQLite, satu trigger untuk satu aksi:
CREATE TRIGGER products_audit_update
AFTER UPDATE ON products
BEGIN
INSERT INTO audit_logs (table_name, record_id, action, old_values, new_values)
VALUES ('products', OLD.id, 'UPDATE',
json_object('name', OLD.name, 'price', OLD.price),
json_object('name', NEW.name, 'price', NEW.price));
END;
AFTER UPDATE ON products: jalan setelah setiap UPDATE diproducts, satu kali untuk tiap baris yang kena.OLDadalah isi baris sebelum berubah,NEWisinya sesudah berubah. Trigger DELETE cuma punyaOLD, trigger INSERT cuma punyaNEW.json_object(...)merangkai kolom-kolomnya jadi teks JSON.
Sekarang naikkan semua harga, dari mana pun perintahnya datang:
UPDATE products SET price = price + 2000;
SELECT action, record_id, old_values, new_values, user_id FROM audit_logs;
| action | record_id | old_values | new_values | user_id |
|---|---|---|---|---|
| UPDATE | 1 | {"name":"Kopi Susu","price":18000} | {"name":"Kopi Susu","price":20000} | NULL |
| UPDATE | 2 | {"name":"Kopi Susu Aren","price":22000} | {"name":"Kopi Susu Aren","price":24000} | NULL |
| UPDATE | 3 | {"name":"Teh Tarik","price":15000} | {"name":"Teh Tarik","price":17000} | NULL |
| UPDATE | 4 | {"name":"Roti Bakar","price":20000} | {"name":"Roti Bakar","price":22000} | NULL |
4 baris berubah, 4 baris audit. Trigger gak bisa dilewati: perubahan dari aplikasi, dari console database, atau dari script lain, semuanya tercatat. Kelemahannya ada di kolom terakhir: user_id kosong. Database cuma tahu koneksinya, bukan user aplikasi mana yang sedang login.
Di PostgreSQL, satu function trigger bisa melayani ketiga aksi: TG_OP berisi 'INSERT', 'UPDATE' atau 'DELETE', dan to_jsonb(OLD) mengubah seluruh baris jadi JSONB. User aplikasinya bisa dititipkan per transaksi dengan SET LOCAL app.user_id = '5', lalu dibaca trigger dengan current_setting('app.user_id', true).
Cara 2: log dari aplikasi
Di Laravel, cara yang umum adalah package spatie/laravel-activitylog. Tambahkan trait LogsActivity ke model, dan setiap model yang dibuat, diubah atau dihapus otomatis tercatat di tabel activity_log, lengkap dengan user yang sedang login (disebut causer):
use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Traits\LogsActivity;
class Product extends Model
{
use LogsActivity;
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()->logOnly(['name', 'price'])->logOnlyDirty();
}
}
logOnly([...]): kolom yang dicatat. Kolom rahasia seperti password atau token jangan ikut.logOnlyDirty(): yang dicatat cuma kolom yang benar-benar berubah.- Package ini bekerja lewat event model Eloquent (
created,updated,deleted). Dewi, admin Kedai Senja dengan user id 5, mengubah harga Kopi Susu di halaman admin: tercatat, dengan user id Dewi.
Masalahnya, event model cuma terpicu kalau perubahannya lewat model, satu per satu. DB::table('products')->increment('price', 2000) atau Product::where(...)->update([...]) langsung mengirim satu UPDATE ke database tanpa memuat model. Gak ada event, gak ada log: 4 produk berubah, 0 baris audit. Itu jawaban tebakannya. Hal yang sama terjadi untuk SQL yang diketik di console database dan perubahan dari aplikasi lain.
Trigger atau log aplikasi?
| Trigger database | Log aplikasi | |
|---|---|---|
| Perubahan lewat model | tercatat | tercatat |
| Update massal, SQL di console, aplikasi lain | tercatat | terlewat |
| Siapa user-nya, IP, halaman | gak tahu, kecuali dititipkan | tahu |
| Letak logikanya | di database, gak kelihatan di kode | di kode aplikasi |
Data yang diawasi ketat (saldo, harga, stok) sering memakai dua-duanya: trigger supaya gak ada perubahan yang lolos, log aplikasi supaya tahu siapa pelakunya.
📌 Event sourcing
Audit trail menempel ke tabel biasa: products tetap menyimpan harga terakhir, audit cuma catatan pinggir. Event sourcing membaliknya. Yang disimpan justru daftar kejadian (OrderCreated, ItemAdded, PaymentMade, OrderShipped), dan isi pesanan saat ini dihitung ulang dengan memutar ulang semua kejadian itu dari awal. Riwayatnya lengkap dan gak pernah diubah, tapi pertanyaan sederhana seperti "pesanan ini isinya apa?" jadi lebih rumit dijawab. Pakai hanya kalau riwayat itu memang inti bisnisnya.
⚠️ Jebakan yang sering ketemu
- Mengandalkan log aplikasi untuk update massal.
increment()atauupdate()pada query, dan SQL mentah, gak memicu event model. Kalau perubahan seperti itu wajib tercatat, pakai trigger. - Mencatat kolom rahasia. Password hash, token atau nomor kartu yang masuk
old_valuessekarang tersimpan dua kali. Batasi kolom yang dicatat. - Tabel audit dibiarkan tumbuh. Satu update massal 10.000 baris berarti 10.000 baris audit. Rencanakan arsip untuk data lama.
- Di latihan, isi yang gak ada tetap ditulis
null. INSERT:old_values: null. DELETE:new_values: null. Key-nya jangan dihapus, karena object-nya dibandingkan utuh.
TL;DR: Audit trail mencatat setiap perubahan data: action (INSERT, UPDATE, DELETE), table_name, record_id, old_values, new_values, user_id dan created_at. INSERT gak punya isi lama (old_values NULL), DELETE gak punya isi baru (new_values NULL). Trigger database mencatat semua perubahan, termasuk update massal dan SQL dari console, tapi gak tahu user aplikasinya. Log aplikasi seperti spatie/laravel-activitylog tahu siapa yang login, tapi melewatkan update massal dan SQL mentah karena event model gak terpicu. Data penting sering memakai dua-duanya.