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

Correlation ID ve Trace Context: Bir İsteğin Bütün Loglarını API'den Queue'ya Kadar Bağlamak

Mert Özen 8 Kas 2026 10 dk 4 görüntülenme
Correlation ID ve Trace Context: Bir İsteğin Bütün Loglarını API'den Queue'ya Kadar Bağlamak

Bir isteğin bütün log'larını bağlamanın yolu, her log satırına W3C traceparent'tan gelen TraceId'yi yazmaktır. ASP.NET Core ve HttpClient bunu kendiliğinden yapar; queue ve BackgroundService'te zincir kopar ve DistributedContextPropagator ile elle taşınmalıdır. ActivityTrackingOptions, Serilog, baggage, X-Correlation-ID, hata ekranında destek kodu ve Seq'te tek sorgu gerçek çıktılarla.

Sürüm notu: Bu yazıdaki kodlar .NET 10, Serilog.AspNetCore 10.0, Serilog.Sinks.Seq 9.1, Seq.Extensions.Logging 9.0 ve Seq 2026.1 ile derlenip denenmiştir; Angular interceptor'ı Angular 22.2 ve TypeScript 6.0 ile derleme kontrolünden geçirilmiştir.

Önce Tahmin Et

Bookstore'da bir sipariş üç yerde log üretiyor: siparişi alan Bookstore.Api, fatura kesen Bookstore.Billing servisi ve kargoyu planlayan, queue'dan beslenen bir BackgroundService. Hiçbir ek kod yazmadan, sadece varsayılan ASP.NET Core ve HttpClient ayarlarıyla, bu üç yerin log'larında ortak bir değer görür müsün? Görürsen hangileri bağlanır, hangisi kopuk kalır? Bir de istemci kendi traceparent header'ını gönderirse API (Application Programming Interface) onu kullanır mı, yoksa yeni bir id mi üretir?

Activity, TraceId ve SpanId

.NET'te dağıtık izlemenin (distributed tracing) temel taşı System.Diagnostics.Activity. Bir Activity bir işin süresini temsil eder ve iki kimlik taşır: bütün yolculuk boyunca aynı kalan 16 byte'lık TraceId ve her adımın kendine ait 8 byte'lık SpanId. Bir adımın ParentId'si, onu başlatan adımın SpanId'sidir. ASP.NET Core her gelen istek için bir Activity başlatır; o istek boyunca Activity.Current bu Activity'yi gösterir.

Bu kimlikler servisler arasında W3C (World Wide Web Consortium) Trace Context standardındaki traceparent header'ı ile taşınır. Biçimi 00-{trace-id}-{parent-id}-{flags}: sürüm, 32 hex karakterlik trace id, 16 hex karakterlik parent span id ve sampling bayrağı. Log açısından önemli olan şu: her log satırına TraceId yazılırsa, bir isteğin bütün servislerdeki bütün log'larını tek sorguyla toplarsın. Span ve metric tasarımı, yani trace'in kendisi, ilerideki DevOps › Observability serisinin konusu. Burada trace context'i yalnız log'ları birbirine bağlamak için kullanıyoruz.

Log'a TraceId Eklemek: ActivityTrackingOptions

Bookstore.Api'nin logging ayarı:

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();
builder.Logging.AddSimpleConsole(o => o.IncludeScopes = true);
builder.Logging.Configure(o => o.ActivityTrackingOptions =
    ActivityTrackingOptions.TraceId | ActivityTrackingOptions.SpanId |
    ActivityTrackingOptions.ParentId | ActivityTrackingOptions.Baggage);
builder.Logging.AddFilter("System.Net.Http.HttpClient", LogLevel.Warning);
builder.Logging.AddSeq("http://localhost:17481");

ActivityTrackingOptions, logger factory'ye "her log'a o anki Activity'nin şu bilgilerini scope olarak ekle" der. WebApplication builder'ı TraceId, SpanId ve ParentId'yi zaten varsayılan olarak açıyor; ilk denememde hiçbir şey ayarlamadan JSON (JavaScript Object Notation) console çıktısının Scopes dizisinde bu üç alan vardı. Ben Baggage'ı eklemek için listeyi açıkça yazdım. Değerler scope olarak geldiği için console'da IncludeScopes açık olmalı. Bir sipariş isteğinin gerçek çıktısı:

info: Bookstore.Api.Orders[0]
      => SpanId:470b9d1fa575bdc7, TraceId:fcee41938107cef33c87cce0271e3c46, ParentId:0000000000000000 => correlation.id:web-7f3c2a91 => ConnectionId:0HNPT7IDJRVON => RequestPath:/orders RequestId:0HNPT7IDJRVON:00000001
      Order 01a1b8e6-493e-7b1b-8995-fffa3c82477f placed for customer 42

ParentId'nin sıfırlardan oluşması, bu isteğin bir zincirin ilk halkası olduğunu söylüyor: istemci traceparent göndermedi, TraceId'yi API üretti. correlation.id ise baggage'dan geliyor; ona birazdan döneceğiz.

System.Net.Http.HttpClient filtresini neden koyduğumu da söyleyeyim. Varsayılan ayarla IHttpClientFactory her dış çağrı için dört Information satırı yazıyor ("Start processing", "Sending", "Received headers", "End processing"). TraceId zaten her satırda olduğu için bu dört satır production'da çoğunlukla gürültü.

Gelen traceparent'ı Devralmak

Şimdi istemci kendi trace context'ini gönderiyor:

curl -i -H 'Content-Type: application/json' \
  -H 'traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01' \
  -d '{"customerId":42,"isbn":"978-0135957059","quantity":1}' http://localhost:17430/orders

HTTP/1.1 201 Created
X-Trace-Id: 4bf92f3577b34da6a3ce929d0e0e4736
info: Bookstore.Api.Orders[0]
      => SpanId:ddc57f680e1ee066, TraceId:4bf92f3577b34da6a3ce929d0e0e4736, ParentId:00f067aa0ba902b7 => ConnectionId:0HNPT7IDJRVOO => RequestPath:/orders RequestId:0HNPT7IDJRVOO:00000001
      Order 01a1b8e6-4971-7a93-b510-926197590279 placed for customer 42

ASP.NET Core header'ı okudu, TraceId'yi aynen devraldı, istemcinin span'ini ParentId yaptı ve kendi yeni SpanId'sini üretti. Bunun için tek satır kod yazmadık. Baştaki ikinci sorunun cevabı bu: geçerli bir traceparent gelirse API yeni trace başlatmaz, mevcut olanın devamı olur.

Bunun bir güvenlik yanı var. İnternetten gelen her istek senin TraceId'ni seçebilir. Bu genellikle sorun değil, ama TraceId'yi bir yetki ya da tekillik anahtarı gibi kullanmamalısın; sadece korelasyon içindir.

HttpClient ile Dış Servise Taşımak

Bookstore.Billing Serilog kullanıyor. Serilog 4, log event'ini oluştururken Activity.Current'tan TraceId ve SpanId'yi kendisi alıyor; output template'e yazmak yeterli:

Log.Logger = new LoggerConfiguration()
    .MinimumLevel.Information()
    .MinimumLevel.Override("Microsoft.AspNetCore", Serilog.Events.LogEventLevel.Warning)
    .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {TraceId} {SpanId} {Message:lj}{NewLine}{Exception}")
    .WriteTo.Seq("http://localhost:17481")
    .CreateLogger();

Endpoint, gelen header'ları da logluyor ki ne taşındığını görelim:

app.MapPost("/invoices", (InvoiceRequest request, HttpRequest http, ILoggerFactory loggerFactory) =>
{
    Microsoft.Extensions.Logging.ILogger logger = loggerFactory.CreateLogger("Bookstore.Billing.Invoices");
    logger.LogInformation("Incoming traceparent {TraceParent}, baggage {Baggage}",
        http.Headers.TraceParent.ToString(), http.Headers.Baggage.ToString());
    if (request.Amount > 1000)
    {
        throw new InvalidOperationException($"Invoice limit exceeded for order {request.OrderId}");
    }
    logger.LogInformation("Invoice created for order {OrderId}, amount {Amount}", request.OrderId, request.Amount);
    return Results.Ok();
});

API'nin BillingClient'ı sıradan bir typed HttpClient; içinde trace ile ilgili hiçbir şey yok. Billing'in console'u:

[05:04:22 INF] fcee41938107cef33c87cce0271e3c46 9036081b143eab36 Incoming traceparent 00-fcee41938107cef33c87cce0271e3c46-3105afa4d82a1e36-00, baggage correlation.id = web-7f3c2a91
[05:04:22 INF] fcee41938107cef33c87cce0271e3c46 9036081b143eab36 Invoice created for order 01a1b8e6-493e-7b1b-8995-fffa3c82477f, amount 91.0

TraceId API'dekiyle aynı. Dikkat çekici bir ayrıntı: gelen traceparent'taki parent id 3105afa4d82a1e36, API'nin SpanId'si olan 470b9d1fa575bdc7 değil. Çünkü HttpClient giden istek için kendi Activity'sini açıyor ve header'a onun SpanId'sini yazıyor. Zincir API → HttpClient → Billing şeklinde, üç halka.

Baggage ise trace ile birlikte taşınan anahtar-değer çiftleri. .NET 10'da DistributedContextPropagator.Current'ın varsayılanı W3C propagator oldu; baggage artık standart dışı Correlation-Context yerine W3C'nin baggage header'ında gidiyor. Eski sürümlerle konuşan bir servisin baggage'ı okuyamıyorsa sebep bu değişiklik olabilir; eski davranışa DistributedContextPropagator.CreatePreW3CPropagator() ile dönülebiliyor.

Queue ve BackgroundService'e Taşımak

HTTP (HyperText Transfer Protocol) çağrısında bu iş kendiliğinden oluyor, ama bir mesaj queue'ya yazıldığında zincir kopar: consumer'ın elinde ne gelen bir istek ne de bir Activity.Current var. Bu örnekte queue yerine process içi bir Channel<OrderMessage> kullanıyorum; mantık RabbitMQ ya da Kafka'da da aynı, mesajın header'ları taşıyıcı olur. Gerçek broker'larla bunu ilerideki Message Queue serisinde göreceğiz.

Producer tarafında, HttpClient'ın yaptığı işi biz yapıyoruz:

// HttpClient'ın yaptığını mesaj için biz yapıyoruz: traceparent ve baggage header'a yazılır
Dictionary<string, string> headers = [];
DistributedContextPropagator.Current.Inject(Activity.Current, headers,
    static (carrier, name, value) => ((Dictionary<string, string>)carrier!)[name] = value);
await shippingQueue.Writer.WriteAsync(new OrderMessage(orderId, headers), ct);

Consumer tarafında header'ları okuyup yeni bir Activity'yi o parent'a bağlıyoruz:

public sealed class ShippingWorker(Channel<OrderMessage> queue, ILogger<ShippingWorker> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        await foreach (OrderMessage message in queue.Reader.ReadAllAsync(stoppingToken))
        {
            DistributedContextPropagator propagator = DistributedContextPropagator.Current;
            propagator.ExtractTraceIdAndState(message.Headers, ReadHeader, out string? traceParent, out string? traceState);

            using Activity activity = new("Shipping.Process");
            if (traceParent is not null)
            {
                activity.SetParentId(traceParent);
                activity.TraceStateString = traceState;
            }
            foreach ((string key, string? value) in propagator.ExtractBaggage(message.Headers, ReadHeader) ?? [])
            {
                activity.AddBaggage(key, value);
            }
            activity.Start();

            logger.LogInformation("Shipment scheduled for order {OrderId}", message.OrderId);
        }
    }

    private static void ReadHeader(object? carrier, string name, out string? value, out IEnumerable<string>? values)
    {
        values = null;
        ((Dictionary<string, string>)carrier!).TryGetValue(name, out value);
    }
}

Burada ActivitySource.StartActivity yerine new Activity(...) kullandım. Sebebi şu: ActivitySource, kendisini dinleyen bir listener yoksa (yani OpenTelemetry gibi bir şey kurulu değilse) null döner ve log'a TraceId gelmez. Projede OpenTelemetry tracing açıksa ActivitySource ile ActivityKind.Consumer kullan; değilse elle oluşturulan Activity log korelasyonu için yeterli. Worker'ın çıktısı:

info: Bookstore.Api.ShippingWorker[0]
      => SpanId:038e75be855957b0, TraceId:fcee41938107cef33c87cce0271e3c46, ParentId:470b9d1fa575bdc7 => correlation.id:web-7f3c2a91
      Shipment scheduled for order 01a1b8e6-493e-7b1b-8995-fffa3c82477f

TraceId aynı, ParentId API isteğinin SpanId'si ve baggage'daki correlation.id de mesajla birlikte gelmiş. Baştaki ilk sorunun cevabı da burada: hiçbir şey yapmasaydık API ile Billing bağlanırdı, ama worker'ın log'unda hiç TraceId olmazdı, çünkü Channel'dan okunan mesajın etrafında bir Activity yok.

06-trace-chain-tr.png

Her iki uygulama da Seq'e yazdığı için zincirin tamamını tek sorguyla görebiliyorum. Seq'in HTTP API'sine gönderdiğim sorgu ve gerçek sonucu:

select count(*) from stream where @TraceId = 'fcee41938107cef33c87cce0271e3c46' group by SourceContext

{"Columns":["SourceContext","count(*)"],"ColumnMetadata":{},"Rows":[["Bookstore.Api.Orders",1],["Bookstore.Billing.Invoices",2],["Bookstore.Api.ShippingWorker",1]],"Statistics":{"ElapsedMilliseconds":1.0147}}

İki farklı logging kütüphanesi (biri Microsoft.Extensions.Logging, diğeri Serilog), iki process ve bir queue; dört event, tek TraceId. Seq sorgu dilinin ayrıntıları serinin "Seq ile Structured Log'ları Okumak" yazısında.

X-Correlation-ID mi, traceparent mı?

Birçok projede W3C standardından önce yazılmış bir X-Correlation-ID header'ı vardır. İkisi aynı işi mi yapıyor? Kısmen. traceparent teknik bir zincir kurar: kimin kimi çağırdığını, hangi adımın hangi adımın çocuğu olduğunu. Correlation id ise daha çok iş tarafı bir etiket: "kullanıcının bu ekrandaki bu tıklaması". Bir kullanıcı işlemi birden fazla HTTP isteği üretebilir; her istek kendi trace'ini alır, ama hepsi aynı correlation id'yi taşıyabilir.

Benim önerim: yeni sistemlerde kimlik olarak traceparent'ı kullan, kendi header'ını icat etme. Eski bir istemci ya da dış ortak X-Correlation-ID gönderiyorsa onu kaybetme, baggage'a koy:

app.Use(async (context, next) =>
{
    string? correlationId = context.Request.Headers["X-Correlation-ID"];
    if (correlationId is { Length: > 0 and <= 64 } && correlationId.All(c => char.IsAsciiLetterOrDigit(c) || c == '-'))
    {
        // baggage, HttpClient ile bir sonraki servise kendiliğinden taşınır
        Activity.Current?.AddBaggage("correlation.id", correlationId);
    }

    context.Response.OnStarting(() =>
    {
        context.Response.Headers["X-Trace-Id"] = Activity.Current?.TraceId.ToString();
        return Task.CompletedTask;
    });
    await next();
});

Header dışarıdan geliyor, bu yüzden uzunluğunu ve karakterlerini kontrol ediyorum. Kontrolsüz bir değer log'a girer, Billing'e baggage olarak gider ve 10 KB'lık bir header her log satırına kopyalanabilir. Log injection riskini de unutma: satır sonu karakteri içeren bir değer düz metin log dosyasında sahte bir satır gibi görünebilir. Baggage'ı yalnız korelasyon için kullan; içine kişisel veri ya da yetki bilgisi koyma, çünkü bir sonraki her servise açık metin olarak gider.

Kullanıcıya Hata Kodunu Göstermek

Korelasyonun en çok işe yaradığı an, kullanıcının "sipariş veremiyorum" diye destek ekibini aradığı an. Billing fatura limitini aştığında API'nin döndürdüğü yanıt:

HTTP/1.1 500 Internal Server Error
Content-Type: application/problem+json
X-Trace-Id: 99b632033749266ba7154b5b4654ab5a

{"type":"https://tools.ietf.org/html/rfc9110#section-15.6.1","title":"An error occurred while processing your request.","status":500,"traceId":"00-99b632033749266ba7154b5b4654ab5a-157e997fe467c64a-00"}

AddProblemDetails yanıta zaten bir traceId ekliyor, ama bu tam traceparent biçiminde. Benim middleware'imin eklediği X-Trace-Id ise yalnız TraceId; destek ekibinin Seq'e yapıştıracağı değer tam olarak bu. Hata ekranında "Destek kodu: 99b632033749266ba7154b5b4654ab5a" gösterirsen, telefondaki konuşma "dün akşam bir hata almıştım" yerine tek bir sorguya dönüşür. Global hata yönetiminin kendisi ASP.NET Core Web API serisinin "Global Hata Yönetimi: Exception Middleware ve ProblemDetails" yazısında.

İstemci tarafında Angular'ın functional interceptor'ı her API isteğine bir correlation id ekliyor ve hata olduğunda destek kodunu okuyor:

import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
import { catchError, throwError } from 'rxjs';

export const correlationIdInterceptor: HttpInterceptorFn = (req, next) => {
  if (!req.url.startsWith('/api/')) {
    return next(req);
  }

  const correlationId = `web-${crypto.randomUUID().slice(0, 8)}`;
  return next(req.clone({ setHeaders: { 'X-Correlation-ID': correlationId } })).pipe(
    catchError((error: HttpErrorResponse) => {
      const traceId = error.headers?.get('X-Trace-Id') ?? 'unknown';
      console.error(`Request failed. Support code: ${traceId} (correlation ${correlationId})`);
      return throwError(() => error);
    })
  );
};

Interceptor provideHttpClient(withInterceptors([correlationIdInterceptor])) ile kaydediliyor. Bu kodu Angular 22.2 ve TypeScript 6.0 ile tsc --noEmit kontrolünden geçirdim, tarayıcıda çalıştırmadım; yukarıdaki web-7f3c2a91 değeri curl ile aynı biçimde gönderildi. Frontend farklı bir origin'deyse iki ayar şart: CORS (Cross-Origin Resource Sharing) politikası X-Correlation-ID request header'ına izin vermeli ve X-Trace-Id'yi WithExposedHeaders ile açmalı; aksi hâlde tarayıcı header'ı JavaScript'e göstermez. CORS ayarının kendisi Web API serisinin "CORS ve Güvenlik" yazısında.

Üretimde Dikkat

TraceId'yi her log'a koymanın maliyeti çok düşük; scope'lar her log çağrısında zaten oluşturuluyor. Asıl maliyet log satırının boyutunda: yukarıdaki simple console çıktısında scope satırı mesajın kendisinden uzun. JSON çıktıda Scopes dizisi her event'te tekrar ediyor. Serilog ya da OTLP (OpenTelemetry Protocol) gibi TraceId'yi ayrı bir alan olarak taşıyan bir çıkış kullanıyorsan bu tekrar ortadan kalkar; bunu serinin "Serilog Sink'leri" yazısında göreceğiz.

Sampling bayrağına dikkat: traceparent'ın son iki karakteri (-00 ya da -01) trace'in kaydedilip kaydedilmediğini söylüyor. Log'larda trace-based sampling kullanırsan (serinin "Log Level'ları ve Maliyet" yazısında), bayrağı 00 olan isteklerin log'ları da atılabilir. İstemcinin gönderdiği bayrağa körü körüne güvenmek bu yüzden bir maliyet kararı.

Uzun süren batch işlerinde tek bir TraceId'ye binlerce log bağlanabilir. Her kayıt ya da her parti için yeni bir Activity açmak, hem sorguyu hem de trace görünümünü okunur tutar.

Sık Düşülen Tuzaklar

Birincisi, Task.Run ya da fire-and-forget ile başlatılan işin isteğin Activity'sini taşıdığını sanmak. Activity.Current bir AsyncLocal; Task.Run onu kopyalar, ama istek bittiğinde o Activity durdurulmuş olur ve iş saatler sonra hâlâ eski TraceId ile log yazar. Arka plan işine trace context'i mesaj gibi açıkça taşı.

İkincisi, kendi correlation id'ni üretip traceparent'ı yok saymak. İki ayrı kimlik sistemi ortaya çıkar; log'larda biri, trace aracında diğeri görünür ve ikisini eşlemek için ayrı bir tablo gerekir.

Üçüncüsü, consumer'da gelen traceparent'ı Activity yerine sadece bir log property'si olarak yazmak. Bu durumda consumer'ın kendi log'ları ve oradan yapılan HTTP çağrıları yeni bir TraceId ile devam eder; zincir bir adım sonra yine kopar.

Dördüncüsü, baggage'ı bir veri taşıma kanalı gibi kullanmak. Her servise, her istekte, açık metin olarak gider; boyutu büyüdükçe bütün çağrılar yavaşlar.

Küçük Bir Deneme

★☆☆ ActivityTrackingOptions satırını tamamen kaldır ve uygulamayı tekrar çalıştır. Console'da hangi alanlar hâlâ görünüyor, hangisi kayboldu? Sonra Tags seçeneğini ekleyip ne değiştiğine bak.

★★☆ ShippingWorker'daki extract kodunu kaldır, sadece new Activity("Shipping.Process").Start() bırak. Seq'te aynı @TraceId sorgusunu çalıştır ve kaç event döndüğünü say.

★★★ Worker'dan Billing'e ikinci bir HTTP çağrısı ekle (ör. kargo ücreti faturası). Billing'in log'unda TraceId'nin aynı kaldığını ve traceparent'taki parent id'nin worker'ın SpanId'si olmadığını, worker içindeki HttpClient Activity'sinin SpanId'si olduğunu doğrula.

Log'larımız artık birbirine bağlı, ama içlerinde ne olduğunu henüz sorgulamadık. Bir sonraki yazıda tehlikeli olan tarafa bakıyoruz: log'a giren parola, token, e-posta ve kart verisi; Microsoft.Extensions.Compliance.Redaction ile data classification, [LogProperties] ile redaction, HMAC (Hash-based Message Authentication Code) redactor ile maskelenmiş ama hâlâ korelasyon kurulabilen değerler ve log'da e-posta olmadığını doğrulayan bir test.