Mert Özen Her Satırda Daha İleri
DevOps Observability — #09

Log Backend'leri: OpenTelemetry Logs, Loki ve ELK — Label, LogQL ve Log'dan Trace'e Geçiş

Mert Özen 15 Kas 2026 11 dk 10 görüntülenme
Log Backend'leri: OpenTelemetry Logs, Loki ve ELK — Label, LogQL ve Log'dan Trace'e Geçiş

Loki'de log'u ucuz tutmanın yolu label'ları az tutmak: OpenTelemetry log'unun resource attribute'larının bir kısmı label, geri kalanı ve trace_id structured metadata oluyor. Bookstore'da template'li log gövdesini, her restart'ta yeni stream açan service.instance.id'yi ve LogQL ile hata, seviye, süre ve log'dan trace'e geçiş sorgularını gerçek çıktılarla inceliyor; ELK, OpenSearch ve Seq'i karşılaştırıyoruz.

Sürüm notu: Bu yazıdaki örnekler .NET 10, OpenTelemetry .NET 1.21 ve grafana/otel-lgtm 0.41.0 image'ı (Loki 3.7.9, Tempo 3.1.1, Grafana 13.3.0, OpenTelemetry Collector contrib 0.165.0) ile denenmiştir. ELK (Elasticsearch, Logstash ve Kibana) ve Seq bu yazıda çalıştırılmadı.

Önce Tahmin Et

Bookstore'da (sitedeki serilerin ortak örnek uygulaması, bir online kitapçı) ödeme sağlayıcısının yavaş node'unu 5 saniyeye çıkarıp 400 sipariş gönderdik; 8 sipariş ödeme alınamadığı için 502 ile bitti. Loki'de bir hata satırını açtık ve gördüğümüz şu oldu: Payment for order {OrderId} failed. Sipariş numarası satırda yok, yerinde süslü parantezli bir yer tutucu var. Log'u yazan kod logger.LogError(ex, "Payment for order {OrderId} failed", order.Id). Numara kayboldu mu, yoksa başka bir yerde mi duruyor? Ve |= "Payment for order 3826" diye bir arama neden hiçbir şey bulamaz?

Log'u Backend'e Taşımanın İki Yolu

.NET'te Logging serisi log'un nasıl yazılacağını anlattı: ILogger, seviyeler, message template'leri, Serilog ve sink'ler. Bu yazı log yazıldıktan sonrasıyla ilgileniyor: satırlar nereye gidiyor, nasıl saklanıyor ve nasıl aranıyor. Backend'e gitmenin iki yaygın yolu var. Birincisi, serinin başından beri kullandığımız yol: ILogger kayıtlarını OpenTelemetry SDK'sı topluyor (WithLogging()) ve OTLP (OpenTelemetry Protocol; telemetry taşıma protokolü) ile metric ve trace'le aynı exporter'dan Collector'a gönderiyor. İkincisi, Logging serisinin Serilog yazısındaki gibi bir sink: Serilog satırı doğrudan Seq'e, Elasticsearch'e ya da Serilog'un OpenTelemetry sink'iyle bir OTLP endpoint'ine yazıyor.

Birinci yolun iki pratik faydası var. Log kaydı, o an aktif olan span'ın trace_id ve span_id'sini kendiliğinden taşıyor; ek bir enricher gerekmiyor. Ve log'lar Collector'dan geçtiği için Collector yazısında kurduğumuz filtreler ve PII (kişiyi tanımlayan veri; e-posta, telefon gibi) maskelemesi log'lara da uygulanıyor. Serilog'un zengin formatlama ve enrichment imkânlarına ihtiyacın varsa ikinci yol da doğru; ama ikisini aynı anda açma (Tuzaklar bölümünde).

Log Satırı: Template mi, Mesaj mı?

Önce Tahmin Et'in cevabı OpenTelemetry .NET'in bir varsayılanında. OpenTelemetryLoggerOptions.IncludeFormattedMessage varsayılan olarak false; bu durumda OTLP exporter'ı log kaydının gövdesine (body) biçimlenmiş mesajı değil, template'in kendisini yazıyor. Değerler kaybolmuyor: her biri ayrı bir attribute olarak gidiyor. Loki'de satırı açınca OrderId: 3826 ayrı bir alan olarak duruyordu; yalnızca satır metninde değildi. Bu yüzden satır metninde arayan |= filtresi onu bulamadı. Template'li gövdenin bir faydası da var: aynı template'ten gelen bütün satırlar aynı metne sahip, gruplamak kolay. Ama log'u okuyan insan için satırda numara görmek daha önemli; bu yüzden ayarı açtık:

// Log gövdesi biçimlenmiş mesaj ("Payment for order 3826 failed"), yalnızca template değil;
// template'in değerleri yine attribute olarak gider (OrderId=3826).
.WithLogging(configureBuilder: null, configureOptions: options => options.IncludeFormattedMessage = true)

Uygulamayı yeniden başlatıp aynı trafiği gönderdikten sonra başarısız bir siparişin hata kaydı Loki'de şöyle (structured metadata'nın bir kısmını gösteriyoruz):

{service_name="bookstore-api"} | OrderId="4686" | detected_level="error"

line: Payment for order 4686 failed
OrderId: 4686
_OriginalFormat_: Payment for order {OrderId} failed
detected_level: error
exception_message: The operation didn't complete within the allowed timeout of '00:00:10'.
exception_type: TimeoutRejectedException
scope_name: Bookstore.Ordering.OrderService
severity_text: Error
span_id: 0a67b2f619700d2d
trace_id: 5fd74d9e6aab18e18112a647d261eb29

Template kaybolmadı; {OriginalFormat} attribute'u olarak geliyor (Loki adındaki süslü parantezleri alt çizgiye çeviriyor). Bu ayarın bir yan etkisini de gördük: sipariş onayı log'u sent to {CustomerEmail} içeriyor ve gövde artık e-posta adresinin kendisini taşıyor. Collector yazısında kurduğumuz replace_pattern kuralı gövdedeki adresleri maskelediği için Loki'deki satır Order confirmation for order 4695 sent to <email> oldu; attribute'taki değer ise Collector'da hash'lenmiş (geri çevrilemeyecek şekilde dönüştürülmüş) hâlde. O kural olmasaydı bu ayar, PII'yi log satırına taşımış olacaktı.

Loki'nin Veri Modeli: Label, Structured Metadata ve Satır

Loki log'ları stream'ler hâlinde saklıyor. Bir stream, label değerlerinin bir kombinasyonu; Loki yalnızca label'ları index'liyor, satırların kendisini sıkıştırılmış chunk'lar hâlinde object storage'a yazıyor. Bu model ucuz, ama label sayısı ve label değerlerinin çeşitliliği (cardinality) düşük kalırsa. Loki 3'te bunun yanına structured metadata geldi: her satıra iliştirilen, index'e girmeyen ama sorgularda filtrelenebilen anahtar-değer çiftleri. OTLP ile gelen bir log kaydının parçaları şöyle dağılıyor:

09-loki-mapping-tr.png

Loki'nin varsayılan bir listesi var ve bu listedeki resource attribute'ları label oluyor (service.name, deployment.environment.name, k8s ve cloud attribute'ları ve service.instance.id gibi); kalan resource attribute'ları, log attribute'ları, trace_id, span_id ve severity structured metadata'ya gidiyor. Gövde log satırı oluyor. Bizim kurulumda bir sorun çıktı: son iki saatte API'yi yedi kere yeniden başlatmıştık ve Loki'de service_name="bookstore-api" için yedi ayrı stream vardı. Sebep service.instance.id: .NET SDK'sı onu her process başlangıcında yeni bir GUID (128 bitlik benzersiz kimlik) olarak üretiyor ve label olduğu için her başlangıç yeni bir stream açıyor. Kubernetes'te her pod, her deploy ve her autoscaling adımı aynı şeyi yapar. Loki'nin dokümanı da service.instance.id ve k8s.pod.name'in artık varsayılan label olarak önerilmediğini söylüyor; yalnızca mevcut kurulumları bozmamak için listede tutuluyorlar.

İlk denememizde limits_config.otlp_config altında service.instance.id için action: structured_metadata yazdık ve hiçbir şey değişmedi. Loki'nin /config endpoint'inden etkin config'e bakınca sebebi göründü: varsayılan listenin index_label kuralı bizimkinden önce geliyor ve ilk eşleşen kural kazanıyor. Doğru yer distributor'daki varsayılan liste:

# Bookstore: üstteki her şey image'ın kendi config'i; yalnızca bu blok bizim.
distributor:
  otlp_config:
    # Loki'nin varsayılan listesi, service.instance.id ve k8s.pod.name hariç: ikisi de her process başlangıcında
    # ya da her pod'da yeni bir değer alıyor, label olunca her seferinde yeni bir stream açıyor. Burada olmayan her şey
    # structured metadata olarak saklanır ve sorgulanabilir kalır.
    default_resource_attributes_as_index_labels:
      - service.name
      - service.namespace
      - deployment.environment
      - deployment.environment.name
      - cloud.region
      - cloud.availability_zone
      - k8s.cluster.name
      - k8s.namespace.name
      - k8s.container.name
      - container.name
      - k8s.replicaset.name
      - k8s.deployment.name
      - k8s.statefulset.name
      - k8s.daemonset.name
      - k8s.cronjob.name
      - k8s.job.name

Bu dosyayı (image'ın kendi Loki config'inin kopyası ve sonuna eklenmiş bu blok) deploy/observability/loki-config.yaml olarak repo'ya koyup compose'ta image'daki dosyanın üzerine bağladık. Yeniden başlattıktan sonra API'nin log'ları tek stream'de: {"deployment_environment_name":"development","service_name":"bookstore-api"}. Instance id kaybolmadı; her satırda structured metadata olarak duruyor ve | service_instance_id="..." ile filtrelenebiliyor.

LogQL

Bir LogQL sorgusu bir stream seçicisiyle başlıyor ({service_name="bookstore-api"}, yalnızca label'larla) ve arkasından bir pipeline geliyor: |= ve != satır metninde arıyor, | key="value" structured metadata'da ya da parser'ın çıkardığı alanlarda filtreliyor. Seçiciyi dar tut, çünkü Loki seçicinin bulduğu bütün chunk'ları okuyor; pipeline ise o chunk'ların içinde çalışıyor. Hata satırları:

{service_name="bookstore-api"} | detected_level="error"

Resilience event occurred. EventName: 'OnTimeout', Source: 'IPaymentGateway-payment-provider//Timeout', Operation Key: '', Result: ''
Resilience event occurred. EventName: 'OnTimeout', Source: 'IPaymentGateway-payment-provider//Timeout', Operation Key: '', Result: ''
Payment for order 4686 failed

detected_level'ı Loki kendisi ekliyor; OTLP'deki severity'den türetiyor. LogQL'in metric sorguları satırları sayıya çeviriyor. 181 saniyelik koşu için seviyeye göre satır sayısı ve log'u yazan scope'a göre dağılım:

sum by (detected_level) (count_over_time({service_name="bookstore-api"}[181s]))
{detected_level="info"}   1192
{detected_level="warn"}   261
{detected_level="error"}  141

sum by (scope_name) (count_over_time({service_name="bookstore-api"}[181s]))
{scope_name="Polly"}                                            788
{scope_name="Bookstore.Ordering.OrderService"}                  407
{scope_name="Bookstore.Notifications.OrderConfirmationSender"}  393
{scope_name="Microsoft.Hosting.Lifetime"}                       4
{scope_name="Microsoft.EntityFrameworkCore.Migrations"}         2

İkinci sorgu bir maliyet gerçeğini gösteriyor: satırların yarısı Polly'nin resilience log'ları. 141 "error" satırının çoğu da bir hata değil, retry ile kurtarılmış timeout'lar; Polly her timeout'u Error seviyesinde yazıyor. Bu sayıları gören bir ekip Polly'nin log seviyesini düşürür, çünkü aynı bilgi zaten metric olarak var (resilience_polly_strategy_events_total). unwrap ise bir alanın değeriyle hesap yapıyor. Siparişin süresini log'dan da çıkarabiliyoruz:

quantile_over_time(0.95, {service_name="bookstore-api"} |= "placed" | unwrap ElapsedMs [181s]) by (service_name)
{service_name="bookstore-api"}  8544

Trafik aracının ölçtüğü p95 de 8.544 ms'ydi. Filtre hem "Order 4695 placed" hem "Order not placed" satırlarını yakalıyor, ikisi de ElapsedMs taşıyor. Yine de log'dan metric üretmeyi geçici bir araç olarak gör: her sorguda satırlar yeniden okunuyor. Sürekli izlenecek bir değerin yeri histogram.

Log'dan Trace'e ve Geri

Başarısız siparişin trace_id'siyle o isteğin bütün satırlarını, zaman sırasıyla aldık (seviye ve satırın başı):

{service_name="bookstore-api"} | trace_id="5fd74d9e6aab18e18112a647d261eb29"

Error        Resilience event occurred. EventName: 'OnTimeout', Source: 'IPaymentGateway-payment-provider//Timeout', ...
Warning      Execution attempt. Source: 'IPaymentGateway-payment-provider//Retry', ... Result: 'The operation didn't complete within the allowed tim
Warning      Resilience event occurred. EventName: 'OnRetry', Source: 'IPaymentGateway-payment-provider//Retry', ...
Error        Resilience event occurred. EventName: 'OnTimeout', Source: 'IPaymentGateway-payment-provider//Timeout', ...
Warning      Execution attempt. Source: 'IPaymentGateway-payment-provider//Retry', ... Result: 'The operation didn't complete within the allowed tim
Warning      Resilience event occurred. EventName: 'OnRetry', Source: 'IPaymentGateway-payment-provider//Retry', ...
Information  Execution attempt. Source: 'IPaymentGateway-payment-provider//Retry', ... Result: 'The operation was canceled.', ...
Error        Resilience event occurred. EventName: 'OnTimeout', Source: 'IPaymentGateway-payment-provider//Timeout', ...
Error        Payment for order 4686 failed
Warning      Order not placed: PaymentFailed, took 10015 ms

Hikâye log'da da okunuyor: iki deneme 4 saniyelik attempt timeout'una takıldı, üçüncü denemenin ortasında 10 saniyelik toplam timeout doldu, ödeme başarısız oldu. Grafana bunu tek tıkla yapıyor: otel-lgtm'in Loki data source'unda trace_id alanı için tanımlı bir derived field var; satırı açtığında trace_id'nin yanında Tempo'ya giden bir bağlantı çıkıyor. Ters yön de tanımlı: Tempo data source'u bir span'dan aynı trace_id'li Loki satırlarına bir sorgu açıyor. Exemplar'la birlikte üç sinyal arasındaki köprü tamamlanıyor: metric'teki sıçramadan trace'e, trace'ten o isteğin log'larına.

Retention ve Maliyet

Bu 400 siparişlik koşu, 3 dakikada 1.594 satır ve satır metni olarak yaklaşık 177 KB üretti; structured metadata, özellikle exception stack trace'leri, bunun üstüne geliyor. Sipariş başına 4 satır küçük görünüyor, ama günde bir milyon siparişte 4 milyon satır demek. Loki'nin maliyet avantajı, index'in küçük kalması ve chunk'ların ucuz object storage'da durması. Saklama süresi Loki'de compactor üzerinden yönetiliyor; süreyi log'un ne için tutulduğuna göre seç: hata ayıklama için birkaç hafta çoğu zaman yeterli, denetim log'ları ise ayrı bir yerde, ayrı kurallarla tutulmalı. Hangi log'un ne kadar tutulacağı ve seviyelerin production'da nasıl ayarlanacağı Logging serisinin konusu; serinin production yazısında ingestion maliyetine tekrar döneceğiz.

ELK, OpenSearch ve Seq

Bu bölümdeki araçları bu yazıda çalıştırmadık. ELK farklı bir modele dayanıyor: Elasticsearch her alanı index'liyor ve full-text arama sunuyor. "Bu hata mesajını içeren bütün satırlar, son 30 gün" gibi aramalar çok hızlı; karşılığında index'in kendisi büyük, cluster'ı işletmek (shard, heap, index yaşam döngüsü) ayrı bir uzmanlık. Log'lar Elastic Agent, Logstash ya da OTLP ile alınabiliyor. OpenSearch, Elasticsearch'ün lisans değişikliğinden sonra AWS'nin (Amazon Web Services; Amazon'un bulut altyapısı) başlattığı açık kaynak fork; aynı model, OpenSearch Dashboards ile. Seq ise tek bir sunucuda çalışan, structured log'lar için tasarlanmış bir araç; .NET ekiplerinin yerelde ve küçük kurulumlarda en kolay başladığı yer. OTLP ile log ve trace alabiliyor, Serilog ile de doğrudan çalışıyor; ölçek ve yüksek erişilebilirlik ihtiyacı büyüdükçe sınırına gelir.

DurumÖnerilenNeden
Küçük ekip, tek ortam, .NET ağırlıklıSeqKurulumu birkaç dakika, structured log'da sorgusu güçlü
Metric ve trace zaten Grafana'daLokiUcuz storage, aynı ekranda log, trace ve metric
Full-text arama ağırlıklı (destek, güvenlik)ELK ya da OpenSearchHer alan index'li; işletme maliyeti en yüksek
Altyapıyı işletmek istemeyen ekipYönetilen servis (Azure Monitor, Grafana Cloud…)İşletme yok; ücret ingestion ve saklama hacmine göre

Üretimde Dikkat

Log seviyelerini metric'le birlikte düşün. Bizim koşuda satırların yarısı Polly'den geldi ve aynı bilgi metric olarak zaten vardı. Bir olayı hem log hem metric olarak tutmak gerekiyorsa log'u Warning yerine Debug seviyesinde yaz ve production'da kapat; sayısını metric verir.

IncludeFormattedMessage'ı açtığında gövdeye neyin girdiğine bak. Template'in değerleri artık satırın içinde; template'e kişisel veri koyan her log, o veriyi satıra da taşır. Collector'daki maskeleme kuralı bu yüzden önemli; asıl çözüm ise kişisel veriyi log'a hiç yazmamak.

Loki'de label listesini merkezi olarak yönet. Uygulama ekipleri resource attribute eklediğinde Loki'nin varsayılan listesi yüzünden yeni label'lar oluşabilir; Loki'nin etkin config'ini ve stream sayısını düzenli olarak kontrol et.

Sık Düşülen Tuzaklar

Loki'de label'a request id, sipariş id ya da kullanıcı id koymak. Her değer yeni bir stream demek; index büyür, sorgular yavaşlar ve Loki stream limitlerine takılırsın. Yüksek cardinality'li her alan structured metadata'ya ya da satırın içine gider.

Log'u hem Serilog sink'inden hem OpenTelemetry'den göndermek. Serilog'u ILogger provider'ı olarak kurup aynı anda OpenTelemetry'nin logging provider'ını da açarsan her satır backend'e iki kere gider: biri Serilog'un sink'inden, biri OTLP'den. Faturada ve sayımda ikiye katlanır; bir yolu seç.

Satır metninde template'in değerini aramak. IncludeFormattedMessage kapalıyken değerler satırda değil, attribute'larda; |= "4686" değil | OrderId="4686" yaz. Açıkken de structured metadata filtresi satır aramasından daha kesin.

Etkin config'e bakmadan config'in çalıştığını varsaymak. Bizim ilk Loki ayarımız hata vermeden yüklendi ve hiçbir şey değiştirmedi. Değişiklikten sonra stream sayısını ya da /config'i kontrol et.

Küçük Bir Deneme

★☆☆ Grafana'nın Explore ekranında {service_name="bookstore-api"} | detected_level="error" sorgusunu çalıştır, bir satırı aç ve trace_id'nin yanındaki bağlantıyla Tempo'daki trace'e geç. Trace'teki hangi span o satırı yazmış?

★★☆ appsettings.json'da Polly kategorisinin log seviyesini Warning'e çek, aynı trafiği gönder ve sum by (scope_name) (count_over_time(...)) ile satır sayısının ne kadar düştüğünü ölç. Hangi bilgiyi kaybettin, hangisi metric'te hâlâ duruyor?

★★★ Loki config'inde service.instance.id'yi tekrar varsayılan listeye ekle, API'yi beş kere yeniden başlat ve stream sayısını say. Sonra aynı soruyu structured metadata ile cevaplayan bir LogQL sorgusu yaz: "son bir saatte kaç farklı API instance'ı log yazdı?"

Artık üç sinyal de backend'inde ve birbirine bağlı. Ama bunlara biri bakmadıkça hiçbir şey olmuyor; Gül'ün şikâyeti de bir müşteriden geldi, bir alarmdan değil. Bir sonraki yazıda alerting'e geçiyoruz: hangi semptoma alarm kurulur, Prometheus alerting rule'ları ve Alertmanager ile Grafana Alerting, runbook'lar ve alarm yorgunluğu.