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

Serilog Derinlemesine: Bootstrap Logger, appsettings Yapılandırması, Enricher'lar ve Request Logging

Mert Özen 8 Kas 2026 12 dk 3 görüntülenme
Serilog Derinlemesine: Bootstrap Logger, appsettings Yapılandırması, Enricher'lar ve Request Logging

Production'a uygun Serilog kurulumu iki aşamalıdır: açılış hatalarını yakalayan bir bootstrap logger, ardından appsettings ve DI'dan beslenen asıl logger. ReadFrom.Configuration, kendi enricher'ımız, PushProperty ile BeginScope farkı, istek başına tek özet event yazan UseSerilogRequestLogging ve CloseAndFlush unutulunca kaybolan log'lar.

Sürüm notu: Bu yazıdaki kodlar .NET 10, Serilog 4.4, Serilog.AspNetCore 10.0, Serilog.Settings.Configuration 10.0 ve Serilog.Sinks.Seq 9.1 ile derlenip denenmiştir.

Önce Tahmin Et

Gece çalışan küçük bir export uygulaması var. Serilog'u Seq sink'iyle kuruyor, işini bitirince tek bir satır yazıyor ve çıkıyor:

using Serilog;

Log.Logger = new LoggerConfiguration()
    .WriteTo.Seq("http://localhost:17481")
    .CreateLogger();

string runId = args[0];
Log.Information("Nightly export {RunId} finished: {OrderCount} orders", runId, 1250);

if (args is [_, "flush"])
{
    await Log.CloseAndFlushAsync();
}

Uygulamayı bir kez flush argümanı olmadan, bir kez de onunla çalıştırıyorsun. Seq'te kaç event görürsün? Cevabı yazının sonundaki tuzaklar bölümünde, Seq'in API'sinden (Application Programming Interface) alınmış sayılarla veriyorum.

Neden İki Aşamalı Başlatma?

ASP.NET Core Web API serisinin "ASP.NET Core'da Logging: ILogger, Log Seviyeleri, Structured Logging ve Serilog" yazısında Serilog'a ilk geçişi yapmış, AddSerilog ile console'a yazmıştık. Bu yazı o geçişin derinlemesine hâli: production'da gerçekten işine yarayacak bir Serilog kurulumu.

İlk sorun şu: logger'ın asıl yapılandırması appsettings.json'dan geliyor, ama appsettings.json'ı okuyan kod da hata verebilir. Configuration dosyası bozuksa, bir environment variable eksikse ya da DI (Dependency Injection) container'ı kurulurken bir service patlıyorsa, uygulama logger hazır olmadan ölür ve sana kalan tek şey stderr'e düşmüş, kimsenin toplamadığı bir stack trace olur. Serilog'un çözümü iki aşamalı başlatma: önce her yerde çalışan basit bir bootstrap logger, sonra configuration ve service'ler hazır olunca onun yerine geçen asıl logger.

using Bookstore.Api;
using Serilog;
using Serilog.Context;
using Serilog.Core;
using Serilog.Events;

Log.Logger = new LoggerConfiguration()
    .MinimumLevel.Override("Microsoft", LogEventLevel.Information)
    .Enrich.FromLogContext()
    .WriteTo.Console()
    .CreateBootstrapLogger();

try
{
    Log.Information("Starting Bookstore.Api");

    WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

    builder.Services.AddSingleton<ILogEventEnricher, AppVersionEnricher>();
    builder.Services.AddSerilog((services, lc) => lc
        .ReadFrom.Configuration(builder.Configuration)
        .ReadFrom.Services(services));

    WebApplication app = builder.Build();

    // ... middleware ve endpoint'ler

    app.Run();
    return 0;
}
catch (Exception ex) when (ex is not HostAbortedException)
{
    Log.Fatal(ex, "Bookstore.Api terminated unexpectedly");
    return 1;
}
finally
{
    await Log.CloseAndFlushAsync();
}

CreateBootstrapLogger() sıradan bir logger değil, bir ReloadableLogger döndürüyor. AddSerilog'un (services, lc) alan overload'u, Log.Logger'ın bu tipte olduğunu fark ediyor ve onu yeni yapılandırmayla yeniden kurup donduruyor. Böylece açılış sırasında oluşturulmuş logger'lar da asıl yapılandırmayla yazmaya devam ediyor. Serilog.AspNetCore'un README'sindeki önemli not: asıl logger bootstrap logger'ı tamamen değiştirir, yani sink'leri iki yerde de ayrıca tanımlaman gerekir.

when (ex is not HostAbortedException) filtresi de bilinçli: Entity Framework Core (EF Core) migration araçları gibi tool'lar host'u bilerek bu exception'la durduruyor ve bunun her seferinde Fatal diye loglanmasını istemezsin.

Denemek için appsettings.Staging.json dosyasını kasıtlı olarak kapanmamış bir JSON (JavaScript Object Notation) object'iyle bıraktım ve uygulamayı ASPNETCORE_ENVIRONMENT=Staging ile başlattım. Gerçek çıktının başı (dosya yolunu kısalttım):

[05:15:06 INF] Starting Bookstore.Api
[05:15:06 FTL] Bookstore.Api terminated unexpectedly
System.IO.InvalidDataException: Failed to load configuration from file '/app/appsettings.Staging.json'.
 ---> System.FormatException: Could not parse the JSON file.
 ---> System.Text.Json.JsonReaderException: Expected depth to be zero at the end of the JSON payload. There is an open JSON object or array that should be closed. LineNumber: 4 | BytePositionInLine: 0.

Hata WebApplication.CreateBuilder(args) satırında, yani asıl logger yapılandırılmadan önce oluştu. Onu yakalayıp Fatal seviyesinde yazan bootstrap logger. Uygulama 1 koduyla çıktı; container orchestrator'ın restart döngüsüne girdiğinde log'da neden girdiğini görebiliyorsun. Bootstrap logger'a console'dan fazlasını, örneğin Seq'i de eklersen bu satır merkezi log sistemine de düşer.

appsettings ile Yapılandırma

ReadFrom.Configuration, Serilog section'ını okuyup minimum seviyeyi, sink'leri, enricher'ları ve sabit property'leri kurar. Bookstore.Api'nin bölümü:

{
  "Serilog": {
    "Using": [ "Serilog.Sinks.Console", "Serilog.Enrichers.Environment" ],
    "MinimumLevel": {
      "Default": "Information",
      "Override": {
        "Microsoft.AspNetCore": "Warning",
        "Microsoft.Hosting.Lifetime": "Information"
      }
    },
    "WriteTo": [
      {
        "Name": "Console",
        "Args": {
          "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext} {Message:lj} {Properties:j}{NewLine}{Exception}"
        }
      }
    ],
    "Enrich": [ "FromLogContext", "WithMachineName", "WithEnvironmentName" ],
    "Properties": {
      "Application": "Bookstore.Api"
    }
  },
  "AllowedHosts": "*"
}

Dikkat edilecek birkaç şey var. Serilog, Logging:LogLevel section'ını okumaz; seviye kuralları artık Serilog:MinimumLevel altında. Override anahtarları category önekidir ve en uzun eşleşen kazanır: Microsoft.AspNetCore Warning iken Microsoft.Hosting.Lifetime Information kalıyor, böylece "Now listening on" satırını görmeye devam ediyoruz. Using listesi, sink ve enricher'ın hangi assembly'de olduğunu söylüyor; .NET 10'da assembly'ler çoğunlukla otomatik bulunuyor, ama trimming ya da single-file publish'te bu liste hayat kurtarır. Console sink'in outputTemplate'indeki {Properties:j}, mesajda kullanılmayan bütün property'leri JSON olarak satırın sonuna basıyor; aşağıdaki çıktılarda enricher'ların etkisini bununla göreceğiz.

Configuration Web API serisinin "Configuration ve Options Pattern" yazısındaki provider sırasına uyuyor: komut satırından --Serilog:MinimumLevel:Override:Microsoft.AspNetCore=Information vererek tek bir koşuda framework log'larını açabilirsin. Bunu birazdan request logging'in faydasını ölçmek için kullanacağız.

Enricher'lar ve Kendi Enricher'ın

Enricher, her log event'ine property ekleyen küçük bir class. FromLogContext, LogContext'e itilen property'leri ekler (aşağıda). WithMachineName ve WithEnvironmentName, Serilog.Enrichers.Environment paketinden geliyor; ikincisi değeri ASPNETCORE_ENVIRONMENT ya da DOTNET_ENVIRONMENT environment variable'ından okuyor. Kendi enricher'ımız da uygulamanın sürümünü ekliyor:

using System.Reflection;
using Serilog.Core;
using Serilog.Events;

namespace Bookstore.Api;

public sealed class AppVersionEnricher : ILogEventEnricher
{
    // değer hiç değişmiyor, property bir kez oluşturuluyor
    private readonly LogEventProperty _property = new("AppVersion", new ScalarValue(
        typeof(AppVersionEnricher).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
            ?.InformationalVersion.Split('+')[0] ?? "unknown"));

    public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) =>
        logEvent.AddPropertyIfAbsent(_property);
}

Enricher'ı DI container'a ILogEventEnricher olarak kaydettik; ReadFrom.Services(services) bunları bulup pipeline'a ekliyor. Bu sayede enricher constructor'dan başka service'ler de alabilir; örneğin aktif tenant'ı bilen bir service. Split('+') ise SDK'nın (Software Development Kit) informational version'a eklediği commit hash'ini atıyor. Projede <Version>1.4.2</Version> varken açılış satırı:

[05:14:36 INF] Microsoft.Hosting.Lifetime Now listening on: http://localhost:17415 {"EventId": {"Id": 14, "Name": "ListeningOnAddress"}, "MachineName": "Mac", "EnvironmentName": "Production", "Application": "Bookstore.Api", "AppVersion": "1.4.2"}

AppVersion küçük görünüyor ama deploy sonrası "hata yeni sürümle mi başladı?" sorusunun tek sorgu uzaklığındaki cevabı. Aynı şekilde Application, birden fazla servisin aynı Seq'e yazdığı ortamda log'ları ayırmanın en basit yolu.

LogContext.PushProperty mi, BeginScope mu?

Bir iş parçasının bütün log'larına aynı property'yi eklemenin iki yolu var. Endpoint'te ikisini yan yana kullandım:

app.MapGet("/books/{id:int}", (int id, ILogger<Program> logger, IDiagnosticContext diagnosticContext) =>
{
    diagnosticContext.Set("BookId", id);

    using (logger.BeginScope(new Dictionary<string, object> { ["CustomerId"] = 42 }))
    using (logger.BeginScope("Catalog lookup {LookupId}", 9001))
    using (LogContext.PushProperty("CartId", 7))
    {
        logger.LogInformation("Looking up book {BookId}", id);
    }

    return id <= 100 ? Results.Ok(new { id, title = "The Pragmatic Programmer" }) : Results.NotFound();
});
[05:15:38 INF] Program Looking up book 12 {"LookupId": 9001, "CustomerId": 42, "RequestId": "0HNPT7ON6GKOT:00000001", "RequestPath": "/books/12", "ConnectionId": "0HNPT7ON6GKOT", "Scope": ["Catalog lookup 9001"], "CartId": 7, "MachineName": "Mac", "EnvironmentName": "Production", "Application": "Bookstore.Api", "AppVersion": "1.4.2"}

Üçü de property'ye dönüştü, ama farklı yollardan. Dictionary ile açılan scope doğrudan CustomerId property'si oldu. Message template'li scope hem LookupId property'si hem de render edilmiş metniyle Scope array'ine bir eleman oldu. LogContext.PushProperty ise CartId'yi ekledi. RequestId, RequestPath ve ConnectionId'yi biz yazmadık: ASP.NET Core her istek için bir scope açıyor ve Serilog onu da property'ye çeviriyor.

Hangisini seçmeli? BeginScope, Microsoft.Extensions.Logging soyutlamasının parçası: kodun Serilog'a bağımlı olmaz, yarın provider'ı değiştirsen de scope'lar çalışır ve kütüphane kodu yazıyorsan tek doğru seçenek odur. LogContext.PushProperty ise Serilog'a özgü; yalnız Serilog pipeline'ında ve yalnız Enrich.FromLogContext() açıkken görünür, ama Log.Information gibi static çağrıları da kapsar ve value'yu Serilog'un destructuring kurallarıyla işler. Uygulama kodunda varsayılanım BeginScope ve dictionary; Serilog'a özgü PushProperty'yi middleware gibi altyapı kodunda kullanıyorum. İkisi de AsyncLocal üzerinden akıyor, yani await'ten sonra da geçerli.

UseSerilogRequestLogging: İstek Başına Tek Event

ASP.NET Core'un kendi log'ları Information seviyesinde bir isteği adım adım anlatır. Komut satırından Microsoft.AspNetCore'u Information'a çekip /books/12'ye tek istek attığımda gelen satırlar (property'leri okunurluk için kırptım):

[05:14:56 INF] Microsoft.AspNetCore.Hosting.Diagnostics Request starting HTTP/1.1 GET http://localhost:17415/books/12 - null null
[05:14:56 INF] Microsoft.AspNetCore.Routing.EndpointMiddleware Executing endpoint 'HTTP: GET /books/{id:int}'
[05:14:56 INF] Program Looking up book 12
[05:14:56 INF] Microsoft.AspNetCore.Http.Result.OkObjectResult Setting HTTP status code 200. [EventId 1 WritingResultAsStatusCode]
[05:14:56 INF] Microsoft.AspNetCore.Http.Result.OkObjectResult Writing value of type '<>f__AnonymousType0`2' as Json. [EventId 3 WritingResultAsJson]
[05:14:56 INF] Microsoft.AspNetCore.Routing.EndpointMiddleware Executed endpoint 'HTTP: GET /books/{id:int}' [EventId 1 ExecutedEndpoint]
[05:14:56 INF] Serilog.AspNetCore.RequestLoggingMiddleware HTTP GET /books/12 responded 200 in 8.7307 ms
[05:14:56 INF] Microsoft.AspNetCore.Hosting.Diagnostics Request finished HTTP/1.1 GET http://localhost:17415/books/12 - 200 null application/json; charset=utf-8 33.7686ms

Bizim yazdığımız tek satıra karşılık yedi framework satırı. Serilog'un request logging middleware'i bunların yerine istek bitince tek bir özet event yazıyor; framework'ü appsettings.json'daki gibi Warning'e çektiğimizde aynı istek için kalan iki satır:

[05:15:38 INF] Program Looking up book 12 {"LookupId": 9001, "CustomerId": 42, "RequestId": "0HNPT7ON6GKOT:00000001", "RequestPath": "/books/12", "ConnectionId": "0HNPT7ON6GKOT", "Scope": ["Catalog lookup 9001"], "CartId": 7, "MachineName": "Mac", "EnvironmentName": "Production", "Application": "Bookstore.Api", "AppVersion": "1.4.2"}
[05:15:38 INF] Serilog.AspNetCore.RequestLoggingMiddleware HTTP GET /books/12 responded 200 in 7.8500 ms {"BookId": 12, "ClientIp": "::1", "UserAgent": "curl/8.7.1", "RequestId": "0HNPT7ON6GKOT:00000001", "ConnectionId": "0HNPT7ON6GKOT", "MachineName": "Mac", "EnvironmentName": "Production", "Application": "Bookstore.Api", "AppVersion": "1.4.2"}

Özet event'in varsayılan template'i HTTP {RequestMethod} {RequestPath} responded {StatusCode} in {Elapsed:0.0000} ms; yani method, path, status code ve süre zaten sorgulanabilir property. Fazlasını iki yerden ekliyoruz:

app.UseSerilogRequestLogging(options =>
{
    options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
    {
        diagnosticContext.Set("ClientIp", httpContext.Connection.RemoteIpAddress?.ToString());
        diagnosticContext.Set("UserAgent", httpContext.Request.Headers.UserAgent.ToString());
    };
});

EnrichDiagnosticContext her istek için HTTP (Hypertext Transfer Protocol) context'inden alınacak alanları tanımlıyor. Endpoint'in içinden de IDiagnosticContext enjekte edip diagnosticContext.Set("BookId", id) diyebiliyorsun; değer o anda log'a yazılmıyor, istek bitince özet event'e ekleniyor. "Hangi kitabın sayfası en yavaş?" sorusu artık BookId üzerinden gruplanmış tek bir sorgu.

Birkaç seçenek daha var: GetLevel ile seviyeyi duruma göre seçebilirsin (varsayılan, 500 ve üzeri ya da exception için Error, diğerleri için Information); /health isteklerini Verbose'a düşürmek klasik kullanım. IncludeQueryInRequestPath varsayılan olarak false; query string'de token ya da e-posta taşıyan endpoint'lerin varsa öyle kalsın. Middleware'in sırası da önemli: UseSerilogRequestLogging'den önce eklenen middleware'ler ölçülmez ve loglanmaz, o yüzden onu pipeline'ın başına yakın koy; static file isteklerini loglamak istemiyorsan UseStaticFiles'tan sonra.

MEL mi Serilog mu?

Her projeye Serilog gerekmez. MEL (Microsoft.Extensions.Logging) tek başına structured logging yapar; JSON console formatter'ı var, scope'ları var ve builder.Logging.AddOpenTelemetry(...) ile OTLP (OpenTelemetry Protocol) exporter'a bağlandığında log'larını bir OpenTelemetry Collector'a ya da OTLP kabul eden herhangi bir backend'e gönderir. Uygulaman container'da çalışıyor, log'lar stdout'tan ya da OTLP ile toplanıyor ve dosya, veritabanı gibi hedeflere ihtiyacın yoksa MEL ile OTLP yeter; bir bağımlılık eksik olur.

Serilog'un öne çıktığı yerler: geniş sink ekosistemi (dosya rolling, Seq, Elasticsearch ve onlarcası), enricher'lar, {@Order} ile destructuring ve destructuring policy'leri, Serilog.Expressions ile sink bazında filtreleme, bootstrap logger ve request logging gibi hazır parçalar. NLog da aynı ligde olgun bir alternatif; NLog.Extensions.Logging ile MEL'in altına provider olarak girer ve yapılandırmasını kendi nlog.config dosyasından ya da appsettings'ten alır. Hangisini seçersen seç, uygulama kodu ILogger<T>'ye yazmaya devam etsin; provider değişikliği yalnız Program.cs'e dokunsun.

Üretimde Dikkat

Bootstrap logger'ın sink'lerini production için de düşün. Yalnız console varsa ve container log'larını topluyorsan yeterli; toplamıyorsan açılış hataları yine kaybolur. Asıl logger yapılandırmasındaki sink'leri bootstrap'e de koymak çoğu zaman en güvenli seçim.

Request logging özet event'i, kendi başına iyi bir metric kaynağı gibi görünür ama değildir. Elapsed değerlerini log'dan hesaplamak her sorguda milyonlarca event taramak demek; yüzdelikler için ASP.NET Core'un yerleşik http.server.request.duration metric'ini kullan, log'u tek tek isteklere inmek için sakla.

ClientIp ve UserAgent gibi alanlar kişisel veri sayılabilir. Neyi topladığını ve ne kadar sakladığını bil; bu konuyu serinin "Log'larda Hassas Veri" yazısında açacağız.

Yapılandırma hatalarına karşı da tetikte ol. Serilog:WriteTo'da sink adını yanlış yazarsan Serilog exception fırlatmıyor, o sink'i sessizce atlıyor. Komut satırından --Serilog:WriteTo:0:Name=Consol verdiğimde uygulama hatasız açıldı, ama bootstrap logger'ın "Starting" satırından sonra console'a tek satır düşmedi. Sebebi görmenin yolu, ReadFrom.Configuration'dan önce SelfLog.Enable(Console.Error) çağırmak; aynı yapılandırmayla küçük bir console uygulamasında aldığım satır:

2026-11-08T02:18:39.5484640Z Unable to find a method called Consol. Candidate methods are:

Ardından aday method'ların listesi geliyor. SelfLog'u, sink'lerin çalışırken yaşadığı hatalarla birlikte bir sonraki yazıda ayrıntılı açıyoruz.

Sık Düşülen Tuzaklar

Baştaki soruya dönelim. Export uygulamasını iki kez çalıştırdım, sonra Seq'in /api/events endpoint'inden RunId'ye göre event'leri saydım:

run-no-flush: 0
run-with-flush: 1

CloseAndFlush çağrılmadan çıkan koşunun event'i hiç gelmedi. Seq sink event'leri bellekte biriktirip batch hâlinde gönderiyor; process bu batch gönderilmeden bittiğinde bellekteki her şey gidiyor. Kısa ömürlü console uygulamalarında, Azure Functions ya da background job'larda bu, "uygulama çalıştı ama hiç log yok" vakalarının en yaygın sebebi. Web uygulamasında finally içindeki CloseAndFlushAsync aynı işi yapıyor.

İkincisi, aynı exception'ı iki kez görmek. /boom endpoint'i exception fırlattığında request logging middleware özet event'i exception'la birlikte Error seviyesinde yazıyor, ardından Kestrel aynı exception'ı bir kez daha logluyor:

[05:15:50 ERR] Serilog.AspNetCore.RequestLoggingMiddleware HTTP GET /boom responded 500 in 0.3427 ms {"ClientIp": "::1", "UserAgent": "curl/8.7.1", "RequestId": "0HNPT7OQR6B6T:00000001", "ConnectionId": "0HNPT7OQR6B6T", "MachineName": "Mac", "EnvironmentName": "Production", "Application": "Bookstore.Api", "AppVersion": "1.4.2"}
System.InvalidOperationException: Inventory service is not reachable
   at Program.<>c.<<Main>$>b__0_3() in /src/Bookstore.Api/Program.cs:line 51
   at lambda_method2(Closure, Object, HttpContext)
   at Serilog.AspNetCore.RequestLoggingMiddleware.Invoke(HttpContext httpContext)
[05:15:50 ERR] Microsoft.AspNetCore.Server.Kestrel Connection id "0HNPT7OQR6B6T", Request id "0HNPT7OQR6B6T:00000001": An unhandled exception was thrown by the application. {"EventId": {"Id": 13, "Name": "ApplicationError"}, "RequestId": "0HNPT7OQR6B6T:00000001", "RequestPath": "/boom", "MachineName": "Mac", "EnvironmentName": "Production", "Application": "Bookstore.Api", "AppVersion": "1.4.2"}

Serinin ilk yazısındaki "exception'ı bir kez logla" kuralı burada da geçerli. Web API serisinin "Global Hata Yönetimi" yazısındaki exception handler'ı kurduğunda exception orada ele alınıyor ve Kestrel'e ulaşmıyor; o zaman da handler'ın log'u ile request logging'in Error event'i arasında tercih yapman gerekir. Ben request logging event'ini GetLevel ile Information'da bırakıp exception'ı handler'ın loglamasını tercih ediyorum; böylece hata sayısı tek bir yerden geliyor.

Üçüncüsü, Enrich.FromLogContext()'u unutmak. PushProperty hata vermez, property sadece hiçbir yerde görünmez. Bootstrap ve asıl yapılandırmanın ikisinde de olduğundan emin ol.

Dördüncüsü, Logging:LogLevel ayarlarını değiştirip Serilog'un seviyesinin değişmesini beklemek. AddSerilog'dan sonra seviye kuralları Serilog:MinimumLevel'da; eski section'ı silmek, ekipte "ben burayı değiştirdim, neden etki etmedi?" kafa karışıklığını önler.

Küçük Bir Deneme

★☆☆ appsettings.json'da Override altına "Program": "Warning" ekle ve /books/12'ye istek at. "Looking up book" satırı kayboluyor mu, request logging özet event'i kalıyor mu? Neden?

★★☆ UseSerilogRequestLogging'e bir GetLevel yaz: /health isteklerini Verbose, 1 saniyeden uzun süren istekleri Warning, exception'lı istekleri Error yapsın. Bir /health endpoint'i ve Task.Delay ile yavaşlayan bir endpoint ekleyip dene.

★★★ Aktif kullanıcının id'sini HttpContext.User'dan okuyup her log event'ine UserId olarak ekleyen bir enricher yaz. IHttpContextAccessor'ı constructor'dan alsın ve ReadFrom.Services ile pipeline'a girsin. İstek dışındaki log'larda (açılış satırları gibi) ne olduğuna bak.

Serilog'u kurduk; şimdilik bütün event'ler console'a gidiyor. Bir sonraki yazıda sink'lere geçiyoruz: container dünyasında neden stdout ve compact JSON, File sink'te rolling ve retention, Serilog 4 ile core'a giren batching, async wrapper ve sink yavaşladığında log'ların nasıl kaybolduğu, SelfLog ve OpenTelemetry sink'iyle OTLP'ye çıkış.