Distributed Tracing: Span, Context Propagation, W3C traceparent ve ActivitySource ile Özel Span'ler
Distributed tracing, bir isteğin geçtiği her adımı aynı trace id'yi taşıyan span'lar olarak kaydeder; .NET'te span bir Activity'dir ve servisler arasında W3C traceparent header'ıyla taşınır. Bookstore'un checkout'una ActivitySource ile span'lar ekliyor, exception ve status kaydediyor, retry'ların traceparent'ını, baggage'ı, bir queue üzerinden propagation'ı, null Activity'yi ve tarayıcıdan trace başlatmanın bugünkü durumunu görüyoruz.
Sürüm notu: Bu yazıdaki örnekler .NET 10, OpenTelemetry .NET 1.21, xUnit v3 4.0 ve Aspire Dashboard 13.6.5 ile denenmiştir; tarayıcı tarafı için OpenTelemetry JavaScript paketlerinin durumu npm ve resmi dokümandan kontrol edildi, Angular kodu çalıştırılmadı.
Önce Tahmin Et
Bookstore'un (sitedeki serilerin ortak örnek uygulaması, bir online kitapçı) API'si ödeme sağlayıcısına bir sipariş için dört kez gidiyor: üç deneme zaman aşımına uğruyor, dördüncüsü başarılı oluyor. Sağlayıcı her istekte gelen traceparent header'ını log'luyor. Dört satırda trace id aynı mı olur, yoksa her deneme yeni bir trace mi açar? Peki header'ın ortasındaki span id? İkinci soru: ActivitySource.StartActivity("reserve stock") çağrısı hangi durumda null döner? Cevaplar, gerçek çıktılarla aşağıda.
Trace'in Parçaları
Trace, bir isteğin sistem içindeki yolculuğunun tamamı; span ise o yolculuktaki tek bir iş: bir HTTP isteğini karşılamak, bir SQL sorgusu, bir ödeme çağrısı. Her span'ın bir adı, başlangıç zamanı, süresi ve bir trace id'si var; kökteki span hariç her birinin bir parent span'ı var ve bu parent ilişkileri ağacı oluşturuyor. Span kind, span'ın rolünü söylüyor: dışarıdan gelen isteği karşılayan Server, dışarıya çağrı yapan Client, bir mesajı kuyruğa koyan Producer, mesajı işleyen Consumer, süreç içindeki bir adım için Internal. Attribute'lar span'ın anahtar/değer bilgileri (http.route, bookstore.order.id), event'ler span'ın içindeki zaman damgalı olaylar (en yaygını exception), status ise span'ın Unset, Ok ya da Error ile bitip bitmediği. Link'ler bir span'ı parent'ı olmayan başka bir span'a bağlıyor; birden çok mesajı birlikte işleyen bir batch işlemi bunun tipik örneği.
.NET'te span'ın karşılığı System.Diagnostics.Activity, tracer'ın karşılığı ise ActivitySource. Bir önceki yazılarda gördüğümüz HTTP ve SQL span'larını ASP.NET Core, HttpClient ve SqlClient bu sınıflarla üretiyordu. Şimdi checkout'a kendi span'larımızı ekliyoruz.
ActivitySource ile Checkout'a Kendi Span'larımız
Metric'lerde olduğu gibi her module için bir kaynak açtık ve kurulumda joker karakterle kaydettik:
// Meter'lar gibi her module için bir ActivitySource. AddSource("Bookstore.*") ile kaydediliyor.
public static class BookstoreActivitySources
{
public static readonly ActivitySource Ordering = new("Bookstore.Ordering");
public static readonly ActivitySource Notifications = new("Bookstore.Notifications");
}
Kurulumdaki WithTracing'e tek satır eklendi: .AddSource("Bookstore.*"). OrderService'te üç yeri span'a aldık: stok kontrolü, stok rezervasyonu ve ödeme. İlk ikisinin içinde zaten SQL span'ları vardı; kendi span'ımız onları anlamlı bir başlık altında topluyor:
using (Activity? check = BookstoreActivitySources.Ordering.StartActivity("check availability"))
{
check?.SetTag("bookstore.cart.line_count", cart.Items.Count);
foreach (CartItem item in cart.Items)
{
if (await inventory.GetAvailableAsync(item.BookId, ct) < item.Quantity)
{
shortIsbns.Add(books[item.BookId].Isbn);
}
}
}
Ödeme span'ı daha önemli, çünkü resilience handler'ın bütün denemeleri onun altında toplanıyor ve hata durumunu da o taşıyor:
private async Task<PlaceOrderOutcome> AuthorizePaymentAsync(Order order, CancellationToken ct)
{
// Resilience handler'ın yaptığı her HTTP denemesinin (retry'lar dahil) parent'ı.
using Activity? activity = BookstoreActivitySources.Ordering.StartActivity("authorize payment");
activity?.SetTag("bookstore.order.id", order.Id);
try
{
await payments.AuthorizeAsync(new PaymentRequest(order.Id, order.Total, order.Currency), ct);
order.Status = OrderStatus.Paid;
await db.SaveChangesAsync(ct);
await confirmations.PublishAsync(order.Id, order.Customer.Email, ct);
return new PlaceOrderOutcome.Placed(order);
}
catch (Exception ex) when (ex is HttpRequestException or TimeoutException or Polly.Timeout.TimeoutRejectedException)
{
activity?.AddException(ex);
activity?.SetStatus(ActivityStatusCode.Error, "Payment could not be authorized");
logger.LogError(ex, "Payment for order {OrderId} failed", order.Id);
order.Status = OrderStatus.Cancelled;
await inventory.ReleaseAsync([.. order.Items.Select(i => new StockLine(i.BookId, i.Quantity))], ct);
await db.SaveChangesAsync(ct);
return new PlaceOrderOutcome.PaymentFailed(order.Id);
}
}
using önemli: span'ın süresi Dispose ile bitiyor. AddException (.NET 9 ile geldi) exception'ı OpenTelemetry'nin exception event'i olarak span'a yazıyor; status'u ise ayrıca set etmek gerekiyor, exception kaydetmek span'ı kendiliğinden hatalı yapmıyor. Sipariş numarasını span'a tag olarak yazdık; metric'te yasak olan yüksek cardinality'li bilgi, trace'te tam yerinde. Uygulamayı standalone Aspire Dashboard'a gönderip 40 siparişlik trafik çalıştırdık. Sonra en yavaş başarılı ve başarısız iki siparişin trace'ini açtık:
0.0 5106.1 POST /api/orders/
0.2 0.4 SELECT [c].[Id] [c].[CreatedAtUtc] [c].[UpdatedAtUtc] [ordering].[Carts] [ordering].[CartItems]
0.7 0.4 SELECT [catalog].[Books]
1.0 0.8 check availability
1.1 0.3 SELECT [inventory].[StockItems]
1.4 0.4 SELECT [inventory].[StockItems]
2.1 0.7 reserve stock
2.1 0.4 UPDATE
2.5 0.3 UPDATE
2.8 0.3 SELECT [ordering].[Customers]
3.3 0.3 BookstoreDb
3.6 0.4 DELETE
4.1 0.5 DELETE
5.7 5100.1 authorize payment
5.7 2002.8 POST ERROR
2513.1 2002.4 POST ERROR
5018.8 80.5 POST
5100.4 5.2 UPDATE
5105.8 0.0 order-confirmations publish
5105.9 49.1 order-confirmations process

Ağaç artık iş diliyle okunuyor: "authorize payment" 5,1 saniye sürmüş ve içindeki üç denemenin ikisi hatalı. Dört denemenin dördü de zaman aşımına uğrayan siparişin trace'inde ise hem kök span hem de ödeme span'ı hatalı:
0.0 9541.5 POST /api/orders/ ERROR
8.2 9528.0 authorize payment ERROR
8.3 2001.7 POST ERROR
2512.1 2002.4 POST ERROR
5018.0 2000.9 POST ERROR
7522.8 2000.7 POST ERROR
authorize payment status: Error "Payment could not be authorized" bookstore.order.id=1136
event exception
exception.type=Polly.Timeout.TimeoutRejectedException
exception.message=The operation didn't complete within the allowed timeout of '00:00:02'.
Kök span'ın hatası bizden gelmiyor: ASP.NET Core instrumentation'ı, 5xx dönen her isteğin sunucu span'ını hatalı işaretliyor (http.response.status_code=502, error.type=502). 4xx cevaplar ise sunucu tarafında hata sayılmıyor; semantic convention'a göre istemcinin hatası.
W3C Trace Context: traceparent
Bir span'ın parent'ı başka bir process'teyse trace context ağ üzerinden taşınmalı. Standart yol W3C (World Wide Web Consortium; web standartlarını yayınlayan kuruluş) Trace Context: traceparent ve tracestate HTTP header'ları. Level 1 sürümü 2021'den beri W3C Recommendation; Level 2 ise Mart 2024 tarihli bir Candidate Recommendation taslağı. Önce Tahmin Et'teki ilk sorunun cevabı, sahte ödeme sağlayıcısının log'unda (4 denemelik bir sipariş):
18:43:21.785 info: Program[0] order-1172 traceparent=00-ecc0e08609bd750a25ef6b27b6533bcb-055bbb333454c35e-03 baggage=
18:43:23.787 warn: Program[0] order-1172 attempt 1: client gave up after waiting
18:43:24.289 info: Program[0] order-1172 traceparent=00-ecc0e08609bd750a25ef6b27b6533bcb-c2c7e0d18d6225bb-03 baggage=
18:43:26.291 warn: Program[0] order-1172 attempt 2: client gave up after waiting
18:43:26.794 info: Program[0] order-1172 traceparent=00-ecc0e08609bd750a25ef6b27b6533bcb-a60d2b8c3861cbb4-03 baggage=
18:43:28.795 warn: Program[0] order-1172 attempt 3: client gave up after waiting
18:43:29.299 info: Program[0] order-1172 traceparent=00-ecc0e08609bd750a25ef6b27b6533bcb-56e5e946075fe9bc-03 baggage=
18:43:29.352 info: Program[0] order-1172 attempt 4: authorized 249.90 TRY in 53 ms
Header dört parçadan oluşuyor: sürüm (00), 16 byte'lık trace id, 8 byte'lık parent span id ve flag'ler. Trace id dört denemede de aynı: hepsi aynı trace'in parçası. Parent span id her denemede farklı, çünkü her deneme ayrı bir HTTP istemci span'ı ve sağlayıcı açısından parent'ı o span. Bu yüzden sağlayıcı OpenTelemetry kullansaydı, onun span'ları doğru denemenin altına yerleşirdi. Son byte 03: en düşük bit (01) "sampled", yani bu trace kaydediliyor; ikinci bit (02) Level 2'nin random flag'i, trace id'nin sağdaki en az 7 byte'ının rastgele üretildiğini söylüyor. tracestate ise vendor'lara özel ek bilgi için; bizim çıktıda yok.
Bu işi kimin yaptığına bakmak gerekiyor. HttpClient tarafında header'ı kod yazmadan HttpClient instrumentation'ı ekliyor; ASP.NET Core tarafında gelen header'dan parent'ı okuyup sunucu span'ını onun child'ı yapan da framework. Bunu curl ile doğruladık: sampled flag'li bir header gönderince API'nin span'ı gönderdiğimiz span id'nin child'ı olarak kaydedildi, aynı header'ı -00 flag'iyle gönderince hiç span kaydedilmedi:
curl -s http://localhost:5080/api/categories -H "traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
curl -s http://localhost:5080/api/categories -H "traceparent: 00-5bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-00"
4bf92f3577b34da6a3ce929d0e0e4736 GET /api/categories 2ff977c117089b24 parent= 00f067aa0ba902b7
4bf92f3577b34da6a3ce929d0e0e4736 SELECT [catalog].[Categories] 939f307b20abf46d parent= 2ff977c117089b24
Varsayılan sampler parent'ın kararına uyuyor (parent-based). Bu, istemcinin "kaydetme" dediği bir isteği API'nin de kaydetmeyeceği anlamına geliyor; tarayıcıdan trace başlatmayı düşünürken bunu hatırlayacağız.
Baggage: Ne Konur, Ne Asla Konmaz
Baggage, trace context'in yanında taşınan anahtar/değer çiftleri; W3C'nin ayrı bir baggage header'ı var. Trace id'den farkı, değerin uygulama koduna açık olması: aşağı akıştaki her servis okuyabiliyor ve kendi span'larına yazabiliyor. Örneğin "bu istek mobil uygulamadan geldi" ya da "kurumsal sipariş" bilgisi. .NET'te iki ayrı baggage kavramı var ve bunu denemeden bilmek zor. Bir middleware'e geçici olarak iki satır ekledik:
System.Diagnostics.Activity.Current?.AddBaggage("demo.activity", "1");
OpenTelemetry.Baggage.SetBaggage("demo.otel", "2");
Sahte sağlayıcının gördüğü header:
18:43:56.135 info: Program[0] order-1175 traceparent=00-1307a830f7a794c6dbce4b8f2235be17-2903e8e666e9785d-03 baggage=demo.otel=2
Dışarıya yalnızca OpenTelemetry.Baggage ile eklenen değer gitti; Activity'nin kendi baggage'ı yolda kaldı. OpenTelemetry kurulu bir .NET uygulamasında baggage için OpenTelemetry'nin API'sini kullan. Baggage'a ne konacağı ise daha önemli bir soru. Header her çağrıyla birlikte gidiyor: senin servislerine ama aynı zamanda ödeme sağlayıcısı gibi üçüncü taraflara da. Kullanıcı id'si, e-posta, token, sipariş tutarı gibi bir bilgi baggage'a asla girmemeli. Girebilecek olan, düşük hassasiyetli ve aşağı akışta gerçekten kullanılacak bağlam bilgisi; dış servislere giden isteklerde de baggage'ı temizlemek gerekiyor.
Queue Üzerinden Propagation
HTTP'de propagation'ı framework yapıyor; bir mesajlaşma sisteminde ise bunu ya messaging library'si ya da sen yapıyorsun. Sipariş onay e-postasını checkout'tan ayırdık: ödeme başarılı olunca bir mesaj in-process bir Channel'a yazılıyor, bir background service onu okuyup e-postayı gönderiyor. Gerçek bir broker (RabbitMQ, Azure Service Bus) Message Queue'larla Çalışmak serisinin konusu; trace context'in taşınma biçimi orada da aynı: mesajın header'larına yazmak.
public async ValueTask PublishAsync(int orderId, string customerEmail, CancellationToken ct)
{
using Activity? activity = BookstoreActivitySources.Notifications.StartActivity("order-confirmations publish", ActivityKind.Producer);
activity?.SetTag("messaging.system", "in-process");
activity?.SetTag("messaging.destination.name", "order-confirmations");
activity?.SetTag("bookstore.order.id", orderId);
Dictionary<string, string> headers = [];
// traceparent (ve varsa baggage) mesaja yazılır; consumer aynı trace'e devam edebilir.
Propagator.Inject(new PropagationContext(activity?.Context ?? default, Baggage.Current), headers,
static (carrier, key, value) => carrier[key] = value);
await channel.Writer.WriteAsync(new OrderConfirmationMessage(orderId, customerEmail, headers), ct);
}
Tüketici tarafı header'lardan context'i çıkarıp yeni span'ı onun child'ı olarak açıyor:
PropagationContext parent = Propagators.DefaultTextMapPropagator.Extract(default, message.Headers,
static (carrier, key) => carrier.TryGetValue(key, out string? value) ? [value] : []);
Baggage.Current = parent.Baggage;
// Consumer span'ı checkout'un trace'ine devam eder: parent'ı mesajdaki producer span'ı.
using Activity? activity = BookstoreActivitySources.Notifications.StartActivity(
"order-confirmations process", ActivityKind.Consumer, parent.ActivityContext);
Yukarıdaki trace'in son iki satırı bu: order-confirmations process, publish'in child'ı olarak aynı trace'te. Background service'te span'ı bu context olmadan açsaydık, Activity.Current boş olduğu için yeni bir kök span ve yeni bir trace oluşurdu; checkout ile e-posta arasındaki bağ kopardı. Burada bir karar var. Bir mesaj tek bir isteğin devamıysa (bizim e-posta) parent-child doğal. Ama bir job yüzlerce siparişi birlikte işliyorsa ya da mesaj saatler sonra işleniyorsa, tek bir parent seçmek yanlış olur ve saatlerce süren bir trace okunmaz hâle gelir. O durumda job yeni bir kök span açıp gelen her mesajın context'ini link olarak eklemeli. OpenTelemetry'nin messaging semantic convention'ları da varsayılan olarak link'i öneriyor.
Bu davranışı bir integration testiyle sabitledik. Test bir ActivityListener ile Bookstore.* kaynaklarını dinliyor, sipariş veriyor ve consumer span'ının ödeme span'ıyla aynı trace'te, parent'ının da publish span'ı olduğunu doğruluyor:
Assert.Equal(payment.TraceId, process.TraceId);
Assert.Equal(publish.SpanId, process.ParentSpanId);
Assert.Equal(ActivityKind.Consumer, process.Kind);
Kimse Dinlemiyorsa: null Activity
Kodda her yerde activity?.SetTag(...) yazmamızın sebebi Önce Tahmin Et'teki ikinci soru. StartActivity, o kaynağı dinleyen bir listener yoksa ya da sampler span'ı kaydetmemeye karar verirse null döndürüyor; böylece telemetry kapalıyken span oluşturmanın maliyeti neredeyse sıfır. Bunu da bir testle gösterdik:
[Fact]
public void StartActivity_returns_null_when_nobody_listens()
{
using ActivitySource source = new("Demo.NobodyListens");
Assert.Null(source.StartActivity("check availability"));
using ActivityListener listener = new()
{
ShouldListenTo = s => s.Name == source.Name,
Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllDataAndRecorded
};
ActivitySource.AddActivityListener(listener);
using Activity? activity = source.StartActivity("check availability");
Assert.NotNull(activity);
}
Testin ilk hâlinde kaynağın adı Bookstore.TracingDemo idi ve ilk Assert.Null başarısız oldu: Assert.Null() Failure: Value is not null. Aynı test process'inde ayağa kalkan API'nin OpenTelemetry kurulumu AddSource("Bookstore.*") ile bu adı da dinliyordu. Joker karakter kullanıyorsan adlandırma kuralının neyi kapsadığını bil.
Angular Tarafı: Trace'i Tarayıcıdan Başlatmak
Şu an trace API'nin sunucu span'ıyla başlıyor. Tarayıcıdan başlatmak, kullanıcının yaşadığı süreyi de (ağ, sayfanın kendi işi) trace'e katar. Seçenekleri bugünkü durumuyla kontrol ettik. OpenTelemetry'nin JavaScript dokümanı tarayıcı instrumentation'ını açıkça deneysel ve büyük ölçüde tanımlanmamış olarak işaretliyor. Fetch isteklerine traceparent ekleyen @opentelemetry/instrumentation-fetch paketi (0.301) README'sinde deprecated olarak işaretli ve kullanıcıları yeni opentelemetry-browser repo'sundaki @opentelemetry/browser-instrumentation paketine yönlendiriyor; o paket de 0.11 sürümünde ve "experimental" alt yollarıyla geliyor. Angular kodu yazıp çalıştırmadık; bu durumdaki bir API'ye bugün production'da yaslanmak, sık kırılma demek.
Tarayıcıdan başlatmaya karar verirsen API tarafında iki şey gerekiyor. Birincisi CORS (Cross-Origin Resource Sharing; tarayıcının başka origin'e yapılan isteklere izin verme kuralları): bookstore-web API'den farklı bir origin'den sunuluyorsa, tarayıcı traceparent header'ını ancak sunucu izin verirse gönderir.
// bookstore-web başka bir origin'den sunuluyorsa: tarayıcı trace'i başlatabilir, header'ı göndermesine izin verilmeli.
builder.Services.AddCors(options => options.AddPolicy("bookstore-web", policy => policy
.WithOrigins(builder.Configuration.GetSection("Cors:Origins").Get<string[]>() ?? [])
.WithMethods("GET", "POST", "PUT", "DELETE")
.WithHeaders("content-type", "traceparent", "tracestate")));
curl -s -i -X OPTIONS http://localhost:5080/api/orders -H "Origin: http://localhost:4200" -H "Access-Control-Request-Method: POST" -H "Access-Control-Request-Headers: content-type,traceparent,baggage"
HTTP/1.1 204 No Content
Date: Sat, 14 Nov 2026 18:44:49 GMT
Server: Kestrel
Access-Control-Allow-Headers: content-type,traceparent,tracestate
Access-Control-Allow-Methods: GET,POST,PUT,DELETE
Access-Control-Allow-Origin: http://localhost:4200
Preflight'ta baggage'ı da istedik; sunucu reddetmedi, yalnızca izin listesinde onu yazmadı. Kararı tarayıcı veriyor: listede olmayan header'lı isteği göndermiyor. İkincisi sampling: yukarıda gördüğümüz gibi API, tarayıcının "kaydetme" dediği isteği kaydetmiyor; tarayıcıda yüzde 10 örnekleme yaparsan API'deki trace'lerin yüzde 90'ını da kaybedersin. Çoğu ekip için trace'i API'de başlatmak yeterli: süre sorunlarının büyük kısmı sunucu tarafında ve tarayıcı tarafındaki performans ölçümü (Core Web Vitals) Web Performansı serisinin konusu.
Üretimde Dikkat
Span'ları anlamlı adımlara aç, her method'a değil. Bir span'ın maliyeti küçük ama sıfır değil; asıl maliyet backend'de. "check availability", "reserve stock", "authorize payment" gibi bir olay sırasında soracağın sorulara karşılık gelen adımlar yeterli.
Span adını düşük cardinality'li tut, değişkenleri attribute'a koy. "authorize payment" iyi bir ad; $"authorize payment {order.Id}" her sipariş için ayrı bir ad üretir ve backend'de gruplamayı bozar.
Dış servislere giden isteklerde trace context'in de gittiğini unutma. traceparent zararsız bir id, ama baggage'a ne koyduğunu kontrol et ve gerekirse dış istemcilerde temizle.
Mesajlaşmada parent mi link mi kararını bilinçli ver. Tek mesaj ve kısa gecikme için parent okunaklı bir ağaç verir; batch işleyen ya da gecikmeli job'larda link kullan.
Sık Düşülen Tuzaklar
PII'yi (Personally Identifiable Information; kişiyi tanımlayan bilgi) attribute'a yazmak. Sipariş numarası span'da sorun değil; müşterinin e-postası, adı ya da adresi sorun. Span'lar log'lardan daha az dikkatle saklanır ve daha geniş bir ekip tarafından okunur.
Status'u set etmeyi unutmak. AddException exception'ı kaydeder ama span'ı hatalı yapmaz; hata oranı ve "yalnız hatalı trace'leri sakla" gibi sampling kuralları status'a bakar.
Background iş akışında context'i kaybetmek. Task.Run ve await Activity.Current'ı taşır, ama bir queue'dan ya da veritabanından okunan iş için bunu sen yapmalısın. Aksi hâlde her mesaj yeni bir trace başlatır ya da daha kötüsü, bir yerde kalmış eski bir Activity.Current'ın altına yanlış parent ile yazılır.
activity'nin null olabileceğini unutmak. activity.SetTag listener yokken NullReferenceException fırlatır; her zaman ?. kullan.
Küçük Bir Deneme
★☆☆ Sahte sağlayıcıya gelen traceparent'ları log'dan oku ve dashboard'da aynı siparişin trace'ini aç. Header'daki parent span id'lerin hangi span'lara karşılık geldiğini eşleştir.
★★☆ OrderConfirmationSender'ı, 10 mesajı birlikte işleyen bir batch consumer'a çevir. Parent yerine her mesajın context'ini ActivityLink olarak ekleyen bir kök span aç ve dashboard'da trace'lerin nasıl göründüğünü karşılaştır.
★★★ Payments module'ündeki typed client'a, dış sağlayıcıya giden isteklerden baggage header'ını silen bir DelegatingHandler ekle. Sahte sağlayıcının log'unda baggage'ın kaybolduğunu, ama checkout'un consumer span'ında hâlâ okunabildiğini doğrula.
Uygulama artık zengin bir telemetry üretiyor ve hepsini doğrudan bir backend'e gönderiyor. Bir sonraki yazıda araya OpenTelemetry Collector'ı koyuyoruz: receiver, processor ve exporter'lar, tail sampling ile yalnız hatalı ve yavaş trace'leri saklamak, gereksiz log'ları filtrelemek ve bu yazıdaki onay e-postası log'unda görünen müşteri e-postasını Collector'da temizlemek.