Yapılandırılmış log: grep alışkanlığından sorgulanabilir kayda
Metin logundan JSON loga geçiş: önce alan sözlüğü, sonra istek bağlamı, sonra kademeli çeviri. jq ve Loki sorgu örnekleri ve kardinalite, maskeleme, birim tuzakları.
Çoğu projede log satırı bir insan tarafından okunmak için yazılıyor ve bir insan tarafından grep ile aranıyor. Sistem küçükken bu yetiyor. Bir gün "dün 14:00 ile 15:00 arasında ödeme adımında hata alan kaç farklı kullanıcı var" sorusu geliyor ve cevap, üç sunucudan dosya çekip grep | awk | sort | uniq -c zinciri kurmak oluyor. Zincir yanlış satırı da sayıyor, doğru satırı da kaçırıyor.
Bu yazının sorusu dar: metin loglarından yapılandırılmış loga, çalışan sistemi durdurmadan nasıl geçilir.
Metin logunun asıl kusuru
Şu satıra bakın:
2026-09-13 14:02:11 ERROR Ödeme başarısız: kullanıcı 4812, sipariş A-9931, sebep: kart reddedildi (tutar 1250.00)
İnsan için okunaklı. Makine için tek bir uzun metin. Kullanıcı kimliğini çıkarmak için bir düzenli ifade gerekiyor ve o ifade, geliştiricinin bir gün cümleyi "Kullanıcı #4812 için ödeme başarısız" diye değiştirmesiyle sessizce bozuluyor. Metin logunda biçim bir sözleşme değil, bir alışkanlık.
Aynı olay yapılandırılmış hali:
{"ts":"2026-09-13T11:02:11.482Z","level":"error","event":"payment.failed","user_id":4812,"order_id":"A-9931","reason":"card_declined","amount_minor":125000,"request_id":"01J7Z…","service":"checkout"}
Burada mesaj cümlesi yok, olay adı var. Her alan bir anahtar. Sorgu artık metin aramıyor, alan eşleştiriyor.
Önce şemayı yazın, kodu sonra
Geçişte en sık yapılan hata, loglayıcıyı JSON çıktıya çevirip her geliştiricinin alan adını kendi seçmesine izin vermek. Bir modülde userId, diğerinde user_id, üçüncüsünde uid çıkıyor. Sorgulanabilirlik vaadi ilk haftada ölüyor.
Kodu değiştirmeden önce repoya kısa bir alan sözlüğü koyun:
# docs/log-alanlari.yaml
zorunlu:
ts: ISO-8601, UTC
level: debug | info | warn | error
event: nokta ayrılmış, geçmiş zaman (payment.failed, user.signed_in)
service: servis adı
request_id: isteğe bağlı değil; yoksa "none"
ortak:
user_id: tamsayı
tenant_id: tamsayı
duration_ms: tamsayı
amount_minor: kuruş cinsinden tamsayı
yasak:
- password, token, authorization, kart numarası, TCKN
event alanının kuralı önemli. "Ödeme başarısız oldu çünkü..." yerine payment.failed ve ayrı bir reason alanı. Olay adları sınırlı bir küme olarak kalmalı; değişken değer olay adına gömülürse (payment.failed.4812) gruplama imkânsızlaşır.
Bağlamı her satıra elle yazmayın
request_id ve user_id her log çağrısına tek tek eklenirse, birinin unutması an meselesi. Bağlamı istek başında bir kez bağlayıp alt çağrılara taşımak gerekiyor. Node.js'te AsyncLocalStorage bunu sağlıyor:
import { AsyncLocalStorage } from "node:async_hooks";
import pino from "pino";
const baglam = new AsyncLocalStorage();
const kok = pino({ base: { service: "checkout" }, timestamp: pino.stdTimeFunctions.isoTime });
// istek içindeyse bağlamlı çocuk loglayıcı, değilse kök
export const log = () => baglam.getStore() ?? kok;
export function istekBaglami(req, res, next) {
const request_id = req.headers["x-request-id"] ?? crypto.randomUUID();
const cocuk = kok.child({ request_id, user_id: req.user?.id ?? null });
baglam.run(cocuk, next);
}
// herhangi bir derinlikte:
log().error({ event: "payment.failed", order_id, reason: "card_declined" });
Python'da structlog ile contextvars, Go'da slog ile context.Context aynı işi görüyor. Araç fark etmiyor; ilke şu: bağlam bir kez bağlanır, satır başına tekrar yazılmaz.
Geçişi kademeli yapın
Bütün log çağrılarını tek bir PR'da çevirmek hem incelenemez hem de geri alınamaz. İşleyen sıra:
- Loglayıcıyı JSON çıktı verecek şekilde değiştirin, eski
log.error("metin")çağrıları{"msg":"metin"}olarak akmaya devam etsin. - İstek bağlamını ekleyin. Bu adımdan sonra eski metin satırları bile
request_idtaşıyor. - Olay çıktığında gerçekten aranan beş-on log çağrısını belirleyip
eventalanına çevirin. Hangileri olduğunu son olay kayıtlarınız söyler. - Yeni kod için lint kuralı koyun:
eventalanı olmayanerrorseviyesi log PR'da uyarı versin. - Kalan
msgsatırlarını dokunduğunuz dosyalarda çevirin, toplu temizlik yapmayın.
Birinci adımda dikkat: geliştirici makinesinde JSON okumak zahmetli. pino-pretty ya da eşdeğeri yalnızca yerel ortamda açılır, üretimde her zaman ham JSON akar.
Sorgulamak
Merkezi bir log sistemi yoksa bile yapılandırılmış log jq ile hemen kazanç veriyor. Girişteki soru (14:00-15:00 Türkiye saati, yani UTC 11:00-12:00):
cat checkout-*.log \
| jq -r 'select(.event=="payment.failed" and .ts>="2026-09-13T11:00" and .ts<"2026-09-13T12:00") | .user_id' \
| sort -u | wc -l
Loki, Elasticsearch, ClickHouse ya da bulut sağlayıcının log servisi kullanılıyorsa aynı sorgu alan filtresi olarak yazılıyor ve sunucu sayısından bağımsız çalışıyor. Örneğin Loki'de:
sum by (reason) (count_over_time({service="checkout"} | json | event="payment.failed" [1h]))
Seçim bütçe ve hacme bağlı. Şemayı doğru kurduysanız arka uç değiştirmek, log satırlarını değiştirmeyi gerektirmiyor.
Tuzaklar
Yüksek kardinaliteli etiket. Loki gibi etiket tabanlı sistemlerde user_id ya da request_id etiket (label) yapılırsa indeks şişer. Bunlar gövde alanı olarak kalır, etiket yalnızca service, level, env gibi az değerli alanlardan seçilir.
Nesneyi olduğu gibi basmak. log().info({ user }) bütün kullanıcı kaydını, parola özetiyle birlikte loga yazar. Loglayıcının redact seçeneğine yasak alan listesini verin ve nesne yerine seçilmiş alanları loglayın.
Para ve süre birimi. amount: 12.5 hangi para birimi, hangi ölçek? Alan adına birimi koyun: amount_minor, duration_ms. Alan adında birim yoksa üç ay sonra kimse hatırlamaz.
Saat. ts her zaman UTC ve ISO-8601. Yerel saatle yazılan log, yaz saati geçişinde bir saati iki kez içerir.
Kontrol
Geçişin tamamlandığını gösteren tek test şu: son yaşadığınız olayın sorusunu yeniden sorun ve cevabı tek bir sorguyla, dosyaya bakmadan alın. Alamıyorsanız eksik olan alan hangisiyse sözlüğe o eklenir.
- loglama
- gözlemlenebilirlik
- pino
- jq