Audit Trail & Event Sourcing — Database

Audit Trail Audit trail adalah catatan semua perubahan yang terjadi pada data. Penting untuk compliance, debugging, dan memahami riwayat data. Implementasi…

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

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;

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();
    }
}

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

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.

Yang akan kamu pelajari