Bir CRM projesinde "bu kaydı kim değiştirdi?" sorusu genellikle canlıya çıktıktan birkaç hafta sonra sorulur. Bir lead'in sahibi gece yarısı başka bir danışmana geçmiştir, bir müşterinin telefon numarası boşalmıştır ya da bir satış fırsatının tutarı sıfırlanmıştır. O an elinizde sadece updated_at sütunu varsa, cevabınız "bilmiyoruz" olur. Ben Koray Yalçın; lead yönetimi, CRM entegrasyonu ve satış otomasyonu üzerine çalışıyorum ve gayrimenkul ile B2B satış ekipleri için kurduğum sistemlerde denetim kaydını artık ilk sprintte kuruyorum.

Bu yazıda audit log'un neden bir "log dosyası" olmadığını, veritabanında nasıl modellendiğini, değişikliği kimin ve hangi bağlamda yaptığını nasıl yakaladığımı, kayıtların sonradan değiştirilmediğini nasıl kanıtladığımı ve kişisel verileri denetim kaydına sızdırmadan nasıl sakladığımı anlatıyorum. Sonda sık sorulan soruları cevapladım.

Audit log ile uygulama logu aynı şey değil

Uygulama logu geliştirici içindir: hata ayıklamak, performans izlemek, bir isteğin nerede takıldığını görmek. Kısa süre saklanır, formatı serbesttir, kaybolması felaket değildir.

Denetim kaydı ise iş içindir. Satış müdürü, uyum sorumlusu ya da bir anlaşmazlıkta hukuk ekibi ona bakar. Şu soruları güvenilir biçimde cevaplaması gerekir:

  • Hangi kayıt, hangi alanında, neyden neye değişti?
  • Değişikliği kim yaptı: bir kullanıcı mı, bir entegrasyon mu, zamanlanmış bir görev mi?
  • Hangi ekrandan, hangi API çağrısından ya da hangi otomasyon kuralından geldi?
  • Bu kayıt sonradan değiştirildi mi?

Bu soruların hiçbiri console.log satırlarıyla cevaplanamaz. Yapılandırılmış, sorgulanabilir, uzun süre saklanan ve değiştirilemeyen bir veri yapısı gerekir.

1. Değişmez (append-only) bir tablo ile başlayın

İlk kural basit: denetim tablosuna yalnızca ekleme yapılır. Güncelleme ve silme, uygulama kullanıcısı için veritabanı seviyesinde yasaktır. Bunu kod incelemesine bırakmıyorum; yetkiyle zorluyorum.

CREATE TABLE audit_event ( id BIGSERIAL PRIMARY KEY, occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(), entity_type TEXT NOT NULL, -- 'lead', 'deal', 'contact' entity_id BIGINT NOT NULL, action TEXT NOT NULL, -- 'create', 'update', 'delete', 'merge', 'export' actor_type TEXT NOT NULL, -- 'user', 'integration', 'system' actor_id TEXT NOT NULL, source TEXT NOT NULL, -- 'ui', 'api', 'workflow:lead-routing', 'import' request_id UUID, changes JSONB NOT NULL, -- alan bazlı fark prev_hash BYTEA, row_hash BYTEA NOT NULL ); CREATE INDEX audit_entity_idx ON audit_event (entity_type, entity_id, occurred_at DESC); CREATE INDEX audit_actor_idx ON audit_event (actor_id, occurred_at DESC); REVOKE UPDATE, DELETE, TRUNCATE ON audit_event FROM app_user; GRANT INSERT, SELECT ON audit_event TO app_user; 

Burada iki şeye dikkat ediyorum. Birincisi, action alanında yalnızca CRUD yok: kayıt birleştirme (merge) ve dışa aktarma (export) da ayrı eylemler. Mükerrer lead temizliği yapan ekiplerde "bu iki kayıt neden birleşti?" sorusu, "bu alan neden değişti?" sorusundan daha sık gelir. Dışa aktarma ise kişisel veri açısından en hassas eylemdir; kimin hangi listeyi Excel'e indirdiğini bilmek isteyeceksiniz.

İkincisi, actor_type ayrımı. Sahada değişikliklerin büyük kısmını insanlar değil, entegrasyonlar ve otomasyon kuralları yapar. Lead dağıtım kuralı bir kaydın sahibini değiştirdiğinde denetim kaydında "admin" yazıyorsa, bu bilgi işe yaramaz.

2. Tüm satırı değil, alan farkını saklayın

İlk denemelerde çoğu ekip kaydın eski ve yeni halini komple JSON olarak saklar. Bu hem depolamayı şişirir hem de okumayı zorlaştırır: 60 alanlı bir kişi kaydında neyin değiştiğini bulmak için iki büyük JSON'u gözle karşılaştırmak gerekir.

Ben yalnızca değişen alanları saklıyorum:

from typing import Any IGNORED_FIELDS = {"updated_at", "last_viewed_at", "search_vector"} def field_diff(before: dict[str, Any] | None, after: dict[str, Any] | None) -> dict: before = before or {} after = after or {} changes = {} for key in sorted(set(before) | set(after)): if key in IGNORED_FIELDS: continue old, new = before.get(key), after.get(key) if old != new: changes[key] = {"old": old, "new": new} return changes 

IGNORED_FIELDS listesi önemsiz görünür ama gürültüyü ciddi ölçüde azaltır. Bir kaydın her açılışında güncellenen "son görüntülenme" alanı denetim kaydına giriyorsa, birkaç ay sonra gerçek değişiklikler bu gürültünün altında kaybolur. Boş bir fark çıkarsa olay hiç yazılmaz.

3. Aktör bağlamını istek başında yakalayın

En zor kısım teknik değil, bağlamsal: değişikliği yapan kodun, değişikliği kimin adına yaptığını bilmesi. Bir lead'in sahibi bir HTTP isteğinin içinde, bir kuyruk işçisinin içinde ya da bir gece görevinin içinde değişebilir. Her fonksiyona actor parametresi geçirmek birkaç hafta sonra dağılır.

Bunun yerine bağlamı istek ya da iş başında bir kez kuruyorum ve alt katmanlar oradan okuyor:

import contextvars import uuid from dataclasses import dataclass @dataclass(frozen=True) class AuditContext: actor_type: str actor_id: str source: str request_id: uuid.UUID _current: contextvars.ContextVar[AuditContext | None] = contextvars.ContextVar("audit_ctx", default=None) def set_audit_context(ctx: AuditContext): return _current.set(ctx) def get_audit_context() -> AuditContext: ctx = _current.get() if ctx is None: raise RuntimeError("Denetim bağlamı kurulmadan veri değiştirilemez") return ctx 

HTTP katmanında oturumdaki kullanıcıdan, kuyruk işçisinde mesajın içindeki entegrasyon kimliğinden, zamanlanmış görevde görev adından bağlam kuruluyor. Bağlam yoksa yazma işlemi hata veriyor. Bu bilinçli bir sertlik: "kimin yaptığı bilinmeyen değişiklik" diye bir kategori bırakmak istemiyorum.

Veritabanı tetikleyicisiyle (trigger) audit log tutan ekipler de var. Tetikleyici hiçbir değişikliği kaçırmaz, bu büyük bir avantaj. Ama tetikleyici "kimin" ve "hangi kuraldan" sorularını kendi başına bilemez. Pratik çözüm ikisini birleştirmek: uygulama işlem başında SET LOCAL app.actor_id = '...' gibi oturum değişkenleri ayarlar, tetikleyici bunları okuyarak satırı yazar. Böylece uygulama dışından yapılan doğrudan SQL değişiklikleri de iz bırakır; bağlamı boş olduğu için de hemen göze çarpar.

4. Değiştirilmediğini kanıtlayın: hash zinciri

REVOKE yetkisi uygulama kullanıcısını durdurur ama veritabanı yöneticisini durdurmaz. Bir anlaşmazlıkta "bu kayıtlar sonradan düzenlenmedi" diyebilmek için basit bir hash zinciri kullanıyorum: her satır, kendinden önceki satırın hash'ini içeriyor.

import hashlib import json def compute_row_hash(prev_hash: bytes | None, event: dict) -> bytes: canonical = json.dumps(event, sort_keys=True, ensure_ascii=False, default=str) h = hashlib.sha256() h.update(prev_hash or b"\x00" * 32) h.update(canonical.encode("utf-8")) return h.digest() def verify_chain(rows) -> int | None: prev = None for row in rows: expected = compute_row_hash(prev, row["payload"]) if expected != row["row_hash"] or row["prev_hash"] != prev: return row["id"] # zincirin kırıldığı ilk satır  prev = row["row_hash"] return None 

Ortadaki bir satır değiştirilirse ondan sonraki tüm hash'ler tutmaz. Doğrulama gece görevi olarak çalışıyor ve günün son hash'i ayrı bir yere (farklı erişim yetkili bir depolama alanına) yazılıyor. Bu kripto düzeyinde bir garanti değil, ama "sessizce düzenleme" ihtimalini pratikte ortadan kaldırıyor.

Bir uyarı: tek bir global zincir, yoğun yazma altında darboğaz olur çünkü her ekleme bir öncekini beklemek zorunda kalır. Ben zinciri varlık tipine ya da güne göre bölüyorum. Her bölüm kendi zincirine sahip; bütünlük kontrolü bölüm bazında yapılıyor.

5. Kişisel veriyi denetim kaydına sızdırmayın

Burası en çok gözden kaçan nokta. Denetim kaydı, tasarımı gereği eski değerleri saklar. Bir müşteri KVKK kapsamında verisinin silinmesini istediğinde kişi kaydını silersiniz, ama telefon numarası ve e-posta adresi denetim tablosunda "old" değeri olarak yaşamaya devam eder. Değiştirilemez tablo, silinmesi gereken veriyi değiştirilemez hale getirmiş olur.

Benim yaklaşımım hassas alanları denetim kaydına ham haliyle hiç yazmamak:

import hmac SENSITIVE_FIELDS = {"phone", "email", "national_id", "iban"} def mask_value(field: str, value, key: bytes): if value is None: return None digest = hmac.new(key, f"{field}:{value}".encode(), "sha256").hexdigest()[:16] visible = str(value)[-2:] if field == "phone" else "" return {"masked": True, "fingerprint": digest, "hint": visible} def mask_changes(changes: dict, key: bytes) -> dict: out = {} for field, diff in changes.items(): if field in SENSITIVE_FIELDS: out[field] = {k: mask_value(field, v, key) for k, v in diff.items()} else: out[field] = diff return out 

Böylece denetim kaydı "telefon numarası değişti" bilgisini ve iki değerin farklı olduğunu kanıtlayan parmak izini taşıyor, ama numaranın kendisini taşımıyor. HMAC anahtarı kişi bazında ya da kiracı bazında tutulursa, silme talebinde o anahtarı yok etmek parmak izlerini de anlamsız hale getiriyor. Bu yönteme "kripto ile silme" (crypto-shredding) deniyor ve değiştirilemez kayıtlarla silme hakkını uzlaştırmanın en temiz yolu.

Ham değerin gerçekten gerektiği durumlar için (örneğin dolandırıcılık incelemesi), değerleri ayrı, şifreli, kısa saklama süreli bir tabloda tutuyor ve erişimi rol bazlı olarak birkaç kişiyle sınırlıyorum. O tabloya yapılan her okuma da elbette bir denetim olayı.

6. Okumayı da tasarlayın

Denetim kaydı kimsenin bakmadığı bir tabloya dönüşürse değeri sıfırdır. Satış ekibinin gerçekten kullandığı iki görünüm var:

Kayıt zaman çizelgesi. Lead ya da fırsat ekranında, alan değişikliklerini insan diliyle gösteren bir akış: "Lead sahibi Ayşe'den Mehmet'e geçti — kaynak: dağıtım kuralı 'İskele projesi hafta sonu'". Kaynak alanı burada altın değerinde; çoğu "kim değiştirdi" sorusu aslında "hangi kural değiştirdi" sorusudur.

Aktör raporu. Belirli bir kullanıcı ya da entegrasyonun belirli bir zaman aralığında yaptığı değişiklikler. Bir entegrasyon hatalı veri yazmaya başladığında, etkilenen kayıtların listesini bu rapor verir ve geri alma betiği bu listeyle çalışır.

Geri alma demişken: alan farkı saklamanın güzel bir yan etkisi, belirli bir olaydan sonraki değişiklikleri tersine uygulayabilmeniz. Ama bunu otomatik "geri al" butonu olarak sunmuyorum. Geri alma da bir değişikliktir ve kendi denetim olayını üretir; aradaki başka değişikliklerle çakışma kontrolünden geçmesi gerekir.

7. Saklama süresi ve arşiv

Denetim tablosu hızla büyür. Aylık bölümleme (partitioning) kullanıyorum; sıcak sorgular son birkaç ayın bölümlerine gidiyor, eski bölümler sıkıştırılarak daha ucuz depolamaya taşınıyor. Saklama süresini ise teknik ekip değil, şirketin veri saklama politikası belirliyor. Benim işim politikayı koda dökmek: hangi olay tipi ne kadar saklanır, süre dolunca ne olur, arşivden geri çağırma nasıl yapılır.

Sık sorulan sorular

Audit log için ayrı bir veritabanı mı kullanmalıyım?
Başlangıçta aynı veritabanında, ayrı bir şemada ve ayrı yetkilerle tutmak yeterli; değişiklik ile denetim olayı aynı işlem (transaction) içinde yazılır ve tutarsızlık oluşmaz. Hacim büyüdüğünde olayları outbox deseniyle ayrı bir depoya akıtmak mantıklı hale gelir.

Tetikleyici mi, uygulama katmanı mı?
İkisi birlikte. Tetikleyici hiçbir değişikliği kaçırmamayı, uygulama katmanı ise aktör ve kaynak bağlamını garanti eder. Yalnızca uygulama katmanı, doğrudan SQL değişikliklerini kaçırır; yalnızca tetikleyici, "kim ve neden" sorusunu cevaplayamaz.

Okuma işlemlerini de kaydetmeli miyim?
Her görüntülemeyi değil. Ama dışa aktarma, toplu liste indirme ve hassas alanların açık halinin görüntülenmesi mutlaka kaydedilmeli. Kişisel veri ihlallerinin çoğu bir değişiklikle değil, bir dışa aktarmayla başlar.

Hash zinciri yasal delil niteliği taşır mı?
Tek başına bir garanti değildir; yalnızca kayıtların sonradan değiştirilmediğini teknik olarak göstermeye yardım eder. Hukuki değerlendirme şirketin hukuk danışmanlarına aittir. Benim hedefim, iç anlaşmazlıklarda ve incelemelerde verinin güvenilirliğini tartışma konusu olmaktan çıkarmak.

Mevcut bir CRM'e sonradan audit log eklenebilir mi?
Evet, ama geçmiş geri gelmez. Eklediğiniz günden itibaren kayıt tutarsınız. Bu yüzden yeni projelerde ilk sprintte kurmayı öneriyorum; maliyeti o aşamada en düşük seviyededir.

HubSpot, Salesforce gibi hazır CRM'lerde bu yapıyı kurmak gerekir mi?
Bu platformların kendi alan geçmişi özellikleri var, ancak genellikle alan sayısı ve saklama süresi sınırlı. Kendi entegrasyon katmanınız varsa, oradan geçen değişiklikleri kendi denetim deponuza yazmak, platform sınırlarından bağımsız bir iz bırakır.

Kapanış

İyi bir denetim kaydı yedi kararın toplamı: yalnızca eklenen bir tablo, alan bazlı farklar, istek başında kurulan aktör bağlamı, değiştirilmediğini gösteren bir hash zinciri, kişisel veriyi maskeleyen bir yazma katmanı, gerçekten kullanılan okuma ekranları ve politikaya bağlı saklama süresi. Bunların hiçbiri tek başına karmaşık değil; zor olan, ilk günden birlikte düşünmek.

Bu yazıdaki kod örnekleri kendi projelerimde kullandığım yapıların sadeleştirilmiş halleri. Farklı bir dil ya da veritabanı kullanıyorsanız mantık aynı kalır.

Koray Yalçın — lead yönetimi, CRM entegrasyonu ve satış otomasyonu üzerine çalışıyor.