Mert Özen Her Satırda Daha İleri
Backend .NET'te Logging — #04

Serilog Sink'leri: Console, File, Seq ve OpenTelemetry — Async, Batching ve Kayıp Log

Mert Özen 8 Kas 2026 11 dk 1 görüntülenme
Serilog Sink'leri: Console, File, Seq ve OpenTelemetry — Async, Batching ve Kayıp Log

Container'da log stdout'a compact JSON olarak yazılır; dosya gerekiyorsa rolling ve retention açıkça ayarlanır. Serilog 4'ün core batching'i ve async wrapper, yavaş bir sink karşısında ya event atar ya uygulamayı bekletir: 2.000 event'ten 101'i ulaştı ya da döngü 2,4 saniye sürdü. SelfLog, OTLP'ye çıkış ve sink bazında seviye ayırmak.

Sürüm notu: Bu yazıdaki kodlar .NET 10, Serilog 4.4, Serilog.Sinks.File 7.0, Serilog.Sinks.Async 2.1, Serilog.Formatting.Compact 3.0, Serilog.Sinks.OpenTelemetry 4.2, Serilog.Expressions 5.0 ve Seq 2026.1 ile derlenip denenmiştir.

Önce Tahmin Et

Her event için 1 milisaniye harcayan yavaş bir sink var; uzaktaki bir HTTP (Hypertext Transfer Protocol) endpoint'ine yazdığını düşün. Uygulamayı bekletmesin diye onu Serilog'un async wrapper'ına sarıyorsun ve buffer'ı 100 event'le sınırlıyorsun:

SlowSink slow = new();
Log.Logger = new LoggerConfiguration()
    .WriteTo.Async(a => a.Sink(slow), bufferSize: 100, blockWhenFull: block)
    .CreateLogger();
Stopwatch watch = Stopwatch.StartNew();
for (int i = 1; i <= 2_000; i++)
{
    Log.Information("Order {OrderId} exported", i);
}
watch.Stop();
await Log.CloseAndFlushAsync();

Döngü 2.000 event yazıyor. blockWhenFull: false iken döngü ne kadar sürer ve sink kaç event alır? true iken? Program sonunda CloseAndFlushAsync da çağırıyor, yani "flush etmeyi unuttum" bahanesi yok. Cevap yazının ortasında.

Container Dünyasında stdout ve Compact JSON

Serinin bir önceki yazısında Serilog'u kurduk ve her şeyi console'a, insanın okuyacağı bir template'le yazdık. Container'da çalışan bir uygulamada console'un okuru çoğunlukla insan değil, bir log collector: Kubernetes ya da Docker stdout'u yakalar, bir agent onu okuyup merkezi sisteme taşır. Uygulama dosya yolu, rotation ya da ağ bağlantısıyla uğraşmaz; log'u stdout'a yazar ve geri kalanı platforma bırakır. Bu yaklaşımın tek şartı, satırın makinenin parse edebileceği bir formatta olması:

Log.Logger = new LoggerConfiguration()
    .WriteTo.Console(new CompactJsonFormatter())
    .CreateLogger();
Log.Information("Order {OrderId} placed by customer {CustomerId}, total {OrderTotal}", 1001, 42, 91.0m);
Log.Error(new TimeoutException("Gateway did not answer in 30 s"), "Payment failed for order {OrderId}", 1002);
{"@t":"2026-11-08T02:21:55.3193220Z","@mt":"Order {OrderId} placed by customer {CustomerId}, total {OrderTotal}","OrderId":1001,"CustomerId":42,"OrderTotal":91.0}
{"@t":"2026-11-08T02:21:55.3399720Z","@mt":"Payment failed for order {OrderId}","@l":"Error","@x":"System.TimeoutException: Gateway did not answer in 30 s","OrderId":1002}

Bu, CLEF (Compact Log Event Format): @t UTC (Coordinated Universal Time) zaman damgası, @mt message template, @x exception. İlk satırda @l yok, çünkü CLEF'te Information varsayılan seviye ve yazılmıyor; her satırdan birkaç byte, günde milyonlarca satırda gigabaytlar. Property'ler üst seviyede duruyor, iç içe bir State object'i yok. Mesajın render edilmiş hâlini isteyen, message template'i anlamayan bir hedefin varsa RenderedCompactJsonFormatter template'i metne çevirip @m olarak ve hash'lenmiş bir event id ile yazar. Seq CLEF'i doğrudan okuyor; serinin bir sonraki yazısında bu satırları Seq'e taşıyacağız.

File Sink: Rolling, Retention ve Paylaşımlı Dosya

Her uygulama container'da çalışmıyor. Bir Windows sunucusundaki servis ya da bir VM'deki (Virtual Machine) uygulama için dosya hâlâ en sağlam hedef. File sink'in tehlikesi, diski sessizce doldurmak:

Log.Logger = new LoggerConfiguration()
    .WriteTo.File("logs/bookstore-.log",
        rollingInterval: RollingInterval.Day,
        fileSizeLimitBytes: 2_000,
        rollOnFileSizeLimit: true,
        retainedFileCountLimit: 3)
    .CreateLogger();
for (int i = 1; i <= 150; i++)
{
    Log.Information("Stock updated for {Isbn}: {Quantity} left", "978-0135957059", i);
}

Limiti göstermek için 2.000 byte gibi gülünç küçük bir değer verdim. 150 event'ten sonra logs klasörü (boyut ve ad):

2059 bookstore-20261108_003.log
2000 bookstore-20261108_004.log
1680 bookstore-20261108_005.log

rollingInterval: RollingInterval.Day dosya adına tarihi ekledi, rollOnFileSizeLimit limit dolunca _001, _002 diye yeni dosya açtı ve retainedFileCountLimit: 3 en eski üç dosyayı sildi; ilk 78 event artık diskte yok. _003 dosyasının 2.000 byte'ı biraz geçmesi de belgelenmiş bir davranış: sink yarım event yazmamak için limiti aşan son event'i tam yazıyor. Kalan dosyalardan birinin ilk satırı:

2026-11-08 05:22:06.892 +03:00 [INF] Stock updated for 978-0135957059: 79 left

Varsayılanları bilmekte fayda var, çünkü hepsi production için doğru değil. fileSizeLimitBytes varsayılan olarak 1 GB, retainedFileCountLimit 31; ikisini birlikte düşünmezsen günlük rolling'le 31 GB'a kadar log birikebilir. rollOnFileSizeLimit açık değilse limit dolduğunda sink yeni dosya açmıyor, yazmayı bırakıyor: aynı 150 event'i bu ayar kapalıyken yazdığımda diskte tek bir 2.045 byte'lık dosya kaldı ve son satırı 26 left idi. shared: true birden fazla process'in aynı dosyaya yazmasına izin veriyor (varsayılanı false), ama bir IIS (Internet Information Services) uygulamasının iki worker process'i gibi durumlar dışında her process'e ayrı dosya vermek daha az sürpriz demek. buffered: true disk yazımını tamponlar ve hızlandırır; karşılığında process çökerse son satırlar diske ulaşmaz.

Serilog 4 ile Core'a Giren Batching

Ağ üzerinden yazan sink'ler (Seq, Elasticsearch, OpenTelemetry) her event için ayrı istek atarsa hem yavaş hem pahalı olur. Eskiden her sink bunun için Serilog.Sinks.PeriodicBatching paketini kullanıyordu. Serilog 4 ile batching core'a girdi: sink yalnız hazır bir batch'i göndermeyi biliyor, kuyruğu, zamanlamayı ve retry'ı Serilog yönetiyor. Bookstore'un depo sistemine log gönderen sahte bir sink:

// Serilog 4: batching'i core yapıyor, sink yalnız hazır batch'i gönderiyor
public sealed class WarehouseApiSink : IBatchedLogEventSink
{
    private int _received;

    public int Received => _received;

    public async Task EmitBatchAsync(IReadOnlyCollection<LogEvent> batch)
    {
        Console.WriteLine($"Sending batch of {batch.Count} events");
        await Task.Delay(200);
        Interlocked.Add(ref _received, batch.Count);
    }

    public Task OnEmptyBatchAsync() => Task.CompletedTask;
}
WarehouseApiSink warehouse = new();
Log.Logger = new LoggerConfiguration()
    .WriteTo.Sink(warehouse, new BatchingOptions
    {
        BatchSizeLimit = 100,
        BufferingTimeLimit = TimeSpan.FromSeconds(1),
        QueueLimit = 500
    })
    .CreateLogger();
for (int i = 1; i <= 2_000; i++)
{
    Log.Information("Order {OrderId} exported", i);
}
await Log.CloseAndFlushAsync();
Sending batch of 100 events
Sending batch of 100 events
Sending batch of 100 events
Sending batch of 100 events
Sending batch of 100 events
Sending batch of 100 events
Written: 2000, received by sink: 600

2.000 event yazıldı, sink 600'ünü aldı. QueueLimit = 500 kuyrukta en fazla 500 event tutulacağını söylüyor; döngü sink'ten çok daha hızlı yazdığı için kuyruk doldu ve fazlası atıldı. SelfLog açık olmasına rağmen tek satır uyarı gelmedi: kuyruk taşması sessiz bir kayıp. BatchingOptions'ın varsayılanları paketin kendi dokümantasyonuna göre: BatchSizeLimit 1.000, BufferingTimeLimit 2 saniye, QueueLimit 100.000, RetryTimeLimit 10 dakika ve ilk event'i beklemeden gönderen EagerlyEmitFirstEvent açık. Kuyruk sınırı bir güvenlik önlemi: sınırsız kuyruk, hedef çöktüğünde uygulamanın belleğini log'larla doldurur. Hangisini seçeceğin, "log kaybetmek mi, bellek tükenmesi mi?" sorusuna verdiğin cevap.

Async Wrapper: Hız mı, Kayıp mı?

Batching ağ sink'leri için. Ama senkron çalışan, yavaş bir sink'in varsa (eski bir veritabanı sink'i, yavaş bir disk) Serilog.Sinks.Async onu arka plandaki bir thread'e taşır: log çağrısı event'i bir buffer'a bırakır ve hemen döner. Baştaki sorunun gerçek cevabı:

blockWhenFull=False: logging loop took 18 ms, sink received 101 of 2000
blockWhenFull=True: logging loop took 2407 ms, sink received 2000 of 2000

İki seçeneğin ikisi de bir şeyden vazgeçiyor. blockWhenFull: false (varsayılan) iken döngü 18 milisaniyede bitti, ama 2.000 event'in 1.899'u atıldı: buffer 100'de doldu ve sonrası çöpe gitti. blockWhenFull: true iken hiçbir event kaybolmadı, ama döngü 2,4 saniye sürdü; log çağrısı, buffer'da yer açılana kadar uygulamanın thread'ini bekletti. Yani async wrapper yavaş bir sink'i hızlandırmaz, yalnız maliyetin nereye düşeceğini seçmeni sağlar: ya kaybedersin ya beklersin.

Varsayılan bufferSize 10.000 (paketin method imzasından okudum). Normal trafikte bu, anlık dalgalanmaları yutmaya yeter. Ama sink sürekli olarak gelen hızdan yavaşsa hiçbir buffer yetmez; buffer sadece kaybın başlamasını geciktirir. Async wrapper'ın içine batching yapan bir sink koymak da gereksiz: Seq ya da OpenTelemetry sink'i zaten kendi kuyruğuyla çalışıyor.

SelfLog: Sink'in Kendi Hatası

Sink'ler hata fırlatmaz; fırlatsaydı log yazmak uygulamayı çökertirdi. Bu yüzden Seq'in kapalı olması, yanlış bir API (Application Programming Interface) key ya da dolu bir disk, varsayılan olarak hiçbir iz bırakmaz. Serilog'un kendi teşhis kanalı SelfLog. Seq sink'ini kimsenin dinlemediği bir porta yönlendirdim:

SelfLog.Enable(Console.Error);
Log.Logger = new LoggerConfiguration()
    .WriteTo.Seq("http://localhost:17499")
    .CreateLogger();
Log.Information("Order {OrderId} placed", 1001);
await Log.CloseAndFlushAsync();
2026-11-08T02:22:10.5452980Z Serilog.Core.Sinks.Batching.BatchingSink: failed emitting a batch (Temporary, 1 events)
System.Net.Http.HttpRequestException: Connection refused (localhost:17499)
 ---> System.Net.Sockets.SocketException (61): Connection refused

Satırda iki bilgi var. Birincisi, hatayı yazan Serilog.Core.Sinks.Batching.BatchingSink: Seq sink'i de bir önceki bölümdeki core batching'i kullanıyor. İkincisi, "Temporary": Serilog bunu geçici bir hata sayıp retry etmeye niyetlendi, ama process hemen CloseAndFlushAsync ile kapandığı için event bir daha denenmeden kayboldu. Uzun yaşayan bir web uygulamasında aynı batch, RetryTimeLimit dolana kadar tekrar denenir.

Production'da SelfLog'u console'a değil, stderr'e ya da ayrı bir dosyaya yönlendir ve oradan da topla. SelfLog.Enable'ı logger'ı kurmadan önce çağır; aksi hâlde yapılandırma sırasındaki hataları (bir önceki yazıdaki yanlış yazılmış sink adı gibi) kaçırırsın.

OpenTelemetry Sink ile OTLP'ye Çıkış

OpenTelemetry, log'lar için de standart bir taşıma protokolü tanımlıyor: OTLP (OpenTelemetry Protocol). Serilog.Sinks.OpenTelemetry, Serilog event'lerini OTLP log record'larına çeviriyor; hedef bir OpenTelemetry Collector olabilir ya da OTLP kabul eden herhangi bir backend. Seq'in kendisi de OTLP kabul ediyor, bu yüzden denemeyi doğrudan Seq'in /ingest/otlp endpoint'ine yaptım:

Log.Logger = new LoggerConfiguration()
    .Enrich.WithProperty("Application", "Bookstore.Sinks")
    .WriteTo.OpenTelemetry(options =>
    {
        options.Endpoint = "http://localhost:17480/ingest/otlp";
        options.Protocol = OtlpProtocol.HttpProtobuf;
        options.ResourceAttributes = new Dictionary<string, object>
        {
            ["service.name"] = "bookstore-sinks",
            ["deployment.environment.name"] = "demo"
        };
    })
    .CreateLogger();
Log.Information("Order {OrderId} placed by customer {CustomerId}", 1001, 42);
await Log.CloseAndFlushAsync();

Endpoint'e /v1/logs yazmadım; sink, HTTP/protobuf protokolünde standart /v1/logs yolunu kendisi ekliyor. Seq'in API'sinden @Resource.service.name = 'bookstore-sinks' filtresiyle çektiğim event'in bir kısmı:

"Properties": [
    {
        "Name": "Application",
        "Value": "Bookstore.Sinks"
    },
    {
        "Name": "CustomerId",
        "Value": 42
    },
    {
        "Name": "OrderId",
        "Value": 1001
    }
],
"Level": "INFO",
"Resource": [
    {
        "Name": "deployment",
        "Value": {
            "environment": {
                "name": "demo"
            }
        }
    },
    {
        "Name": "service",
        "Value": {
            "name": "bookstore-sinks"
        }
    },

Event'e ait property'ler ile resource attribute'ları ayrı tutuluyor. service.name gibi "bu log hangi servisten geliyor" bilgisi her event'e property olarak değil, bir kez resource olarak gidiyor; OpenTelemetry dünyasında servisleri birbirinden ayıran anahtar bu. Bir de: sink'in kendi kimliği de resource'a eklendi (telemetry.sdk.name değeri serilog), backend'de log'un hangi kütüphaneden geldiğini görebiliyorsun. Collector tarafı, yani log'ların nereye, nasıl yönlendirileceği, ilerideki DevOps › Observability serisinin konusu; kod tarafı burada bitiyor.

Birden Fazla Sink'te Seviye Ayırmak

Gerçek kurulumlarda her sink her event'i istemez. Console'a Information ve üstü, hata dosyasına yalnız Error, ödeme ekibinin dosyasına yalnız ödeme service'inin log'ları:

Log.Logger = new LoggerConfiguration()
    .MinimumLevel.Debug()
    .WriteTo.Console(restrictedToMinimumLevel: Serilog.Events.LogEventLevel.Information)
    .WriteTo.File("logs/errors-.log", restrictedToMinimumLevel: Serilog.Events.LogEventLevel.Error,
        rollingInterval: RollingInterval.Day)
    .WriteTo.Logger(lc => lc
        .Filter.ByIncludingOnly("SourceContext like 'Bookstore.Payments%'")
        .WriteTo.File("logs/payments-.log", rollingInterval: RollingInterval.Day))
    .CreateLogger();
ILogger orders = Log.ForContext(Serilog.Core.Constants.SourceContextPropertyName, "Bookstore.Orders.OrderService");
ILogger payments = Log.ForContext(Serilog.Core.Constants.SourceContextPropertyName, "Bookstore.Payments.PaymentService");
orders.Debug("Cart {CartId} has {ItemCount} items", 7, 3);
orders.Information("Order {OrderId} placed", 1001);
payments.Information("Payment authorized for order {OrderId}", 1001);
payments.Error("Payment capture failed for order {OrderId}", 1002);

Console:

[05:22:29 INF] Order 1001 placed
[05:22:29 INF] Payment authorized for order 1001
[05:22:29 ERR] Payment capture failed for order 1002

errors-20261108.log ve payments-20261108.log:

2026-11-08 05:22:29.289 +03:00 [ERR] Payment capture failed for order 1002

2026-11-08 05:22:29.282 +03:00 [INF] Payment authorized for order 1001
2026-11-08 05:22:29.289 +03:00 [ERR] Payment capture failed for order 1002

Debug satırı hiçbir yere gitmedi: logger seviyesi Debug, ama her sink kendi alt sınırını koydu. Burada önemli bir kural var: restrictedToMinimumLevel yalnız daraltır. Logger'ın minimum seviyesi Information olsaydı, bir sink'e Debug vermek hiçbir şeyi açmazdı; event pipeline'a hiç girmezdi. WriteTo.Logger ise kendi filtresi ve sink'leri olan bir alt logger. Filtre ifadesi Serilog.Expressions'tan geliyor; like, @l, @x gibi aynı dil serinin Seq yazısında da karşımıza çıkacak.

Üretimde Dikkat

Microsoft'un logging dokümantasyonu bu konuda açık: ILogger'da async log method'u yok, çünkü log'un async kodun maliyetine değmeyecek kadar hızlı olması bekleniyor. Yavaş bir hedefe, örneğin SQL (Structured Query Language) Server'a, Log method'unun içinden doğrudan yazma; dokümanın önerisi log'u önce bellekteki bir queue'ya koymak ve bir background worker'ın onu asenkron olarak veritabanına taşıması. Serilog'da bunun karşılığı batching yapan bir sink. Her log satırı için senkron bir INSERT çalıştıran bir sink, veritabanı yavaşladığı anda bütün API'yi yavaşlatır; ve veritabanı yavaşladığında log'a en çok ihtiyacın olan an da odur.

Disk dolana kadar log tutmak da bir incident sebebi. Rolling ve retention'ı her zaman açıkça ayarla, log klasörünü uygulamanın çalıştığı diskten ayırabiliyorsan ayır ve disk doluluğuna bir alert koy. Log'un amacı sorunu anlatmak; sorunun kendisi olmamalı.

Kaybı ölç. Batching kuyruğu ve async buffer sessizce event atıyor. Serilog.Sinks.Async bir monitor parametresiyle buffer'ın doluluğunu dışarı verebiliyor; kritik bir sistemde bu sayıyı bir metric'e bağla. En basit kontrol de şu: bir istek için log'da görmen gereken event sayısını bil ve arada bir doğrula.

Ve hangi hedef olursa olsun, kısa ömürlü process'lerde CloseAndFlushAsync zorunlu. Bir önceki yazıda Seq'e gitmeyen o tek satırı hatırla.

Sık Düşülen Tuzaklar

Birincisi, async wrapper'ı "log artık bedava" diye okumak. Ölçüm gösterdi: buffer dolunca ya event'leri kaybediyorsun ya uygulamanın thread'ini bekletiyorsun. Async wrapper kısa dalgalanmalar için; sürekli yavaş bir sink'in çözümü sink'i değiştirmek.

İkincisi, aynı event'i iki ağ sink'ine (Seq ve OTLP gibi) göndermek ve sonra faturaya şaşırmak. İki sink iki kat ingestion demek. Geçiş döneminde mecburen ikisini birden kullanıyorsan bir tarih koy.

Üçüncüsü, container'da hem stdout'a hem dosyaya yazmak. Dosya container'ın yazılabilir katmanında büyür, container yeniden başlayınca kaybolur ve kimse okumaz. Container'da stdout yeter.

Dördüncüsü, SelfLog'u sadece geliştirme ortamında açmak. Sink hataları production'da olur: süresi dolmuş API key, değişmiş bir endpoint, ağ politikası. SelfLog'u stderr'e yönlendirip açık bırakmanın maliyeti neredeyse sıfır.

Beşincisi, restrictedToMinimumLevel ile Debug açmaya çalışmak. Sink seviyesi logger seviyesinin altına inemez; Debug'ı bir sink için açmak istiyorsan logger'ı Debug'a çekip diğer sink'leri daraltmak zorundasın.

Küçük Bir Deneme

★☆☆ File sink örneğini retainedFileCountLimit: null ile çalıştır ve kaç dosya oluştuğunu say. Sonra rollingInterval'ı RollingInterval.Hour yap ve dosya adlarının nasıl değiştiğine bak.

★★☆ WarehouseApiSink'in EmitBatchAsync'ini ilk iki çağrıda exception fırlatacak şekilde değiştir, SelfLog'u aç ve RetryTimeLimit'i 10 saniyeye indir. Hangi batch'lerin tekrar denendiğini ve sonunda sink'e kaç event ulaştığını gözle.

★★★ Async wrapper örneğine monitor parametresiyle bir IAsyncLogEventSinkMonitor bağla ve döngü bittiğinde buffer'ın doluluğunu ve atılan event sayısını yazdır. Bu sayıyı System.Diagnostics.Metrics ile bir counter'a çevir.

Log'larımız artık bir yere gidiyor. Bir sonraki yazıda o yerlerden birini, Seq'i derinlemesine kullanacağız: Docker'da ayağa kaldırmak, API key ile ingestion, sorgu dili, signal'lar, dashboard'lar ve alert'ler.