Log, Telemetry ve Analytics'te Kişisel Veri: URL'ler, Exception Mesajları, IP Adresi, Session Replay ve Hata Raporlama Araçları
Kişisel veri log'a çoğu zaman bir LogInformation satırıyla değil; URL'deki e-postayla, log'a verilen bir record'la, exception mesajıyla, span tag'iyle ve metric label'ıyla girer. Bookstore'un checkout akışında bu sızıntıları gerçek çıktılarla gösteriyor, canary değerleri arayan bir testle kilitliyor, tarayıcı hata raporlarını temizleyip IP adresini kısaltıyor ve telemetry'yi envantere ekliyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10, ASP.NET Core 10, Entity Framework Core 10.0.15 (SQLite), Microsoft.Extensions.Diagnostics.Testing 10.11 ve xUnit 2.9 ile derlenip denenmiştir. Hukuki ifadeler ve Google'ın analytics politikası 15 Kasım 2026'da kvkk.gov.tr, mevzuat.gov.tr ve Google'ın yardım sayfasından doğrulanmıştır.
Bu seri bir geliştiricinin bakışıdır, hukuki tavsiye değildir. Log'ların ne kadar saklanacağına, hangi hukuki sebebe dayandığına ve kimin erişeceğine hukuk ve güvenlik ekipleri karar verir; bu yazıdaki süreler örnektir.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore'da (bir online kitapçı) misafir müşteriler siparişlerini e-posta adresi ve sipariş numarasıyla takip ediyor. Endpoint GET /api/orders/lookup?email=...&orderId=.... Veritabanındaki e-posta sütunu 02. yazıda sınıflandırıldı, 03. yazıda erişimi kısıtlandı, 04. yazıda silinme yolu yazıldı. Bu e-posta adresi şu an kaç yerde duruyor? Bir ipucu: cevap veritabanından bağımsız ve kodda tek bir LogInformation satırı bile gerekmiyor.
Kişisel Veri Telemetry'ye Nereden Giriyor
KVKK'nın (6698 sayılı Kişisel Verilerin Korunması Kanunu) 4. maddesi verinin işlendiği amaçla bağlantılı, sınırlı ve ölçülü olmasını, 12. maddesi de verinin hukuka aykırı erişime karşı korunmasını istiyor. Log, trace ve analytics de birer işleme faaliyeti: veriyi kaydediyor, saklıyor ve çoğu zaman başka bir sağlayıcıya aktarıyor. Üstelik bu kopyalar veritabanındaki kayıttan farklı olarak sınıflandırılmamış, erişimi geniş ve silinmesi zor. 04. yazıdaki silme akışı bunlara ulaşamıyor; tek gerçekçi çözüm, kişisel verinin buralara hiç girmemesi. Bookstore'un checkout akışında verinin telemetry'ye girdiği yerler ve her birinin kontrolü:

Log redaction'ın mekaniğini (data classification attribute'ları, redactor, [LogProperties]) .NET'te Logging serisinin "Log'larda Hassas Veri: Redaction, Data Classification ve KVKK'ya Uygun Logging" yazısında, OpenTelemetry Collector'da PII (kişiyi tanımlayan bilgi) filtrelemeyi Observability serisinin "OpenTelemetry Collector: Receiver, Processor, Exporter, Tail Sampling ve PII Filtreleme" yazısında anlattık. Bu yazı o araçların hangi soruya cevap verdiğine bakıyor: Bookstore'da kişisel veri nereden sızıyor ve bunu kim, nasıl fark ediyor.
URL'deki E-posta
Giriş sorusundaki endpoint'i çağırıp API'nin varsayılan log çıktısına baktım. Uygulama kodunda tek bir log satırı yok; satırı ASP.NET Core'un hosting katmanı yazıyor:
info: Microsoft.AspNetCore.Hosting.Diagnostics[1]
Request starting HTTP/1.1 GET http://127.0.0.1:52801/api/orders/lookup?email=ada.testwell@example.com&orderId=7 - - -
Projede appsettings.json olmadığı için varsayılan seviye Information ve hosting her isteğin URL'ini query string'iyle birlikte yazıyor. Aynı URL reverse proxy'nin access log'una, CDN (içerik dağıtım ağı) log'larına, tarayıcı geçmişine ve sayfadan çıkılırken başka bir siteye giden Referer başlığına da düşüyor. Log seviyesini Warning'e çekmek bu satırı gizler ama sorunu çözmez: biri bir gece hata ayıklamak için seviyeyi açtığında e-posta adresleri yeniden akmaya başlar. Doğru çözüm, kişisel veriyi URL'de taşımamak:
// URL'de değil body'de: URL'ler access log'larına, proxy'lere ve tarayıcı geçmişine düşer.
public sealed record OrderLookupRequest([property: PersonalData(InformationType = "Contact Info")] string Email, int OrderId);
app.MapPost("/api/orders/lookup", async (OrderLookupRequest request, OrderLookupService lookup, CancellationToken ct) =>
await lookup.FindAsync(request, ct) is { } status ? Results.Ok(status) : Results.NotFound());
Arama sayfaları, şifre sıfırlama linkleri ve e-postadaki "siparişini takip et" butonları da aynı tuzağa düşer. Linkte kişiyi taşıman gerekiyorsa e-posta adresi yerine tahmin edilemeyen, süreli ve tek amaçlı bir token kullan; token log'a düşse bile tek başına kişiyi göstermez.
Log Mesajı, Exception, Span ve Metric
Checkout servisinin ilk hâli, bir ekipte gerçekten yazılabilecek üç satır içeriyordu:
logger.LogInformation("Checkout started: {Request}", request);
activity?.SetTag("customer.email", customer.Email);
OrdersPlaced.Add(1, new KeyValuePair<string, object?>("customer.id", customer.Id));
logger.LogInformation("Order {OrderId} placed by {Email}", order.Id, customer.Email);
İlk satır en az dikkat çekeni. CheckoutRequest bir record ve derleyicinin ürettiği ToString bütün property'leri yazıyor:
info: Bookstore.Ordering.CheckoutService[0]
Checkout started: CheckoutRequest { ShippingAddress = Example St. 1, Ankara, BillingName = Ada Testwell, Total = 412,50 }
info: Bookstore.Ordering.CheckoutService[0]
Order 7 placed by ada.testwell@example.com
Bugün record'da adres ve isim var; yarın biri Phone ya da fatura için kimlik numarası eklediğinde log satırı sessizce genişler. Exception mesajları da aynı yoldan sızıyor. Takip servisinin ilk hâli sipariş bulunamayınca KeyNotFoundException($"No order {orderId} for {email}") fırlatıyordu ve exception middleware mesajı olduğu gibi log'a yazdı (stack trace kısaltıldı):
fail: Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddleware[1]
An unhandled exception has occurred while executing the request.
System.Collections.Generic.KeyNotFoundException: No order 99 for ada.testwell@example.com
Exception mesajları log'dan sonra hata raporlama aracına, alert e-postasına ve ticket'a kopyalanıyor; yani en uzak yere giden metin. "Bulunamadı" bir hata değil, normal bir sonuç: servis null dönüyor, endpoint 404 veriyor ve mesajda kullanıcı verisi olmuyor.
Span tag'i ve metric label'ı iki ayrı sorun. Span'e yazılan e-posta trace backend'ine gider ve trace'leri okuyan herkes görür. Metric tarafında sorun içerik değil kardinalite: customer.id label'ı her müşteri için ayrı bir time series üretir, metric backend'inin maliyetini büyütür ve sayaçları kişi bazında bir aktivite kaydına çevirir. Metric'te yalnız düşük kardinaliteli ve kişisel olmayan label'lar kalmalı. Düzeltilmiş hâl:
// Yalnız ID: müşteri numarası kayda işaret eder, kayıt veritabanında kalır.
activity?.SetTag("order.id", order.Id);
OrdersPlaced.Add(1, new KeyValuePair<string, object?>("channel", "web"));
logger.LogInformation("Order {OrderId} placed by customer {CustomerId}", order.Id, customer.Id);
info: Bookstore.Ordering.CheckoutService[0]
Order 7 placed by customer 1
Müşteri numarası da kişisel veri; ama pseudonymous (takma adlı) bir veri ve yalnız erişimi kısıtlı veritabanıyla birlikte bir kişiye bağlanıyor. Log'da bir olayı soruşturmak için yeterli, log tek başına sızsa bile ad ve adres içermiyor. Bu bir ödünleşim ve kararını envantere yazmak gerekiyor; birazdan döneceğiz.
Sızıntıyı Testle Yakalamak
Code review'da yukarıdaki satırları yakalamak mümkün ama güvenilir değil: {Request} masum görünüyor. Bunun yerine testin kendisi kişisel veriyi biliyor ve onu her yerde arıyor. Test, checkout'u bilinen sahte verilerle (canary değerleri) çalıştırıyor; log'ları FakeLogger ile, span'leri bir ActivityListener ile, metric label'larını bir MeterListener ile topluyor:
[Fact]
public async Task Checkout_telemetry_contains_no_personal_data()
{
using var testDb = new TestDb();
await using BookstoreContext db = testDb.CreateContext();
Customer ada = FakePeople.ToCustomer(0, Now.AddMonths(-8));
db.Customers.Add(ada);
await db.SaveChangesAsync();
var request = new CheckoutRequest($"Example St. 1, {ada.City}", ada.FullName, 412.50m);
using var telemetry = new TelemetryCapture("Bookstore.Ordering");
var checkout = new CheckoutService(db, new FixedClock(Now), new FakeLogger<CheckoutService>(telemetry.Logs));
await checkout.PlaceOrderAsync(ada.Id, request);
// Canary değerleri: test hangi kişisel veriyi verdiğini bildiği için onu her yerde arayabilir.
string[] leaks = [.. telemetry.FindLeaks([ada.FullName, ada.Email, ada.Phone!, request.ShippingAddress])];
Assert.True(leaks.Length == 0, "Personal data in telemetry:\n" + string.Join("\n", leaks));
string[] tags = [.. telemetry.UnexpectedMetricTags(new HashSet<string> { "channel" })];
Assert.True(tags.Length == 0, "Metric tags not in the allowlist: " + string.Join(", ", tags));
}
TelemetryCapture küçük bir sınıf: FakeLogCollector'dan her kaydın mesajını, structured log alanlarını ve exception mesajını, durmuş span'lerin adını ve tag'lerini topluyor. Servisin ilk hâliyle test şu çıktıyla kırıldı:
Failed Bookstore.Privacy.Tests.TelemetryTests.Checkout_telemetry_contains_no_personal_data [386 ms]
Personal data in telemetry:
log: Checkout started: CheckoutRequest { ShippingAddress = Example St. 1, Ankara, BillingName = Ada Testwell, Total = 412,50 } <-- contains 'Ada Testwell'
log: Checkout started: CheckoutRequest { ShippingAddress = Example St. 1, Ankara, BillingName = Ada Testwell, Total = 412,50 } <-- contains 'Example St. 1, Ankara'
log field Request: CheckoutRequest { ShippingAddress = Example St. 1, Ankara, BillingName = Ada Testwell, Total = 412,50 } <-- contains 'Ada Testwell'
log field Request: CheckoutRequest { ShippingAddress = Example St. 1, Ankara, BillingName = Ada Testwell, Total = 412,50 } <-- contains 'Example St. 1, Ankara'
log: Order 1 placed by ada.testwell@example.com <-- contains 'ada.testwell@example.com'
log field Email: ada.testwell@example.com <-- contains 'ada.testwell@example.com'
span tag customer.email: ada.testwell@example.com <-- contains 'ada.testwell@example.com'
Düzeltmeden sonra test geçiyor ve projedeki 19 testin hepsi yeşil. Testin değeri bugünkü kodu değil, yarınki değişikliği kontrol etmesi: biri checkout'a yeni bir log satırı ya da span tag'i eklediğinde pull request'te kırılıyor. Aynı kalıbı kişisel veri işleyen her akışa (kayıt, şifre sıfırlama, iade) kopyalamak ucuz. Bu test redaction'ın yerine geçmiyor; redaction Logging serisinde anlattığımız gibi sınıflandırılmış alanları otomatik maskeliyor, bu test ise sınıflandırmanın unutulduğu yeri gösteriyor.
Tarayıcı Hataları, IP Adresi ve Session Replay
Frontend hata raporlama araçları ve session replay kayıtları, kullanıcının ekranında ne varsa onu toplayabiliyor: URL, form alanları, sayfa metni, network istekleri. Bookstore'da tarayıcı hatalarını kendi endpoint'imizde topluyoruz; böylece ne saklanacağına kayıttan önce biz karar veriyoruz. Gelen metin bizim değil, o yüzden temizlenerek yazılıyor:
app.MapPost("/api/client-errors", (ClientErrorReport report, HttpContext http, ILogger<ClientErrorReport> logger) =>
{
logger.LogWarning("Client error at {Page}: {Message} (client {ClientNetwork})",
TelemetryScrubber.ScrubUrl(report.Url, ClientErrorReport.AllowedQueryKeys),
TelemetryScrubber.ScrubText(report.Message),
TelemetryScrubber.TruncateIp(http.Connection.RemoteIpAddress));
return Results.Accepted();
});
ScrubUrl path'i ve izin verilen query anahtarlarını (page, category) bırakıp fragment'ı ve diğer parametreleri atıyor; ScrubText e-posta adreslerini ve 10 haneden uzun sayıları (kimlik, kart ve telefon numarası) yer tutucuyla değiştiriyor; TruncateIp IPv4 adresinin son oktetini, IPv6 adresinin ilk 48 bitten sonrasını sıfırlıyor. Yerelde bir rapor gönderdim (rapordaki kimlik numarası kontrol hanesi bilerek bozuk sahte bir değer):
curl -X POST http://127.0.0.1:52801/api/client-errors -H "Content-Type: application/json" \
-d '{"message":"TypeError: cannot read invoice of ada.testwell@example.com (id 23515709358)","url":"http://127.0.0.1:52806/orders/track?email=ada.testwell%40example.com&page=2#ref=x","stack":null}'
warn: Bookstore.Api.ClientErrorReport[0]
Client error at /orders/track?page=2: TypeError: cannot read invoice of [email] (id [number]) (client 127.0.0.0)
Regex tabanlı temizlik bir güvenlik ağı, garanti değil: bir ismi ya da adresi tanıyamaz. Bu yüzden asıl kural istemci tarafında: hata raporuna form değerleri, request body'si ve kullanıcı bilgisi konmuyor. IP kısaltmanın dayanağı da ölçülülük; Kurum'un Çerez Uygulamaları Hakkında Rehber'i (KVKK Yayınları No: 69, Temmuz 2025) birinci taraf analitik çerezler için kimlik belirleme riskini azaltan bir teknik olarak IP bilgisinin maskelenmesini örnek veriyor. Kısaltılmış IP yine de tek başına anonim veri sayılmaz; başka verilerle birleşince kişiye yaklaşabilir, envanterde kişisel veri olarak kalır.
Session replay için ayrı bir karar gerekiyor. Bu araçlar kullanıcının sayfadaki hareketlerini kaydettiği için checkout, hesap ve adres sayfalarında kayıt ya hiç açılmamalı ya da bütün metin ve input'lar maskeli olmalı. Kullandığın aracın maskeleme varsayılanlarını ve network kaydını dokümanından kontrol et; bu yazıda üçüncü taraf bir hata raporlama ya da session replay aracı çalıştırılmadı. Bu araçlar aynı zamanda çerez ve açık rıza konusu, bir sonraki yazıda dönüyoruz.
Analytics, Retention ve Erişim
Google'ın analytics yardım sayfası, Google'a kişiyi tanımlayan veri gönderilmemesini istiyor ve sızıntının en sık URL'ler, sayfa başlıkları, custom dimension'lar ve kullanıcının arama kutusuna yazdığı metinler üzerinden olduğunu söylüyor. GA4 (Google'ın analytics ürünü Google Analytics 4) web stream'lerinde e-posta adreslerini ve seçilen query parametrelerini ayıklayan bir data redaction ayarı var; Google bunu "best-effort" olarak tanımlıyor, yani son savunma hattı olarak bırakmak gerekiyor. Bookstore'da bu, yukarıdaki URL kuralının analytics karşılığı: sayfa yolları kişisel veri içermiyor, arama terimi analytics'e hiç gönderilmiyor. GA4 kurulumunu ve event modelini Analytics ve Ölçüm serisinde ayrıca anlatacağız.
Telemetry kopyaları da envantere giriyor. 02. yazıdaki ProcessingActivities listesine kendi tablosu olmayan bir faaliyet ekledik:
// Log'lar, trace'ler ve tarayıcı hata raporları: kendi tabloları yok ama aynı sorular geçerli.
new("telemetry", "Operations, debugging, security monitoring", "Legitimate interest",
"Example: logs 30 days, traces 7 days, error reports 30 days", ["Developer", "SRE"], ["log-store", "trace-store", "error-reports"]),
Buradaki süreler örnek; asıl değer, sorunun sorulmuş olması. Retention log backend'inde ayarlanıyor (Application Insights ve Log Analytics'te retention ve maliyeti Observability serisinin "Production'da Observability: Azure Monitor ve Application Insights, Sampling, Maliyet, PII ve Kubernetes'te Otomatik Instrumentation" yazısında anlattık), envanterdeki değer onunla aynı olmalı. Erişim de bir kontrol: destek ekibinin bir müşterinin şikâyetini çözmek için ihtiyacı log'un tamamı değil, sipariş numarasına göre süzülmüş bir görünüm. Ham log'a erişimi yalnız geliştirici ve operasyon rollerine vermek, log'u okuyan kişi sayısını küçültüyor.
Üretimde Dikkat
Log seviyesi bir konfigürasyon değeri ve production'da değişebiliyor. Bu örnekte Microsoft.AspNetCore ve Microsoft.EntityFrameworkCore.Database.Command kategorileri Warning'de; Entity Framework Core'un SQL log'u Information seviyesinde bile parametre değerlerini '?' olarak gösteriyor, ama EnableSensitiveDataLogging() açılırsa değerler log'a girer. Bu ayar yalnız geliştirme ortamında açılmalı ve production konfigürasyonunda açık kalmadığını bir test ya da başlangıç kontrolü doğrulamalı. Development'ta açık olan developer exception page de exception mesajını HTTP yanıtına yazıyor; production'da UseExceptionHandler ile mesajsız bir ProblemDetails dön. Son olarak, sızıntıyı bulduğunda kodu düzeltmek yetmiyor: log backend'inde geriye dönük kayıtlar duruyor. Retention'ın onları ne zaman düşüreceğini bil, gerekiyorsa sağlayıcının silme ya da purge özelliğini kullan ve olayı hukuk ekibine bildir; bu bir veri ihlali değerlendirmesi gerektirebilir, 07. yazının konusu.
Sık Düşülen Tuzaklar
En yaygını "geçici" açılan Debug ya da Information log'unun production'da kalması; sorun bir hafta sonra kapanır, ayar aylarca açık kalır. İkincisi, bir record'u ya da bir DTO'yu (Data Transfer Object; katmanlar arasında veri taşıyan class) log mesajına olduğu gibi vermek. Üçüncüsü, kişisel veriyi metric label'ı yapmak: hem gizlilik hem maliyet sorunu. Dördüncüsü, e-posta adresinin hash'ini log'a yazıp anonim sanmak; 01. yazıda gördüğümüz gibi hash bilinen bir e-posta listesiyle geri eşleşir. Beşincisi, hata raporlama ya da session replay aracını varsayılan ayarlarla checkout sayfasında açmak. Altıncısı, log'u sızıntı sonrası temizlemeyi unutmak: kod düzelir, eski kayıtlar retention bitene kadar okunabilir kalır.
Bu Yazının Dayandığı Kaynaklar
Erişim tarihi 15 Kasım 2026.
- 6698 sayılı Kanun md. 4 ve 12:
mevzuat.gov.tr/mevzuatmetin/1.5.6698.pdf - KVKK, Çerez Uygulamaları Hakkında Rehber, KVKK Yayınları No: 69, Temmuz 2025, bölüm 5.9:
kvkk.gov.tr/SharedFolderServer/CMSFiles/fb193dbb-b159-4221-8a7b-3addc083d33f.pdf - Google Analytics yardım sayfası, kişiyi tanımlayan veri gönderilmemesi:
support.google.com/analytics/answer/6366371
Küçük Bir Deneme
★☆☆ TelemetryTests'e iade akışı için ikinci bir test ekle: iade talebi müşterinin IBAN'ını (uluslararası banka hesap numarası) alıyor olsun ve servisin bir log satırında talebi record olarak yazdığını, testin bunu yakaladığını gör.
★★☆ TelemetryCapture'a exception'ların Data sözlüğünü ve span event'lerini de ekle; sonra bir catch bloğunda activity?.AddException(ex) çağıran kodun e-posta adresini trace'e taşıdığını testle göster.
★★★ Production konfigürasyonunu (appsettings.Production.json) okuyan bir test yaz: Microsoft.AspNetCore seviyesi Warning'in altına inerse ya da Entity Framework Core'un sensitive data logging'i açık kalırsa kırılsın. Aynı testi CI'da konfigürasyon değişikliği yapan her pull request için çalıştır.
Log'lardan kişisel veriyi çıkardık; analytics'in kendisine ise henüz hiç onay sormadık. Bir sonraki yazıda aydınlatma ile açık rıza arasındaki farka, ConsentRecord veri modeline, çerez banner'ının "reddet" butonuna, Google Consent Mode'a ve ticari ileti izinlerinin İYS'ye bildirilmesine bakıyoruz.