Aspire Dashboard: Log, Trace ve Metric'leri Okumak, Standalone Kullanım ve Telemetry'nin Kalıcılığı
Aspire dashboard, service'lerin OTLP ile gönderdiği log, trace ve metric'leri tek yerde gösteren bir geliştirme aracı; 13.6'dan beri her çalıştırmayı SQLite'ta saklıyor, Aspire olmadan da tek başına çalışıyor ama production observability aracı değil. Yazı yavaş bir siparişin waterfall'unda index'siz bir EF Core sorgusunu ve sıralı service çağrılarını bulup süreyi 69 ms'den 5 ms'ye indiriyor; aspire otel, export ve API key'li standalone dashboard'u gerçek çıktılarla gösteriyor.
Sürüm notu: Bu yazıdaki kodlar .NET 10, Aspire 13.6 (dashboard 13.6.5), Entity Framework Core 10 ve OpenTelemetry .NET 1.21 ile derlenip denenmiştir.
Önce Tahmin Et
Catalog'un kitap detay endpoint'ine kitap sayfası için küçük bir özellik eklendi: "yazarın diğer kitapları". Ordering bu alanı hiç kullanmıyor; sipariş verirken yalnız fiyatı okuyor. Ertesi gün beş kalemli siparişlerin süresi belirgin şekilde uzuyor. Sebebi bulmak için hangi service'in log'una bakarsın? Bir de ikinci soru: AppHost'u kapatıp açtığında dünkü trace'ler dashboard'da durur mu? İkisinin de cevabı dashboard'da.
Dashboard'un Sayfaları
01'den beri dashboard'u açıp resource listesine baktık. Sol menüde beş sayfa var. Resources, resource'ların durumunu, health'ini, URL'lerini ve environment variable'larını gösteriyor; üstteki sekmelerden Parameters, 02'deki parameter'ları (secret olanlar maskeli), Graph ise WithReference ilişkilerinden çizilen bağımlılık grafını açıyor. Console, process'in standart çıktısı: Aspire bunu uygulamadan bağımsız olarak topluyor, SQL Server container'ının log'u da burada. Structured logs, OpenTelemetry ile gönderilen log kayıtları: mesaj şablonu, parametreleri ayrı alanlar, trace ve span id'leriyle. Traces, dağıtık trace'ler ve waterfall görünümü. Metrics, her resource'un meter'ları ve instrument'ları. Console ile Structured logs arasındaki fark önemli: Console'da gördüğün satır bir metin; Structured logs'ta aynı satırın OrderId alanına göre filtreleyebilir, oradan tek tıkla o isteğin trace'ine gidebilirsin. 01'de gördüğümüz gibi ikincisi için service'te OpenTelemetry olması gerekiyor.
Kendi Telemetry'mizi Eklemek
ASP.NET Core, HttpClient ve Entity Framework Core (EF Core) span'ları ServiceDefaults ve client integration'lardan hazır geliyor. Business tarafında "bir sipariş verildi" bilgisini de görmek için Ordering'e bir ActivitySource ve bir Meter ekledik:
using System.Diagnostics;
using System.Diagnostics.Metrics;
namespace Bookstore.Ordering.Api.Orders;
// Source ve meter uygulama adını kullanıyor; ServiceDefaults bu adı OpenTelemetry'ye kaydediyor
public sealed class OrderingTelemetry
{
public const string Name = "Bookstore.Ordering.Api";
public static readonly ActivitySource ActivitySource = new(Name);
private readonly Counter<long> ordersPlaced;
private readonly Histogram<double> orderTotal;
public OrderingTelemetry(IMeterFactory meterFactory)
{
Meter meter = meterFactory.Create(Name);
ordersPlaced = meter.CreateCounter<long>("bookstore.orders.placed", unit: "{order}", description: "Orders placed");
orderTotal = meter.CreateHistogram<double>("bookstore.orders.total", unit: "TRY", description: "Order total");
}
public void OrderPlaced(Order order)
{
ordersPlaced.Add(1);
orderTotal.Record((double)order.Total);
}
}
Template'in ServiceDefaults'u trace tarafında tracing.AddSource(builder.Environment.ApplicationName) ile uygulamanın adını taşıyan source'u zaten dinliyor, metric tarafında ise böyle bir satır yok. Ona da kendi varsayılanımızı ekledik:
metrics.AddMeter(builder.Environment.ApplicationName)
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation();
Bu kuralla her service, assembly adıyla aynı adda bir source ve meter açtığında ek ayar yapmadan dashboard'a düşüyor. Sipariş endpoint'inde katalog fiyatlarını okuyan kısmı Fetch catalog prices adlı bir span'a aldık, sipariş kaydedilince de counter'ı artırıp structured bir log yazdık. Metrics sayfasında Bookstore.Ordering.Api meter'ının altında bookstore.orders.placed ve bookstore.orders.total, hemen altında da Microsoft.AspNetCore.Hosting, System.Net.Http ve System.Runtime meter'ları görünüyor. Instrument isimlendirmesi (nokta ile ayrılmış, küçük harf, birim ayrı alanda) OpenTelemetry'nin semantic convention'larını izliyor.
Yavaş Siparişin Trace'i
Yavaşlığı görünür kılmak için katalogu büyüttük: AppHost, migration worker'a yalnız lokal ortamda kullanılan bir değişken veriyor (.WithEnvironment("Seed__GeneratedBooks", "200000")) ve worker, boş bir veritabanına 5 klasiğin yanında 200.000 üretilmiş kitap ekliyor (2.000 farklı yazar). Catalog'a eklenen yeni özellik şu:
// Kitap sayfası için: aynı yazarın en fazla üç kitabı daha
string[] moreByAuthor = await db.Books.AsNoTracking()
.Where(b => b.Author == book.Author && b.Isbn != isbn)
.OrderBy(b => b.Title)
.Select(b => b.Title)
.Take(3)
.ToArrayAsync(ct);
Gateway üzerinden beş kalemli 30 sipariş verdik:
n=30 median=69 ms p95=93 ms max=103 ms
Önce Tahmin Et'teki ilk soru: hangi log'a bakmalı? Hiçbirine. Traces sayfasında son siparişin trace'ini açtık:

Waterfall iki şeyi aynı anda söylüyor. Birincisi şekil: Ordering'in Fetch catalog prices span'ı altında beş HTTP GET çağrısı merdiven gibi art arda diziliyor. Her biri bir öncekinin bitmesini bekliyor; beş kalem, beş sıralı ağ çağrısı. İkincisi oran: her Catalog çağrısının içinde iki SQL span'ı var. Kitabı ISBN (kitapları tanımlayan uluslararası standart numara) ile bulan ilki 1 ms civarında, ikincisi ise 10–13 ms; Catalog'daki sürenin neredeyse tamamı. O span'a tıklayınca detay panelinde OpenTelemetry'nin veritabanı attribute'ları görünüyor:
db.system.name microsoft.sql_server
db.namespace BookstoreDb
db.query.summary SELECT [Books]
db.query.text SELECT TOP(@p) [b].[Title]
FROM [Books] AS [b]
WHERE [b].[Author] = @book_Author AND [b].[Isbn] <> @isbn
ORDER BY [b].[Title]
Author kolonunda index yok; SQL Server her istekte 200.005 satırı tarıyor. Ordering'in hiç kullanmadığı bir alan, sipariş süresinin büyük kısmını harcıyor. Log'larda bunu görmek mümkün değildi: her istek 200 dönüyordu ve hiçbir log satırı "yavaşım" demiyordu.
İki Düzeltme, Ölçerek
Birincisi index: BookstoreContext'e book.HasIndex(b => b.Author); ekleyip AddBookAuthorIndex migration'ını ürettik; worker bir sonraki açılışta CREATE INDEX [IX_Books_Author] ON [Books] ([Author]); çalıştırdı. Aynı 30 sipariş:
n=30 median=22 ms p95=34 ms max=316 ms
Trace'te ikinci SQL span'ı 10–13 ms'den 1 ms'ye indi, ama merdiven duruyordu: beş sıralı çağrı, her biri 3 ms. İkinci düzeltme, Ordering'in ihtiyacı olan şeyi tek seferde istemek. Catalog'a diğer service'ler için bir toplu sorgu endpoint'i ekledik:
// Diğer service'ler için fiyat sorgusu: çok kitap için tek sorgu
books.MapGet("/lookup", async ([FromQuery] string[] isbn, BookstoreContext db, CancellationToken ct) =>
await db.Books.AsNoTracking()
.Where(b => isbn.Contains(b.Isbn))
.Select(b => new BookResponse(b.Isbn, b.Title, b.Author, b.Price, b.Currency))
.ToListAsync(ct));
Ordering artık sipariş başına bir çağrı yapıyor:
IReadOnlyList<CatalogBook> books = await catalog.GetBooksAsync(request.Items.Select(i => i.Isbn), ct);
Dictionary<string, CatalogBook> byIsbn = books.ToDictionary(b => b.Isbn);
n=30 median=5 ms p95=6 ms max=216 ms
0.0 ms 5 ms gateway: POST /api/orders/{**rest}
0.2 ms 5 ms ordering: POST /api/orders/
0.3 ms 4 ms ordering: Fetch catalog prices
0.4 ms 4 ms ordering: GET http://localhost:5080/api/books/lookup
0.6 ms 4 ms catalog: GET /api/books/lookup
0.9 ms 3 ms catalog: SELECT [Books] ... WHERE [b].[Isbn] IN (@isbn1, @isbn2, @isbn3, @isbn4, @isbn5)
69 ms'den 5 ms'ye. Max değerlerdeki 216–316 ms, AppHost yeniden başladıktan sonraki ilk isteğin JIT (Just-In-Time; kodun ilk çalıştığında derlenmesi) ve bağlantı havuzu maliyeti. İki düzeltme farklı yerlerde: index Catalog'un kendi sorununu çözdü (kitap sayfası da hızlandı), toplu endpoint ise iki service arasındaki konuşma biçimini düzeltti. Waterfall ikisini de aynı ekranda gösterdi. Bir service'in diğerine döngü içinde çağrı yapması, Microservices serisinde anlattığımız "chatty" iletişimin tipik bir örneği.
Bu arada 200.000 kitapla liste endpoint'i (/api/books) her şeyi döndürmeye başlamıştı; sayfalamayı (?page=, sayfa başına 20 kitap) da bu adımda ekledik. Büyük veri, küçük veride görünmeyen hataları erkenden ortaya çıkarıyor.
Telemetry'yi CLI'dan Okumak
Dashboard'u açmadan da aynı veriye ulaşabiliyorsun. Yukarıdaki waterfall'ları aspire otel traces gateway -n 1 --format Json çıktısından ürettik. Log aramak için:
aspire otel logs ordering --search "placed" -n 3
19:02:16.117 INFO ordering Order 01a1dacb-8fb5-7e9a-aa46-71ec1bf387b2 placed with 5 items, total 1709.00 TRY
19:02:16.191 INFO ordering Order 01a1dacb-8fff-7234-8dda-dcdf64f68543 placed with 5 items, total 1709.00 TRY
19:02:16.264 INFO ordering Order 01a1dacb-9048-755e-8fb9-ab0bb3f33749 placed with 5 items, total 1709.00 TRY
aspire otel traces için --has-error ve --trace-id, aspire otel logs için --severity filtreleri var. Bunlar bir hatayı script'le yakalamak, bir CI job'unda testten sonra hata trace'i var mı diye bakmak ya da çıktıyı bir AI asistanına vermek için kullanışlı. 13.2 ile gelen export ise bütün oturumu tek dosyaya alıyor:
aspire export -o bookstore-export.zip
Gathering structured logs...
Gathering traces...
✅ Export saved to bookstore-export.zip
Zip'te 27 dosya vardı: her resource için resources/*.json, consolelogs/*.txt, structuredlogs/*.json ve traces/*.json. Dosyayı başka birinin dashboard'una import edip aynı trace'lere bakabiliyorsunuz; "bende yavaş" diyen bir ekip arkadaşının oturumunu görmek için iyi bir yol. Ama dikkat: resources/catalog.json içinde environment variable'lar da var ve ConnectionStrings__bookstoredb'nin değeri SQL Server parolasını açık olarak içeriyordu. Export dosyasını bir issue'ya eklemeden önce içine bak.
Telemetry'nin Kalıcılığı: 13.6'daki SQLite Deposu
İkinci soruya geçelim. 13.6'dan önce dashboard telemetry'yi yalnız bellekte tutuyordu; AppHost kapanınca her şey gidiyordu. 13.6 ile AppHost'un başlattığı dashboard'un varsayılan persistence modu Run: her çalıştırma kendi SQLite veritabanına yazılıyor ve tamamlanmış son 10 çalıştırma saklanıyor. Dashboard'un üst çubuğundaki "Live run" düğmesi bu listeyi açıyor; bizde o gün yaptığımız önceki dokuz çalıştırmanın başlama saatleri listelendi ve birini seçince o çalıştırmanın log ve trace'leri salt okunur olarak açılıyor. Diskte her çalıştırma ayrı bir klasör:
<ASPIRE_HOME>/dashboard/runs/
20261114T152311981Z/
dashboard.db
run.json
...
20261114T160057661Z/
20261114T160057661Z.lock
.lock dosyası o an yazılan çalıştırmayı işaretliyor. Mod ASPIRE_DASHBOARD_PERSISTENCE_MODE ile değiştiriliyor: None eski davranış (yalnız bellek), Resume aynı veritabanını çalıştırmalar arasında sürdürüyor. Yer, ASPIRE_DASHBOARD_DATA_DIRECTORY ile değişiyor. Limitler de 13.6'da yükseldi: log, trace ve console log için varsayılan üst sınır 100.000 kayıt; sınır dolunca en eski kayıtlar siliniyor. Güvenlik notu: SQLite dosyası ayrıca şifrelenmiyor ve log'larda ya da span attribute'larında ne varsa onu içeriyor. Aspire Unix'te dosyayı yalnız sahibinin okuyabileceği izinlerle açıyor ama Windows'ta ACL (Access Control List; dosya erişim izinleri listesi) ayarlamıyor; bu klasörü yedeklere ve paylaşılan disklere taşımamak gerekiyor.
Standalone Dashboard: Aspire Kullanmayan Bir Uygulama İçin
Dashboard'un kendisi AppHost'a bağlı değil. OTLP (OpenTelemetry Protocol; telemetry verisini taşıyan standart protokol) konuşan herhangi bir uygulama telemetry'sini ona gönderebilir. Lokal bir trace görüntüleyicisine ihtiyacın varsa ve projen Aspire kullanmıyorsa bu yol en kısası. CLI ile başlattık, OTLP için bir API key zorunlu tuttuk:
aspire dashboard run --application-name bookstore-standalone -- \
--Dashboard:Otlp:AuthMode=ApiKey --Dashboard:Otlp:PrimaryApiKey=<key>
Dashboard: http://localhost:18888/login?t=<token>
OTLP/gRPC: http://localhost:4317
OTLP/HTTP: http://localhost:4318
Arayüz bir browser token'ıyla korunuyor (AppHost'taki gibi), OTLP endpoint'leri ise API key istiyor. Ordering'i AppHost dışında iki kez çalıştırıp birer sipariş verdik. İlkinde yalnız OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 ve OTEL_SERVICE_NAME=ordering-standalone vardı, ikincisinde bir de OTEL_EXPORTER_OTLP_HEADERS=x-otlp-api-key=<key>. İki istek de 201 döndü, ama dashboard'da yalnız biri görünüyordu:
aspire otel traces ordering-standalone --dashboard-url "http://localhost:18888/login?t=<token>"
19:06:24.665 ordering-standalone: POST /api/orders/ 5e49808 3 spans
Showing 1 of 1 traces
Key'siz gelen telemetry reddedildi ve uygulama bunu fark etmedi: exporter hatası isteği etkilemiyor. Telemetry görünmüyorsa önce header'ı kontrol et. Trace'teki span sayısı da öğretici: 3. Catalog hâlâ AppHost altında çalışıyordu ve kendi span'larını öteki dashboard'a gönderiyordu. Trace context istekle birlikte Catalog'a gitti ama span'lar iki ayrı yerde toplandı; bir trace'in tamamını görmek için bütün service'lerin aynı yere göndermesi gerekiyor.
Standalone dashboard'un persistence varsayılanı None. Dashboard'u durdurup aynı komutla yeniden başlattık ve aynı sorgu Resource 'ordering-standalone' not found. döndürdü. Veriyi saklamak istiyorsan --persistence Resume ver. CLI kurmadan container ile de çalışıyor: mcr.microsoft.com/aspire/dashboard image'ı 18888 (arayüz), 18889 (OTLP/gRPC) ve 18890 (OTLP/HTTP) port'larını açıyor; login token'ı container log'una yazılıyor. Bu varyasyonu çalıştırmadık, dokümandan aktarıyoruz.
Dashboard Ne Değildir?
Production observability aracı değil. Aspire dokümanı da onu geliştirme ve kısa süreli teşhis için konumluyor. Sebepleri somut: tek bir makinede çalışıyor, veriyi en fazla 100.000 kayıt ve son 10 çalıştırma kadar tutuyor, alert kuramıyorsun, kullanıcı ve yetki modeli yok (browser token ya da OpenID Connect ile tek bir giriş), şifrelenmemiş bir dosyaya yazıyor. Production'da aynı OTLP verisi bir OpenTelemetry Collector üzerinden Prometheus, Grafana, Tempo, Loki gibi backend'lere ya da bir APM (Application Performance Monitoring; uygulama performansını izleyen ürün) servisine gidiyor; bunlar Observability serisinin konusu. Güzel tarafı şu: service kodu değişmiyor. ServiceDefaults'taki OpenTelemetry ayarı lokalde dashboard'a, production'da Collector'a gönderiyor; değişen yalnız OTEL_EXPORTER_OTLP_ENDPOINT.
Üretimde Dikkat
Lokalde her şey örnekleniyor: Aspire service'lere OTEL_TRACES_SAMPLER=always_on veriyor. Production'da her isteğin her span'ını saklamak pahalı; sampling kararını Collector ya da backend tarafında ver ve lokal ayarları production'a taşıma.
db.query.text attribute'u sorgunun metnini taşıyor. Parametreler @isbn gibi isimlerle kalıyor, değerleri span'a girmiyor; ama sorguyu string birleştirerek kuruyorsan değerler de metne girer. Telemetry'nin kişisel veri taşımadığını production'a çıkmadan kontrol et.
Custom metric'lerde etiket seçimine dikkat. bookstore.orders.placed'e müşteri e-postası ya da sipariş id'si gibi bir etiket eklemek her değer için ayrı bir zaman serisi demek; backend'in maliyeti ve sorgu süresi buna göre büyür. Yüksek kardinaliteli bilgi span ve log'a, düşük kardinaliteli bilgi (ödeme yöntemi, kanal) metric'e.
Sık Düşülen Tuzaklar
Yavaşlığı log'da aramak. Bu yazıdaki sorun hiçbir log'da görünmüyordu; her istek başarılıydı. Süre sorusunun cevabı trace'te, "ne kadar sık" sorusunun cevabı metric'te.
Custom source'u ya da meter'ı kaydetmeyi unutmak. new ActivitySource("Bookstore.Orders") gibi uygulama adından farklı bir isim seçersen, ServiceDefaults'a AddSource/AddMeter eklemedikçe span'lar ve metric'ler hiçbir yere gitmez ve hata da vermez.
Export dosyasını ve dashboard veritabanını zararsız sanmak. İkisi de environment variable'ları, connection string'leri ve log'lara ne yazdıysan onu içeriyor.
Standalone dashboard'da OTLP key'ini uygulamaya vermeyi unutmak. İstekler başarılı, dashboard boş; uygulama tarafında görünür bir hata yok.
Lokal ölçümleri mutlak sanmak. 69 ms ve 5 ms bizim makinemizin sayıları; önemli olan oran ve waterfall'un şekli. Sabit bir eşikle karşılaştırmak yerine aynı ortamda önce ve sonra ölç.
Küçük Bir Deneme
★☆☆ Structured logs sayfasında OrderId alanına göre filtre kur ve bir siparişin log satırından "View trace" ile o isteğin waterfall'una geç. Aynı log satırı Console sayfasında hangi biçimde görünüyor?
★★☆ Index'i SQL Server'da elle düşürüp (DROP INDEX IX_Books_Author ON Books) toplu endpoint'li sürümü ölç. İndex olmadan tek bir /lookup çağrısı ne kadar sürüyor? Neden?
★★★ ASPIRE_DASHBOARD_PERSISTENCE_MODE=Resume ile AppHost'u iki kez başlat ve ilk çalıştırmanın trace'lerinin ikincide de göründüğünü doğrula. Ardından dashboard.db'yi bir SQLite istemcisiyle açıp bir connection string'in bir yerde geçip geçmediğine bak.
Dashboard'u okumayı öğrendik; şimdiye kadar SQL Server ve Redis'i integration'ların içini açmadan kullandık. Bir sonraki yazıda hosting ve client integration'larına geçiyoruz: client integration'ın ne eklediği, SQL Server, Redis, RabbitMQ ve Keycloak örnekleri, kendi resource'unu yazmak ve dashboard'a bir komut eklemek. RabbitMQ eklenince bir mesajın consumer span'ını da bu waterfall'da göreceğiz; mesajlaşmada trace context'in nasıl taşındığı Message Queue'larla Çalışmak serisinin Mesajlaşmada Observability yazısında.