Mert Özen Her Satırda Daha İleri
DevOps Observability — #02

OpenTelemetry'nin Yapısı ve .NET'te İlk Kurulum: SDK, Instrumentation, OTLP, Resource ve Aspire Dashboard

Mert Özen 15 Kas 2026 10 dk 8 görüntülenme
OpenTelemetry'nin Yapısı ve .NET'te İlk Kurulum: SDK, Instrumentation, OTLP, Resource ve Aspire Dashboard

OpenTelemetry'yi .NET'e eklemek için AddOpenTelemetry ile resource, instrumentation ve UseOtlpExporter kurulur; ILogger, Meter ve ActivitySource zaten platformda olduğu için SDK yalnızca onları toplayıp OTLP ile gönderir. Bookstore.Api'yi 1.21 paketleriyle kuruyor, yavaş checkout'un trace'ini standalone Aspire Dashboard'da açıyor, koddaki AddService'in OTEL_SERVICE_NAME'i ezdiği tuzağı ve sessiz export hatalarını görüyoruz.

Sürüm notu: Bu yazıdaki örnekler .NET 10, OpenTelemetry .NET 1.21 (OpenTelemetry.Extensions.Hosting, OpenTelemetry.Exporter.OpenTelemetryProtocol ve AspNetCore, Http, SqlClient ve Runtime instrumentation'ları 1.21.0) ve Aspire Dashboard 13.6.5 container'ı ile denenmiştir.

Önce Tahmin Et

Bir önceki yazıda sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) checkout'unun bazen 8 saniye sürdüğünü gördük. Bu yazıda Bookstore.Api'ye OpenTelemetry ekliyoruz. Kodda servis adını bookstore-api olarak veriyorsun, deploy ederken de OTEL_SERVICE_NAME=bookstore-api-env environment variable'ını set ediyorsun. Telemetry backend'inde servis hangi adla görünür? Bir de şu: telemetry'nin gideceği endpoint yanlış yazılmışsa ve telemetry hiçbir yere gidemiyorsa uygulama bunu nasıl haber verir? İkisini de denedik; cevaplar yazının ortasında.

OpenTelemetry'nin Parçaları

OpenTelemetry tek bir library değil, birbirinden ayrılmış parçalardan oluşan bir standart. Spesifikasyon, sinyallerin (trace, metric, log) veri modelini ve davranışını tanımlıyor. API, uygulama ve library kodunun telemetry üretmek için çağırdığı arayüz; kimse dinlemiyorsa neredeyse hiç maliyeti yok. SDK, API'nin arkasındaki gerçek uygulama: örnekleme, toplama (aggregation), batch'leme ve dışarı gönderme burada. Instrumentation library'leri, kendi telemetry'sini üretmeyen ya da OpenTelemetry'nin beklediği biçimde üretmeyen framework'ler için köprü: ASP.NET Core, HttpClient, SqlClient gibi. Exporter'lar veriyi bir hedefe yolluyor; standart hedef OTLP (OpenTelemetry Protocol; telemetry verisini taşıyan standart protokol), gRPC üzerinden 4317, HTTP üzerinden 4318 portunda. Collector, OTLP'yi alıp işleyen ve backend'lere dağıtan ayrı bir process (beşinci yazının konusu). Semantic conventions ise isimlerin ortak sözlüğü: bir HTTP isteğinin süresi http.server.request.duration, route'u http.route, veritabanı sistemi db.system.name. Backend'ler ve dashboard'lar bu isimlere güvenerek çalışıyor.

.NET'in diğer dillerden farkı, API katmanının büyük kısmının platformun içinde olması. Log için ILogger, metric için System.Diagnostics.Metrics.Meter, trace için System.Diagnostics.ActivitySource ve Activity zaten .NET'te var; ASP.NET Core ve HttpClient bunlarla telemetry üretiyor. OpenTelemetry .NET SDK'sı bu platform API'lerini dinleyip veriyi OpenTelemetry modeline çeviriyor. Pratik sonucu şu: kodunda OpenTelemetry paketine bağımlı olmadan Meter ve ActivitySource kullanabilirsin; SDK'yı yalnızca uygulamanın başlangıcında, bir kere kurarsın.

Bookstore.Api'ye OpenTelemetry Eklemek

Paketleri merkezi paket yönetimiyle (Directory.Packages.props) ekledik:

<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.21.0" />
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.21.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.21.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.21.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.SqlClient" Version="1.21.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.21.0" />

Hepsi kararlı sürüm. Bu her zaman böyle değildi: SqlClient instrumentation'ı uzun süre beta kaldı ve ilk kararlı sürümü 1.15.0 ile Ocak 2026'da çıktı. Entity Framework Core instrumentation'ı ise hâlâ beta (1.21.0-beta.1); Entity Framework Core (EF Core) zaten SqlClient üzerinden konuştuğu için SQL Server'da SqlClient instrumentation'ı yeterli. Bir not daha: 11 Kasım 2026'da çıkan 1.21.0, paketlerden net8.0 ve net9.0 hedeflerini kaldırdı; .NET 10 ve 11'i hedefliyor. .NET 8 kullanan bir proje bu sürüme geçmeden önce changelog'a bakmalı.

Kurulumun tamamı tek bir extension method'da:

public static WebApplicationBuilder AddBookstoreObservability(this WebApplicationBuilder builder)
{
    string version = typeof(ObservabilityExtensions).Assembly
        .GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? "unknown";

    builder.Services.AddOpenTelemetry()
        // Gönderen kim: bu attribute'lar her span'a, metric noktasına ve log kaydına eklenir.
        .ConfigureResource(resource => resource
            .AddService(serviceName: "bookstore-api", serviceVersion: version)
            .AddAttributes([new("deployment.environment.name", builder.Environment.EnvironmentName.ToLowerInvariant())]))
        .WithTracing(tracing => tracing
            .AddAspNetCoreInstrumentation()
            .AddHttpClientInstrumentation()
            .AddSqlClientInstrumentation())
        .WithMetrics(metrics => metrics
            .AddAspNetCoreInstrumentation()
            .AddHttpClientInstrumentation()
            .AddRuntimeInstrumentation())
        .WithLogging()
        // Üç sinyal için tek OTLP exporter'ı; endpoint OTEL_EXPORTER_OTLP_ENDPOINT'ten gelir.
        .UseOtlpExporter();

    return builder;
}

Program.cs'te tek satır: builder.AddBookstoreObservability();. Birkaç ayrıntı kaynak koduna bakınca netleşiyor. Metric tarafında AddAspNetCoreInstrumentation(), .NET 8 ve sonrasında kendi ölçüm yapmıyor; ASP.NET Core'un built-in meter'larını (Microsoft.AspNetCore.Hosting, Microsoft.AspNetCore.Server.Kestrel, Microsoft.AspNetCore.Routing, Authentication, Authorization, Identity ve diğerleri) dinlemeye ekliyor; .NET 11'den eski runtime'larda 5xx cevaplara error.type yazan küçük bir middleware de kuruyor. AddRuntimeInstrumentation() da .NET 9 ve sonrasında runtime'ın kendi System.Runtime meter'ını kaydediyor. Trace tarafında ise instrumentation paketleri gerçekten iş yapıyor: span'lara semantic convention attribute'larını yazıyorlar. UseOtlpExporter() log, metric ve trace için tek bir OTLP exporter'ı kuruyor; aynı builder'da sinyal bazında AddOtlpExporter ile birlikte kullanılamıyor.

Resource: Telemetry'yi Kim Gönderiyor?

Resource, telemetry'yi üreten şeyi tarif eden attribute'lar: her span, her metric noktası ve her log kaydı aynı resource ile gidiyor. En önemlileri service.name (backend'lerde servisi ayıran anahtar), service.version ve deployment.environment.name. Sürüm için assembly'nin informational version'ını kullandık; .NET SDK'sı, proje bir git repo'sundaysa build sırasında commit'in hash'ini de buna ekliyor. Böylece "bu yavaşlık hangi deploy ile başladı" sorusu telemetry'den cevaplanabiliyor; sürümlemenin kendisi Versioning ve Release Management serisinin konusu. deployment.environment.name, eski deployment.environment attribute'unun güncel semantic convention'daki adı; eski adı kullanan dashboard ve sorgularla karşılaşabilirsin.

OpenTelemetry, resource'u environment variable'larla da ayarlamaya izin veriyor: OTEL_SERVICE_NAME ve virgülle ayrılmış OTEL_RESOURCE_ATTRIBUTES. Önce Tahmin Et'teki ilk soruyu bu kodla denedik ve sonuç beklediğimiz gibi değildi. OTEL_SERVICE_NAME=bookstore-api-env ve OTEL_RESOURCE_ATTRIBUTES=service.namespace=bookstore,deployment.environment.name=local ile çalışan uygulamanın resource'u şuydu:

service.name=bookstore-api
service.instance.id=428f78b5-ae79-4675-b0e1-ae89d7c3ad74
deployment.environment.name=development
service.namespace=bookstore
service.version=1.0.0+9430f6b1c6d43694f75c9007a1730186013e51ea
telemetry.sdk.language=dotnet
telemetry.sdk.name=opentelemetry
telemetry.sdk.version=1.21.0

Environment variable'dan gelen service.namespace resource'a eklendi, ama aynı anahtarlar için kod kazandı: servis adı bookstore-api, ortam development kaldı. Sebebi resource builder'ın sırası. SDK, environment variable detector'ını varsayılan olarak en başta çalıştırıyor; sonra kodda verdiğin değerler ekleniyor ve sonra eklenen, öncekini eziyor. Ortam değişkeniyle değiştirilebilmesini istediğin her şeyi kodda varsayılan olarak bırakıp detector'ı sona tekrar eklemek gerekiyor:

.ConfigureResource(resource => resource
    .AddService(serviceName: "bookstore-api", serviceVersion: version)
    .AddAttributes([new("deployment.environment.name", builder.Environment.EnvironmentName.ToLowerInvariant())])
    // Koddaki değerler varsayılan; en son okunan OTEL_SERVICE_NAME / OTEL_RESOURCE_ATTRIBUTES onları ezer.
    .AddEnvironmentVariableDetector())

Aynı environment variable'larla tekrar çalıştırınca servis bookstore-api-env adıyla, ortam da local olarak geldi. service.instance.id'yi biz vermedik: SDK her process için rastgele bir GUID (benzersiz kimlik değeri) üretiyor, aynı servisin iki kopyasını ayırmanın yolu bu.

Lokalde Görmek: Standalone Aspire Dashboard

Telemetry'yi görmek için bir backend lazım. Lokal geliştirmede en kısa yol Aspire Dashboard'un container'ı: OTLP alıcısı, trace waterfall'u, structured log ve metric ekranları tek container'da. Projenin Aspire kullanması gerekmiyor; .NET Aspire serisinin dashboard yazısı bu kullanımı CLI üzerinden anlattı, biz container'ı kullandık:

docker run -d --name bookstore-dashboard -p 18888:18888 -p 4317:18889 -p 4318:18890 -e Dashboard__Api__AuthMode=ApiKey -e Dashboard__Api__PrimaryApiKey=<api-key> mcr.microsoft.com/aspire/dashboard:13.6.5

docker logs bookstore-dashboard
      Login to the dashboard at http://localhost:18888/login?t=<token>
      OTLP server is unsecured. Untrusted apps can send telemetry to the dashboard. ...

Container içinde OTLP/gRPC 18889, OTLP/HTTP 18890 portunda; dışarıya standart 4317 ve 4318 olarak açtık, böylece uygulama tarafında OpenTelemetry'nin varsayılan endpoint'i çalışıyor. Arayüz log'daki token'la açılıyor. Dashboard__Api__* ayarları, dashboard'un telemetry API'sini bir API key ile açıyor; yazıdaki metin çıktılarını bu API'den aldık. Uygulamayı şu environment variable'larla başlattık (PowerShell sözdizimi):

$env:OTEL_EXPORTER_OTLP_ENDPOINT = "http://localhost:4317"
$env:OTEL_RESOURCE_ATTRIBUTES = "service.namespace=bookstore"
dotnet run --project src/Bookstore.Api

20 siparişlik trafikten sonra dashboard'un Traces sayfasında en yavaş POST /api/orders/ trace'ini açtık. Waterfall'u, dashboard'un telemetry API'sinden okuduğumuz span'larla metin olarak veriyoruz (sütunlar: başlangıç ms, süre ms):

      0.0    5140.3  POST /api/orders/
      0.6       1.3    SELECT [c].[Id] [c].[CreatedAtUtc] [c].[UpdatedAtUtc] [ordering].[Carts] [ordering].[CartItems]
      2.2       1.0    SELECT [catalog].[Books]
      3.4       0.8    SELECT [inventory].[StockItems]
      4.3       0.6    SELECT [inventory].[StockItems]
      5.0       0.7    SELECT [inventory].[StockItems]
      6.4       0.8    UPDATE
      7.3       0.7    UPDATE
      8.2       0.7    UPDATE
      9.0       0.5    SELECT [ordering].[Customers]
     10.1       0.5    BookstoreDb
     10.7       0.9    DELETE
     11.9       1.3    DELETE
     15.9    2003.9    POST ERROR
   2539.7    2001.5    POST ERROR
   5042.1      91.1    POST
   5133.6       6.4    UPDATE

Bir önceki yazının "süre nereye gitti?" sorusunun cevabı tek ekranda. SQL kısmının tamamı 13 ms. Ödeme sağlayıcısına giden ilk iki POST 2.000 ms'de kesilmiş ve hata ile işaretlenmiş, aralarında 500 ms'lik bekleme var, üçüncüsü 91 ms'de dönmüş. 5,1 saniyenin 5 saniyesi retry döngüsü. Hiç kod yazmadan bir şey daha görünüyor: üç kitaplık sepet için stok tablosuna üç ayrı SELECT ve üç ayrı UPDATE gidiyor. Şimdilik ucuz, ama 12 kitaplık kurumsal siparişte 24 sorgu demek; serinin ilerisinde bu deseni tekrar ele alacağız.

Span adları semantic convention'lardan geliyor. Sunucu span'ı {method} {route}, istemci span'ı yalnızca method (URL'nin tamamı yüksek cardinality'li olduğu için adda değil, url.full attribute'unda), SQL span'ı ise SqlClient instrumentation'ının sorgudan çıkardığı db.query.summary. BookstoreDb adlı span, özeti çıkarılamayan bir komut: db.query.text'e bakınca EF Core'un SAVE TRANSACTION [__EFSavePoint]; komutu olduğu görülüyor. Zaman aşımına uğrayan denemenin attribute'ları:

http.request.method=POST
server.address=localhost
server.port=5090
url.full=http://localhost:5090/v1/charges
error.type=System.Threading.Tasks.TaskCanceledException

Log'lar ve Metric'ler Aynı Resource ile

WithLogging() sayesinde ILogger kayıtları da OTLP ile gidiyor. Siparişin log kaydı dashboard'da şöyle duruyor (telemetry API'nin JSON'undan kısaltıldı):

{"severityText": "Information",
 "body": {"stringValue": "Order {OrderId} placed: {ItemCount} items, {Total} {Currency}, took {ElapsedMs} ms"},
 "attributes": [{"key": "OrderId", "value": {"stringValue": "408"}}, ... {"key": "ElapsedMs", "value": {"stringValue": "5139"}}],
 "traceId": "67906de9a63b40347b19898355db93e2", "spanId": "7b8d74d9fd18ed92"}

İki şeye dikkat. Kayıtta traceId ve spanId var; dashboard'da log satırından tek tıkla trace'e geçmeyi sağlayan alanlar bunlar. Ve body, formatlanmış mesaj değil mesaj template'i; parametreler ayrı attribute'lar. Bu, log backend'inde OrderId'ye göre filtrelemeyi kolaylaştırıyor. Formatlanmış metni de istersen WithLogging'in options'ında IncludeFormattedMessage var.

Metrics sayfası ise tek satır metric kodu yazmadan şu meter'ları listeledi: Microsoft.AspNetCore.Hosting (http.server.request.duration, http.server.active_requests), Microsoft.AspNetCore.Server.Kestrel (connection süreleri ve kuyrukları), Microsoft.AspNetCore.Routing, Microsoft.AspNetCore.MemoryPool, System.Net.Http (http.client.request.duration dahil), System.Net.NameResolution ve System.Runtime (GC (kullanılmayan belleği toplayan garbage collector), JIT (kodu çalışma anında derleyen derleyici), thread pool, exception sayıları). Bunların ne anlattığını ve kendi metric'lerimizi bir sonraki yazıda ele alıyoruz.

Telemetry Gidemediğinde Ne Olur?

Önce Tahmin Et'teki ikinci soru. Uygulamayı OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4999 ile, yani dinleyen kimsenin olmadığı bir porta göndererek başlattık ve bir istek attık:

curl -s -o /dev/null -w "%{http_code} %{time_total}\n" http://localhost:5080/api/categories
200 0.154831

İstek normal döndü ve uygulamanın konsolunda export hatasıyla ilgili tek satır yok. Bu bilinçli bir tasarım: telemetry'nin gidememesi uygulamayı yavaşlatmamalı ya da durdurmamalı. Exporter hataları SDK'nın kendi EventSource'una yazılıyor ve ancak OpenTelemetry'nin self-diagnostics özelliğini açarsan görünüyor. Uygulamanın çalıştığı klasöre bir OTEL_DIAGNOSTICS.json koyduk ve aynı denemeyi tekrarladık:

{"LogDirectory": ".", "FileSize": 1024, "LogLevel": "Warning"}

[2026-11-14T19:13:16.5938510Z][518806][------][Warning]     OpenTelemetry-Exporter-OpenTelemetryProtocol: Transient HTTP error occurred when communicating with http://localhost:4999/opentelemetry.proto.collector.logs.v1.LogsService/Export. Exception: System.Net.Http.HttpRequestException: Connection refused (localhost:4999)

SDK, klasörde otel-dotnet-<pid>-...log adlı bir dosya açtı ve hatayı oraya yazdı. Yani "dashboard boş" durumunda ilk bakılacak yer uygulamanın log'u değil; endpoint, port, protokol (4317 gRPC, 4318 HTTP) ve varsa API key.

Aspire Kullanmadan ServiceDefaults

Aspire'ın proje şablonundaki ServiceDefaults projesi, burada yazdığımız kurulumun neredeyse aynısını yapıyor: AddOpenTelemetry(), ASP.NET Core, HttpClient ve runtime instrumentation'ları, UseOtlpExporter(), bir de health check ve service discovery ayarları. Projenin AppHost'u olmasa da bu projeyi referans verip builder.AddServiceDefaults() çağırabilirsin; tek fark, OTLP endpoint'ini ve servis adını AppHost'un yerine senin environment variable'larla vermen. Bookstore.Api'de bunu yapmadık; kurulumun her satırını görmek için kendi extension method'umuzu yazdık. Kuracağın şey ServiceDefaults olsa da yukarıdaki resource sırası tuzağı aynen geçerli.

Üretimde Dikkat

Servis adını ve ortamı deploy sırasında verilebilir tut. Aynı image staging'de ve production'da çalışıyorsa ortamı koddan değil environment variable'dan almak gerekiyor; resource builder'da detector'ın sırasını kontrol et.

Lokalde her şey örnekleniyor. SDK'nın varsayılan sampler'ı parent'a uyan "her zaman kaydet" davranışı; production'da her isteğin her span'ını saklamak pahalı. Sampling kararını beşinci yazıda Collector'da ele alıyoruz.

Dashboard'un OTLP portu varsayılan olarak korumasız; log'daki uyarı bunu söylüyor. Lokal makinede sorun değil, ama paylaşılan bir ortamda dashboard'u açacaksan OTLP için de API key iste. Aspire Dashboard ayrıca production için tasarlanmadı: veriyi varsayılan olarak bellekte ve sınırlı sayıda tutuyor, alarm kuramıyorsun.

db.query.text sorgunun metnini taşıyor. EF Core parametreleri @request_CartId gibi isimlerle gönderdiği için değerler span'a girmiyor; sorguyu string birleştirerek kuran kod ise değerleri de metne koyar. Telemetry'nin kişisel veri taşımadığını production'a çıkmadan kontrol et.

Sık Düşülen Tuzaklar

Exporter'ı kurup instrumentation'ı unutmak. UseOtlpExporter() tek başına yalnızca birilerinin ürettiği veriyi gönderir; AddAspNetCoreInstrumentation() olmadan istek span'ı, AddSqlClientInstrumentation() olmadan SQL span'ları gelmez ve hiçbir yerde hata görmezsin.

Prerelease paketlerini fark etmeden production'a taşımak. Instrumentation paketlerinin bir kısmı (EF Core, gRPC istemcisi) hâlâ beta; sürüm numarasındaki -beta eki, attribute adlarının ve davranışın değişebileceği anlamına geliyor. Hangi paketin kararlı olduğunu NuGet'ten ve changelog'dan kontrol et.

Console exporter'ı production'da bırakmak. Denerken faydalı, ama her span'ı ve metric'i stdout'a yazmak hem pahalı hem de log toplayıcısında telemetry'nin ikinci bir kopyasını oluşturur.

Koddaki AddService'in environment variable'ı ezdiğini bilmemek. Yukarıda gördüğümüz gibi hata yok, uyarı yok; servis yalnızca beklediğin addan farklı bir adla görünüyor.

Dashboard boşken uygulamanın log'unda hata aramak. Export hataları sessiz; önce endpoint'i, portu ve protokolü kontrol et.

Küçük Bir Deneme

★☆☆ OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf ile endpoint'i http://localhost:4318 yap. Dashboard'a hâlâ veri geliyor mu? Endpoint'i 4317'de bırakıp yalnız protokolü değiştirince ne oluyor?

★★☆ WithLogging'in configureOptions parametresiyle IncludeFormattedMessage = true ver ve dashboard'daki log kaydının body'sinin nasıl değiştiğine bak. Bunun log backend'inin maliyetine etkisi ne olur?

★★★ Dashboard'u Dashboard__Otlp__AuthMode=ApiKey ve Dashboard__Otlp__PrimaryApiKey ile, OTLP için de API key isteyecek şekilde başlat. Uygulamayı önce OTEL_EXPORTER_OTLP_HEADERS=x-otlp-api-key=<key> olmadan, sonra onunla çalıştır. İlkinde self-diagnostics dosyasında hangi hata görünüyor?

Telemetry artık üretiliyor ve bir backend'e gidiyor, ama şimdilik hepsi framework'lerin hazır verdiği şeyler. Bir sonraki yazıda metric'lere odaklanıyoruz: Counter, Histogram, UpDownCounter ve Gauge ile Bookstore'un kendi iş metric'lerini yazmak, built-in metric'lerin ne anlattığı, metric'i unit test etmek ve cardinality bütçesinin aşıldığında SDK'nın ne yaptığı.