Mert Özen Her Satırda Daha İleri
Geliştirici Araçları .NET'te Profiling ve Diagnostics — #02

dotnet-counters ve Built-in Metric'ler: System.Runtime Meter'ı, ASP.NET Core/Kestrel Metric'leri ve Kendi Meter'ın

Mert Özen 14 Kas 2026 11 dk 2 görüntülenme
dotnet-counters ve Built-in Metric'ler: System.Runtime Meter'ı, ASP.NET Core/Kestrel Metric'leri ve Kendi Meter'ın

dotnet-counters ilk bakış aracıdır: .NET 9+ System.Runtime Meter'ı GC, JIT, thread pool ve CPU'yu, ASP.NET Core meter'ları istek sürelerini yüzdelikleriyle gösterir. Kümülatif ve anlık metric farkını, .NET 8'deki eski EventCounter isimlerini, IMeterFactory ile kendi Meter'ını yazmayı ve collect ile CSV baseline almayı Bookstore'da gerçek yük altında anlatıyoruz; baseline, CPU'nun üçte birini yiyen JIT'i ortaya çıkarıyor.

Sürüm notu: Bu yazıdaki kodlar .NET 10 (container'da runtime 10.0.15) ile derlenip denenmiştir; dotnet-counters 10.0.750501, yük için k6 2.4.0 kullanılmıştır. .NET 8 karşılaştırması runtime 8.0.25 ile yapılmıştır.

Önce Tahmin Et

Bookstore.Api bir süredir yük altında: 50 sanal kullanıcı sırayla sipariş veriyor ve kitap arıyor, servis saniyede 950 civarı isteğe cevap veriyor. dotnet-counters'ın ekranından birkaç satır:

[System.Runtime]
    dotnet.gc.heap.total_allocated (By)                             3.6738e+10
    dotnet.jit.compiled_methods ({method})                           1,123,822
    dotnet.thread_pool.queue.length ({work_item})                            0
    dotnet.thread_pool.thread.count ({thread})                               9

Üç şüphe: 36,7 GB allocation bir memory leak mi? 50 eşzamanlı kullanıcıya 9 thread yetiyor mu, thread pool tıkanmış olabilir mi? 1,1 milyon derlenmiş method normal mi? Bu üç satırdan yalnızca biri gerçek bir sorunu gösteriyor. Hangisi olduğunu ve diğer ikisinin neden yanıltıcı olduğunu yazının ortasında, baseline kaydından çıkan sayılarla göreceğiz.

dotnet-counters monitor ve System.Runtime Meter'ı

dotnet-counters iki işi yapıyor: monitor değerleri ekranda saniyede bir tazeliyor, collect aynı değerleri CSV (virgülle ayrılmış düz metin) ya da JSON dosyasına yazıyor. Hiç --counters vermezsen System.Runtime'ı izliyor. .NET 9 ile runtime metric'leri System.Diagnostics.Metrics API'sine taşındı ve System.Runtime adında bir Meter olarak yayınlanıyor; .NET 10 uygulamasında gördüğün isimler OpenTelemetry semantic convention'larına uyan dotnet.* isimleri. Satırlar GC (garbage collector; kullanılmayan object'leri temizleyen runtime bileşeni), JIT (Just-In-Time; kodu çalışma anında makine koduna çeviren derleyici), thread pool ve CPU tarafını anlatıyor:

docker exec -it profseries-api dotnet-counters monitor -p 1 --counters System.Runtime
[System.Runtime]
    dotnet.assembly.count ({assembly})                                      92
    dotnet.gc.collections ({collection})
        gc.heap.generation
        ------------------
        gen0                                                                54
        gen1                                                               810
        gen2                                                                 5
    dotnet.gc.heap.total_allocated (By)                             3.6738e+10
    dotnet.gc.last_collection.heap.size (By)
        gc.heap.generation
        ------------------
        gen0                                                                 0
        gen1                                                         5,726,688
        gen2                                                        82,359,008
        loh                                                            258,440
        poh                                                          1,368,696
    dotnet.gc.last_collection.memory.committed_size (By)            1.5441e+08
    dotnet.gc.pause.time (s)                                             4.103
    dotnet.jit.compilation.time (s)                                    426.108
    dotnet.jit.compiled_il.size (By)                                1.6203e+08
    dotnet.jit.compiled_methods ({method})                           1,123,822
    dotnet.monitor.lock_contentions ({contention})                      38,985
    dotnet.process.cpu.count ({cpu})                                         4
    dotnet.process.cpu.time (s)
        cpu.mode
        --------
        system                                                         213.419
        user                                                           751.756
    dotnet.process.memory.working_set (By)                          3.2123e+08
    dotnet.thread_pool.queue.length ({work_item})                            0
    dotnet.thread_pool.thread.count ({thread})                               9
    dotnet.thread_pool.work_item.count ({work_item})                14,837,913
    dotnet.timer.count ({timer})                                            46

Ekranı kısaltmak için köşeli parantezle yalnızca istediğin metric'leri seçebilirsin: --counters 'System.Runtime[dotnet.gc.collections,dotnet.gc.pause.time]'. Birden fazla meter'ı virgülle ayırıyorsun; köşeli parantez içindeki virgüller metric listesine ait.

Kümülatif mi, Anlık mı?

Bu ekranı okurken ilk yapılacak iş her satırın tipini bilmek. System.Runtime'daki metric'lerin çoğu Counter: process başladığından beri biriken toplam. dotnet.gc.heap.total_allocated'daki 36,7 GB, process'in ömrü boyunca ayırdığı belleğin toplamı; o kadar bellek kullandığı anlamına gelmiyor, aynı anda yaşayan object'lerin boyutu gen2'deki 82 MB civarında. dotnet.gc.pause.time da toplam: 4,1 saniye, GC'nin process ömrü boyunca thread'leri durdurduğu sürelerin toplamı. Diğerleri UpDownCounter: o anki değer. Thread sayısı, queue uzunluğu, working set, son GC'deki heap boyutu bu gruptan.

MetricTipNeye bakarsın
dotnet.gc.heap.total_allocatedKümülatifİki okuma arasındaki fark / süre = allocation rate
dotnet.gc.collectionsKümülatif, generation'a göreDakikada kaç gen2; gen2 sık ise 04
dotnet.gc.pause.timeKümülatif (saniye)Fark / süre = zamanın yüzde kaçı GC pause'unda
dotnet.jit.compiled_methodsKümülatifIsınmadan sonra artmamalı
dotnet.monitor.lock_contentionsKümülatifSaniyedeki artış; ani tırmanış lock çekişmesi demek
dotnet.process.cpu.timeKümülatif (saniye)Fark / süre / CPU sayısı = CPU kullanımı
dotnet.thread_pool.queue.lengthAnlıkSürekli sıfırın üstündeyse thread pool yetişemiyor
dotnet.thread_pool.thread.countAnlıkYavaş ve sürekli tırmanıyorsa starvation

Kümülatif bir sayının tek bir okuması neredeyse hiçbir şey söylemez; değişimine bakman gerekir. collect bunu senin yerine yapıyor: kümülatif metric'leri dosyaya her aralıktaki artış olarak (Rate) yazıyor.

.NET 8 ve Öncesi: EventCounter İsimlerine Düşüş

System.Runtime Meter'ı .NET 9 ile geldi. .NET 8 ya da daha eski bir uygulamaya bağlandığında dotnet-counters aynı isimle eski System.Runtime EventSource'unun EventCounter'larını gösteriyor. Aynı dotnet-counters 10.0.750501'i .NET 8.0.25'te çalışan küçük bir console uygulamasına bağlayıp CSV aldım:

Counter Name,Counter Type,Mean/Increment
% Time in GC since last GC (%),Metric,0
Allocation Rate (B / 1 sec),Rate,829328
CPU Usage (%),Metric,5.515957612596816E-05
Gen 0 GC Count (Count / 1 sec),Rate,0
Monitor Lock Contention Count (Count / 1 sec),Rate,1
Number of Methods Jitted,Metric,43
ThreadPool Queue Length,Metric,0
ThreadPool Thread Count,Metric,0
Time paused by GC (ms / 1 sec),Rate,0
Working Set (MB),Metric,44.302336

İsimler, birimler ve hatta anlamlar farklı. Eski "CPU Usage" yüzde veriyor, yeni dotnet.process.cpu.time saniye; eski "% Time in GC since last GC" yalnızca son GC'yi anlatıyor, yeni dotnet.gc.pause.time toplam pause süresi. Dashboard'unda ya da runbook'unda eski isimler varsa, .NET 9'a geçtiğin gün o satırlar boş kalır. Hangi isim hangisine karşılık geliyor sorusu serinin son yazısında, sürüm değişiklikleri içinde.

ASP.NET Core ve Kestrel Metric'leri

.NET 8'den beri ASP.NET Core da Meter tabanlı metric yayınlıyor. En çok işe yarayanı Microsoft.AspNetCore.Hosting meter'ındaki http.server.request.duration: her isteğin süresini method, status code ve route tag'leriyle bir histogram'a yazıyor. dotnet-counters histogram'lar için 50, 95 ve 99. yüzdelikleri gösteriyor:

docker exec -it profseries-api dotnet-counters monitor -p 1 \
    --counters 'Microsoft.AspNetCore.Hosting[http.server.request.duration],Bookstore.Ordering'
[Microsoft.AspNetCore.Hosting]
    http.server.request.duration (s)
        http.request.method http.response.status_code http.route        network.protocol.version url.scheme Percentile
        ------------------- ------------------------- ----------------- ------------------------ ---------- ----------
        GET                 200                       /api/books/search 1.1                      http       50         0.001
        GET                 200                       /api/books/search 1.1                      http       95         0.002
        GET                 200                       /api/books/search 1.1                      http       99         0.002
        POST                201                       /api/orders       1.1                      http       50         0.099
        POST                201                       /api/orders       1.1                      http       95         0.207
        POST                201                       /api/orders       1.1                      http       99         0.217

Route tag'i /api/books/search gibi template olarak geliyor, gerçek URL olarak değil; böylece sorgu parametresi kadar ayrı seri oluşmuyor. Kestrel'in kendi meter'ı Microsoft.AspNetCore.Server.Kestrel bağlantı tarafını anlatıyor: kestrel.active_connections, kestrel.queued_connections ve bağlantı başına süre olan kestrel.connection.duration. Bu meter yalnızca bağlantı açılıp kapanırken ölçüm yazıyor; keep-alive kullanan bir yük aracı bağlantılarını kurduktan sonra açtığın monitor ekranında hiç görünmeyebiliyor. Ben collect'i önce başlatıp sonra her istekte yeni bağlantı açan bir curl döngüsüyle gördüm:

Microsoft.AspNetCore.Server.Kestrel,kestrel.queued_connections ({connection})[network.transport=tcp;network.type=ipv6;server.address=::ffff:172.17.0.3;server.port=8080],Metric,0
Microsoft.AspNetCore.Server.Kestrel,kestrel.connection.duration (s)[network.protocol.name=http;network.protocol.version=1.1;network.transport=tcp;network.type=ipv6;server.address=::ffff:172.17.0.3;server.port=8080;Percentile=95],Metric,0.00321197509765625

kestrel.queued_connections'ın sıfırdan uzaklaşması, Kestrel'in yeni bağlantıları kabul etmeye yetişemediğini gösteriyor. Rate limiting kullanıyorsan Microsoft.AspNetCore.RateLimiting reddedilen ve bekleyen istekleri, dışarıya HttpClient ile çağrı yapıyorsan System.Net.Http istemci tarafındaki süre ve bağlantı havuzunu anlatıyor.

Kendi Meter'ın: Bookstore.Ordering

Runtime ve framework metric'leri "servis nasıl" sorusuna cevap veriyor; "iş nasıl" sorusunu sen eklemelisin. Bookstore'un sipariş akışına iki instrument ekledim: verilen sipariş sayısı (Counter) ve ödeme sağlayıcısının cevap süresi (Histogram):

using System.Diagnostics.Metrics;

namespace Bookstore.Api.Ordering;

public sealed class OrderingMetrics
{
    public const string MeterName = "Bookstore.Ordering";

    private readonly Counter<long> _ordersPlaced;
    private readonly Histogram<double> _paymentDuration;

    public OrderingMetrics(IMeterFactory meterFactory)
    {
        Meter meter = meterFactory.Create(MeterName);

        _ordersPlaced = meter.CreateCounter<long>(
            "bookstore.orders.placed", unit: "{order}", description: "Number of orders placed");

        _paymentDuration = meter.CreateHistogram(
            "bookstore.payment.duration", unit: "s", description: "Duration of payment authorization",
            advice: new InstrumentAdvice<double> { HistogramBucketBoundaries = [0.025, 0.05, 0.1, 0.25, 0.5, 1] });
    }

    public void OrderPlaced(string paymentMethod) =>
        _ordersPlaced.Add(1, new KeyValuePair<string, object?>("bookstore.payment.method", paymentMethod));

    public void PaymentCompleted(TimeSpan elapsed, string paymentMethod) =>
        _paymentDuration.Record(elapsed.TotalSeconds, new KeyValuePair<string, object?>("bookstore.payment.method", paymentMethod));
}
using System.Diagnostics;

namespace Bookstore.Api.Ordering;

public sealed record PlaceOrderRequest(int CustomerId, int BookId, int Quantity, string PaymentMethod);

public static class OrderEndpoints
{
    public static void MapOrderEndpoints(this IEndpointRouteBuilder app)
    {
        app.MapPost("/api/orders", async (PlaceOrderRequest request, OrderingMetrics metrics, CancellationToken ct) =>
        {
            long started = Stopwatch.GetTimestamp();
            // ödeme sağlayıcısını taklit ediyor: kart, cüzdandan yavaş
            int delayMs = request.PaymentMethod == "card" ? Random.Shared.Next(60, 220) : Random.Shared.Next(15, 60);
            await Task.Delay(delayMs, ct);
            metrics.PaymentCompleted(Stopwatch.GetElapsedTime(started), request.PaymentMethod);

            metrics.OrderPlaced(request.PaymentMethod);
            return Results.Created($"/api/orders/{Random.Shared.Next(1, 1_000_000)}", new { request.BookId, request.Quantity });
        });
    }
}

OrderingMetrics, Program.cs'te AddSingleton<OrderingMetrics>() ile kayıtlı. new Meter(...) yerine IMeterFactory kullanmanın iki faydası var: meter'ın ömrü DI (dependency injection; bağımlılıkları dışarıdan veren altyapı) container'ına bağlanıyor (container dispose edilince meter da kapanıyor) ve testte her test kendi factory'siyle ayrı meter alabiliyor, testler birbirinin ölçümünü görmüyor. İsimlendirmede OpenTelemetry'nin önerdiği küçük harf ve noktalı yapıyı, birim için s, By, {order} gibi UCUM (birimleri metin olarak yazmanın standart kodlaması) gösterimini kullandım. HistogramBucketBoundaries (.NET 9 ile geldi) histogram'ı bucket'larla dışarı aktaran OpenTelemetry gibi exporter'lar için bir öneri; dotnet-counters yüzdelikleri kendi hesaplıyor:

[Bookstore.Ordering]
    bookstore.orders.placed ({order})
        bookstore.payment.method
        ------------------------
        card                                                             1,779
        wallet                                                           1,117
    bookstore.payment.duration (s)
        bookstore.payment.method Percentile
        ------------------------ ----------
        card                     50                                       0.14
        card                     95                                      0.212
        card                     99                                      0.218
        wallet                   50                                      0.039
        wallet                   95                                      0.058
        wallet                   99                                       0.06

Kodda kart için 60-220 ms, cüzdan için 15-60 ms bekleme var; yüzdelikler tam bu aralıklara düşüyor. POST /api/orders'ın p95'i 207 ms; bunun 212 ms'lik kart p95'iyle neredeyse aynı olması, endpoint süresinin tamamına yakınının ödeme beklemesi olduğunu söylüyor. Bu metric'leri bir zaman serisi veritabanına ve dashboard'a taşımak (OpenTelemetry exporter'ı, Prometheus, Grafana) DevOps kategorisindeki Observability serisinin konusu; burada amaç ölçümün kendisini kurmak ve doğrulamak.

Baseline Kaydetmek: collect ile CSV

Sorun olmadığı bir günde kaydedilmiş bir baseline, olay anında "bu değer normal mi?" sorusuna cevap veren tek şey. Aynı yükü (50 sanal kullanıcı, 65 saniye) verip 60 saniyelik bir kayıt aldım:

docker exec profseries-api dotnet-counters collect -p 1 --refresh-interval 5 \
    --duration 00:00:01:00 --format csv -o /tmp/baseline.csv \
    --counters 'System.Runtime,Microsoft.AspNetCore.Hosting,Bookstore.Ordering'
docker cp profseries-api:/tmp/baseline.csv .
Timestamp,Provider,Counter Name,Counter Type,Mean/Increment
11/13/2026 23:58:30,System.Runtime,dotnet.jit.compiled_methods ({method} / 5 sec),Rate,4980
11/13/2026 23:58:30,System.Runtime,dotnet.gc.pause.time (s / 5 sec),Rate,0.020392999999999883
11/13/2026 23:58:30,Bookstore.Ordering,bookstore.orders.placed ({order} / 5 sec)[bookstore.payment.method=card],Rate,1489
11/13/2026 23:58:30,System.Runtime,dotnet.gc.heap.total_allocated (By / 5 sec),Rate,175837272

Kümülatif metric'ler Rate olarak 5 saniyelik artışla, anlık olanlar Metric olarak yazılıyor. 5 saniyelik aralıkların medyanından çıkan tablo:

ÖlçümBaseline (medyan)Hesap
SiparişSaniyede 480 (292 kart, 189 cüzdan)bookstore.orders.placed / 5
POST /api/orders p95206 mshttp.server.request.duration
GET /api/books/search p952,6 mshttp.server.request.duration
CPU4 CPU'nun yüzde 29'u(user + system) / 5 / 4
AllocationSaniyede 34 MBtotal_allocated / 5
GCDakikada 36 gen1, 0 gen2dotnet.gc.collections
GC pauseZamanın yüzde 0,44'ü (en kötü aralık yüzde 1,5)pause.time / 5
JITSaniyede 970 method, 0,39 CPU-saniyecompiled_methods, compilation.time
Lock contentionSaniyede 58lock_contentions / 5
Thread pool queue0-2Anlık

Sağlıklı Değerleri Okumak

Tahmin sorusunun cevabı bu tabloda. 36,7 GB allocation sorun değil: saniyede 34 MB'lık allocation'ı GC dakikada 36 gen1 ile, zamanın yüzde 0,44'ünü harcayarak temizliyor ve gen2'ye hiç gerek kalmıyor; gen2 heap'i 82 MB'ta sabit. 9 thread da sorun değil: queue sıfırda, yani işler beklemeden alınıyor. Sipariş endpoint'i zamanının neredeyse tamamını await Task.Delay'de geçiriyor ve beklerken thread tutmuyor; 50 eşzamanlı isteğe 9 thread'in yetmesi async kodun beklenen hâli. Thread sayısının yavaş ve sürekli tırmandığı, queue'nun dolduğu tablo Debugging serisinin "Hang ve Yavaşlık Teşhisi" yazısındaki starvation'dı.

Asıl sorun JIT satırında. Isınmış bir serviste yeni derlenen method sayısı sıfıra yakın olmalı; burada saniyede 970 method derleniyor ve JIT, 4 CPU'dan harcanan 1,17 CPU-saniyenin 0,39'unu, yani CPU zamanının üçte birini yiyor. Lock contention'ın saniyede 58 olması da muhtemelen aynı yerden geliyor. Metric'ler "neresi" sorusuna cevap vermez ama nereye bakacağını söyler: hangi kodun her istekte yeni bir şey derlediğini bir sonraki yazıda dotnet-trace ile bulacağız.

Genel olarak benim baktığım eşikler şunlar, ama bunlar kural değil, kendi baseline'ınla karşılaştırma için başlangıç: gen2 sayısı dakikada birkaçı geçiyorsa ya da LOH (Large Object Heap; büyük object'lerin ayrıldığı heap bölgesi) büyüyorsa allocation profilini çıkar (04). GC pause zamanın yüzde 5'ini geçiyorsa istek sürelerinde hissedilir. thread_pool.queue.length saniyelerce sıfırın üstünde kalıyorsa thread pool'a bak. Lock contention baseline'ın birkaç katına çıkıyorsa paylaşılan bir lock'u ara. Working set büyürken GC heap sabitse sorun managed heap'te değil (05).

Üretimde Dikkat

dotnet-counters'ın kendisi ucuz ama tag'ler değil. Her farklı tag kombinasyonu ayrı bir zaman serisi; bookstore.payment.method gibi birkaç değerli bir tag zararsız, müşteri ID'si ya da sipariş numarası gibi sınırsız bir tag ise process'in içinde binlerce seri biriktirir. dotnet-counters bunu sınırlamak için --maxTimeSeries (varsayılan 1000) ve --maxHistograms (varsayılan 10) parametrelerini kullanıyor. Varsayılan 10 histogram, birkaç route'u olan bir API'de çabuk doluyor; sınıra takılınca bazı histogram'lar ekranda hiç görünmüyor ve hata mesajı da dikkat çekmiyor. Bu sınırları büyütmek hedef process'te daha fazla bellek demek.

Aynı process'e aynı anda iki metric oturumu açılamıyor. collect çalışırken monitor açmayı denediğimde araç "Another metrics collection session is already in progress for the target process." deyip durdu. Production'da sürekli toplama için doğru yol dotnet-counters'ı açık bırakmak değil, metric'leri OpenTelemetry ile dışarı aktarmak; dotnet-counters olay anındaki hızlı bakış ve baseline kaydı için. Kayıt aralığını da düşün: --refresh-interval 1 kısa olayları yakalar ama dosyayı beş kat büyütür.

Sık Düşülen Tuzaklar

  • Kümülatif değeri oran sanmak: dotnet.gc.heap.total_allocated ya da dotnet.gc.pause.time'ın tek okumasıyla "çok yüksek" demek.
  • CSV'deki anlık metric'lere güvenmek: bu sürümde dotnet.thread_pool.thread.count ve dotnet.thread_pool.queue.length dosyaya Rate tipiyle, 5 saniyelik değişim olarak yazıldı; mutlak değer için monitor'e bak ya da değişimleri topla.
  • .NET 8'deki dashboard sorgularını .NET 9+ uygulamaya taşımak: eski EventCounter isimleri artık yayınlanmıyor.
  • Bitness uyumsuzluğu: Windows'ta 32 bit bir uygulamayı 64 bit dotnet-counters ile izlemeye çalışınca "A 32 bit processes cannot access modules of a 64 bit process" hatası alırsın; x86 aracı indir.
  • Meter'ı her istekte new Meter(...) ile oluşturmak: her seferinde yeni bir meter ve yeni instrument'lar demek; tek instance olmalı.
  • Kestrel ya da HttpClient metric'lerini keep-alive bağlantılar kurulduktan sonra açılan bir ekranda aramak.

Küçük Bir Deneme

★☆☆ Kendi servisinde dotnet-counters monitor --counters System.Runtime aç ve tablodaki her satırın kümülatif mi anlık mı olduğunu işaretle. 30 saniye arayla iki okuma alıp allocation rate'i elle hesapla, sonra collect'in Rate satırıyla karşılaştır.

★★☆ OrderingMetrics'e bookstore.orders.failed adında, error.type tag'li bir counter ekle; ödeme bekleme süresi 200 ms'yi geçen siparişleri "timeout" sayarak işaretle ve dotnet-counters'ta tag'e göre ayrıldığını gör.

★★★ Kendi servisin için 10 dakikalık bir baseline al: tipik yük altında collect --format csv, ardından küçük bir script'le tablodaki gibi medyan ve en kötü aralık değerlerini çıkar. Bu tabloyu runbook'una koy; bir sonraki olayda ilk karşılaştıracağın şey bu olacak.

Metric'ler bize JIT'in CPU'nun üçte birini yediğini söyledi ama hangi kodun yüzünden olduğunu söylemedi. Bir sonraki yazıda dotnet-trace ile CPU profili alıp flame graph'ı okuyacak, arama endpoint'indeki hatayı bulup düzeltecek ve önce/sonra karşılaştıracağız.