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

Production'da Observability: Azure Monitor ve Application Insights, Sampling, Maliyet, PII ve Kubernetes'te Otomatik Instrumentation

Mert Özen 15 Kas 2026 10 dk 17 görüntülenme
Production'da Observability: Azure Monitor ve Application Insights, Sampling, Maliyet, PII ve Kubernetes'te Otomatik Instrumentation

Production'da telemetry'nin maliyeti ve riski sampling, hacim ve kişisel veriyle yönetilir: tail sampling varken uygulamada head sampling yapmak yavaş trace'lerin çoğunu kaybettirir. Bookstore'da iki ayarı ölçüyor, sipariş başına span, log ve seri bütçesi çıkarıyor, PII'yi Loki'de denetliyor; Azure Monitor için yeni Microsoft OpenTelemetry Distro'yu, semantic convention geçişlerini ve Kubernetes Operator'ünü inceliyoruz.

Sürüm notu: Bu yazıdaki örnekler .NET 10, OpenTelemetry .NET 1.21 ve grafana/otel-lgtm 0.41.0 image'ı (Tempo 3.1.1, Loki 3.7.9, Prometheus 3.16.0, OpenTelemetry Collector contrib 0.165.0) ile denenmiştir. Microsoft.OpenTelemetry 1.2.0 ile yazdığımız Azure Monitor kodu derlendi ama Azure'a gönderilmedi; Kubernetes ve OpenTelemetry Operator kısmı çalıştırılmadı.

Önce Tahmin Et

Bookstore'un (sitedeki serilerin ortak örnek uygulaması, bir online kitapçı) Collector'ında tail sampling var: bütün hataları ve 1 saniyeden yavaş bütün trace'leri tutuyor, kalanın yüzde 10'unu. Trace maliyetini daha da düşürmek isteyen bir ekip arkadaşın uygulamaya da yüzde 10'luk bir head sampling eklemeyi öneriyor: OTEL_TRACES_SAMPLER=parentbased_traceidratio, OTEL_TRACES_SAMPLER_ARG=0.1. Aynı 300 siparişi iki ayarla da gönderdik; ikisinde de 2 saniyeden yavaş 70 civarında checkout vardı. Head sampling açıkken bu yavaş checkout'ların kaçının trace'i Tempo'da kalır? Ve Prometheus'taki checkout p95'i değişir mi?

Yönetilen Seçenek: Azure Monitor ve Application Insights

Serinin örnek stack'i açık kaynak ve kendi container'ımızda çalışıyor. Production'da birçok ekip bunu işletmek yerine yönetilen bir servis seçiyor; Azure'da bu Azure Monitor ve onun uygulama tarafı Application Insights. Microsoft bu tarafı OpenTelemetry'ye taşıdı ve şu an üç yol var. Yeni uygulamalar için Microsoft'un önerisi Microsoft OpenTelemetry Distro (Microsoft.OpenTelemetry paketi). Daha önce Azure Monitor OpenTelemetry Distro'yu (Azure.Monitor.OpenTelemetry.AspNetCore) kullananlar için bir şey yapmak gerekmiyor. Application Insights'ın klasik .NET SDK'sı ise 3.x sürümünde OpenTelemetry üzerine yeniden yazıldı; 2.x kullananların mevcut kodu en az değişiklikle taşıması için önerilen yol 3.x'e geçmek. Yeni distro, telemetry'yi aynı anda hem Azure Monitor'a hem kendi Collector'ına gönderebiliyor:

using Microsoft.OpenTelemetry;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

// Microsoft OpenTelemetry Distro: aynı anda Azure Monitor ve kendi OTLP Collector'ımız.
builder.UseMicrosoftOpenTelemetry(options =>
{
    options.Exporters = ExportTarget.AzureMonitor | ExportTarget.Otlp;
    // Connection string APPLICATIONINSIGHTS_CONNECTION_STRING'den (Key Vault / app settings) gelir, asla repo'dan değil.
    options.AzureMonitor.ConnectionString = builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"];
    // Varsayılan hız sınırı (saniyede 5 trace) yerine sabit yüzde 10.
    options.AzureMonitor.TracesPerSecond = null;
    options.AzureMonitor.SamplingRatio = 0.1F;
});

WebApplication app = builder.Build();
app.MapGet("/", () => "ok");
app.Run();

Bu kodu ayrı bir projede Microsoft.OpenTelemetry 1.2.0 ile derledik, ama bir Azure aboneliğine göndermedik. Dikkat edilecek iki nokta dokümandan: distro varsayılan olarak hız sınırlı sampling yapıyor (saniyede yaklaşık 5 trace), yani yüzde ile sampling istiyorsan TracesPerSecond'ı null yapman gerekiyor; ve yeni distro ile eski Azure Monitor distro'su aynı uygulamada birlikte başlatılmamalı. Bookstore'da Azure Monitor'u bu yazıda eklemedik; Collector'ın contrib dağıtımında bir Azure Monitor exporter'ı da var (azuremonitorexporter); uygulamayı değiştirmeden Collector'dan da gönderilebilir. Hangisi: uygulama ekibi Azure'un hazır ekranlarını (Application Map, Failures, Live Metrics) istiyorsa distro, telemetry'yi tek yerden yönetmek ve birden çok backend'e göndermek istiyorsan Collector.

Sampling: Head, Tail ve İkisi Birlikte

Head sampling kararı isteğin başında, SDK'da veriliyor: trace id'ye bakılıyor ve span'lar ya hiç oluşturulmuyor ya da hepsi oluşturuluyor. parentbased_ öneki, gelen isteğin traceparent'ında bir karar varsa ona uyulmasını sağlıyor; böylece bir trace'in yarısı bir serviste tutulup yarısı atılmıyor. Tail sampling ise Collector yazısında gördüğümüz gibi trace bittikten sonra, sonucuna bakarak karar veriyor. Önce Tahmin Et'teki iki koşunun sonuçları, aynı dakikalar için Collector'ın kendi metric'lerinden, Tempo'dan ve Prometheus'tan:

Ölçüm (aynı 300 sipariş)Yalnız tail samplingHead yüzde 10 + tail sampling
Collector'a gelen span10.602995
Tempo'ya giden span2.085188
Tempo'daki checkout trace'i926
2 sn'den yavaş checkout (trafik aracı)6878
Bunlardan Tempo'da trace'i olan684
Prometheus'taki exemplar2112
Prometheus'a göre verilen siparişyaklaşık 306yaklaşık 332

Önce Tahmin Et'in cevabı tabloda: head sampling açıkken 78 yavaş checkout'tan yalnızca 4'ünün trace'i kaldı. Tail sampling'in "yavaş olan her şeyi tut" kuralı, kendisine hiç gelmeyen trace'lere uygulanamıyor; head sampling yavaş ve hızlı istek ayırmadan yüzde 90'ını en baştan atıyor. Collector'a gelen span sayısı da beklendiği gibi yaklaşık yüzde 9'a düştü. İkinci sorunun cevabı hayır: metric'ler sampling'den etkilenmiyor, çünkü histogram'a her istek yazılıyor; iki koşuda da sipariş sayısı 300 civarında (artıştaki fark increase'in ekstrapolasyonu). Log'lar da etkilenmiyor: iki koşuda da Collector yaklaşık 2.100 log kaydı aldı. Exemplar'lar ise azaldı, çünkü TraceBased filtre yalnızca sampled span'ların içindeki ölçümleri kabul ediyor.

Pratik kural şu: tail sampling kullanıyorsan uygulamada head sampling yapma ya da onu yüzde 100'de bırak. Head sampling'in tek faydası uygulamadan Collector'a giden ağ trafiğini ve Collector'ın belleğini azaltmak; bunun bedeli hatalı ve yavaş trace'leri kaybetmek. Trafik çok yüksekse ve tail sampling'in belleği yetmiyorsa, önce Collector'ı ölçeklemeyi (load-balancing exporter ile trace id'ye göre dağıtmak) düşün.

Maliyet: Ne Kadar Telemetry Üretiyoruz?

Telemetry faturası hacimden geliyor: alınan veri (ingestion), saklama süresi ve metric'lerde seri sayısı. Yönetilen servislerin hepsi bu üçünden birine göre ücretlendiriyor; kendi işlettiğin stack'te de aynı üçü disk, bellek ve CPU olarak karşına çıkıyor. Bookstore'un birinci koşusundan (head sampling yok) sipariş başına çıkan sayılar:

SinyalSipariş başınaNot
Uygulamanın ürettiği spanyaklaşık 35Sepet oluşturma, kitap ekleme, sipariş, SQL; kurumsal siparişte daha fazla
Tail sampling sonrası saklanan spanyaklaşık 7Yavaş ve hatalı trace'ler tam, kalanın yüzde 10'u
Uygulamanın ürettiği log kaydı7HttpClient'ın Information log'ları dahil
Loki'ye giden log kaydı3Collector'daki filtre HttpClient log'larını atıyor
Prometheus'taki aktif serisiparişten bağımsız, 626En büyük pay HTTP histogram bucket'larında

Bu tablo bir bütçe aracı. Günde 100 bin siparişte uygulama 3,5 milyon span üretiyor; tail sampling sayesinde 700 bin tanesi saklanıyor. Log tarafında filtre hacmi yarıdan fazla azaltıyor. Metric'ler ise trafikten değil, label kombinasyonlarından büyüyor: 626 serinin 150'si http_server_request_duration_seconds_bucket (route × method × status code × bucket sayısı). Yeni bir route ya da yeni bir label, sipariş sayısından bağımsız olarak seri sayısını artırır; metric üretme yazısındaki cardinality bütçesi bu yüzden önemli. Maliyeti izlemek için Collector'ın kendi metric'leri (otelcol_receiver_accepted_spans_total, otelcol_exporter_sent_log_records_total) bir dashboard'a ve günlük hacim belli bir eşiği geçince ticket açan bir alarma konmalı; ingestion faturası bir anda yükselmesin. Log seviyelerinin production'da nasıl ayarlanacağı Logging serisinde, bulut tarafındaki maliyet alarmları Cloud Temelleri serisinde.

PII ve KVKK

Telemetry kişisel veri, yani PII (kişiyi tanımlayan veri) taşıyabilir: log'a yazılan bir e-posta adresi, URL path'ine giren bir T.C. kimlik numarası, bir exception mesajındaki müşteri adı, baggage'a konan bir kullanıcı id'si. KVKK (Kişisel Verilerin Korunması Kanunu) açısından telemetry backend'i de kişisel veri işlenen bir sistem olur: kim erişebiliyor, ne kadar süre tutuluyor, yurt dışına çıkıyor mu soruları onun için de geçerli. Hukuki değerlendirme bu yazının konusu değil; teknik tarafta ilke basit: veriyi en baştan hiç yazma, yazıldıysa Collector'da maskele, kalan riski düzenli olarak ölç. Bookstore'da Collector e-posta adresini hash'liyor; bunun gerçekten çalıştığını Loki'de üç sorguyla kontrol ettik (birinci koşunun 300 siparişi için; sonuçlar Loki API'sinin döndürdüğü hâliyle):

sum(count_over_time({service_name="bookstore-api"} |~ "@example\\.com" [105s]))
[]
sum(count_over_time({service_name="bookstore-api"} | CustomerEmail=~".*@.*" [105s]))
[]
sum(count_over_time({service_name="bookstore-api"} | CustomerEmail!="" [105s]))
[{"metric":{},"value":[1794689449,"300"]}]

İlk iki sorgu boş döndü. 300 sipariş onayı log'u CustomerEmail alanını taşıyor ama hiçbirinde @ yok; hepsi hash. Bu tür sorguları bir alarm kuralına çevirmek (Loki'nin ruler'ı LogQL ile alarm üretebiliyor) yeni bir log satırının maskelemeyi atlattığını ilk günden yakalıyor. Erişim tarafında, production telemetry'sine kimlerin bakabildiğini rollerle sınırla ve saklama süresini amaçla eşleştir: hata ayıklama için tutulan trace'in 90 gün saklanmasının bir gerekçesi yok.

Semantic Convention Geçişleri

OpenTelemetry'nin attribute adları zaman içinde değişti: HTTP için http.method → http.request.method, http.status_code → http.response.status_code; veritabanı için db.statement → db.query.text gibi. Geçiş dönemlerinde bazı dillerin instrumentation'ları OTEL_SEMCONV_STABILITY_OPT_IN environment variable'ını kullandı; http/dup değeri eski ve yeni adları birlikte yazdırıp dashboard'ları taşımaya zaman tanıyordu. .NET'te bu ayar artık yok: AspNetCore instrumentation'ı 1.6.0'da (2023) HTTP için, SqlClient instrumentation'ı 1.14.0'da (2026) veritabanı için bu desteği kaldırdı ve yalnızca stabil adları yazıyor. Pratikte bu, instrumentation paketlerini yükseltirken dashboard'ların ve alarmların kullandığı attribute ve metric adlarını kontrol etmen gerektiği anlamına geliyor. Trace backend'i yazısında gördüğümüz SQL span'larının db.query.summary ve db.system.name attribute'ları bu yeni adlarla geliyor; eski bir dashboard db.statement arıyorsa sessizce boş kalır.

Kubernetes'te Otomatik Instrumentation

Bu bölümü çalıştırmadık. Kubernetes'te OpenTelemetry Operator, kodu değiştirmeden instrumentation eklemenin bir yolu: bir Instrumentation nesnesi exporter'ı, propagator'ları ve sampler'ı tanımlıyor; pod'a eklenen bir annotation ile .NET'in otomatik instrumentation'ı (bir startup hook üzerinden) container'a enjekte ediliyor. Operator dokümanındaki örneğin Bookstore'a uyarlanmış hâli:

apiVersion: opentelemetry.io/v1alpha1
kind: Instrumentation
metadata:
  name: bookstore-instrumentation
spec:
  exporter:
    endpoint: http://otel-collector:4318
  propagators:
    - tracecontext
    - baggage
  sampler:
    type: parentbased_traceidratio
    argument: "1"
---
# Deployment'ın pod template'inde:
metadata:
  annotations:
    instrumentation.opentelemetry.io/inject-dotnet: "true"
    # Alpine (musl) tabanlı image'lar için:
    instrumentation.opentelemetry.io/otel-dotnet-auto-runtime: "linux-musl-x64"

.NET'in otomatik instrumentation'ı OTLP'yi (OpenTelemetry Protocol; telemetry taşıma protokolü) varsayılan olarak http/protobuf ile, yani 4318 portuna gönderiyor. Bu yol, kodunu değiştiremediğin uygulamalar için iyi bir başlangıç; Bookstore gibi kodunu yazdığın servislerde ise SDK'yı kodda kurmak daha fazla kontrol veriyor: kendi span'ların, kendi metric'lerin, kendi filtrelerin. Kubernetes'te Collector genellikle iki katmanlı çalışıyor: her node'da bir DaemonSet (node'daki pod'lardan OTLP alıyor, node ve pod metric'lerini topluyor) ve merkezi bir gateway (tail sampling, PII ve backend'e gönderim). Cluster'ın kendi durumu için kube-state-metrics (deployment, pod ve node nesnelerinin durumu) ve node exporter ya da Collector'ın kubeletstats receiver'ı yaygın seçimler. Kubernetes'in kendisi Kubernetes serisinin konusu.

Production'a Çıkmadan Önce: Kontrol Listesi

Yeni bir servis production'a çıkmadan önce telemetry'sinin hazır olduğunu şu listeyle kontrol ediyoruz; repo'da docs/telemetry-checklist.md olarak da duruyor:

  • Resource: service.name, service.version ve deployment.environment.name doğru ve her sinyalde aynı.
  • Trace: gelen ve giden HTTP, veritabanı ve message queue çağrıları span üretiyor; context propagation servisler arasında kopmuyor.
  • Metric: RED (istek hızı, hata oranı, süre) metric'leri var, iş metric'leri tanımlı, label'ların cardinality'si hesaplandı.
  • Log: seviyeler production için ayarlı, log kayıtları trace_id taşıyor, kişisel veri yok ya da maskeleniyor.
  • Sampling: tail sampling kuralları hataları ve yavaş istekleri tutuyor; uygulamada head sampling yok.
  • Dashboard: servisin RED dashboard'u Git'te ve provisioning ile yükleniyor.
  • Alarm: semptom alarmları, "telemetry yok" alarmı ve her birinin runbook'u hazır; bildirim kanalı test edildi.
  • Maliyet: günlük span, log ve seri sayısı tahmin edildi, saklama süreleri belirlendi.

Üretimde Dikkat

Telemetry'nin de bir SLO'su (Service Level Objective; hedeflenen güvenilirlik seviyesi) olmalı. Collector'ın düşürdüğü veri (otelcol_exporter_send_failed_spans_total gibi), kuyrukta bekleyen veri ve backend'in gecikmesi izlenmezse, bir olay anında dashboard'ların eksik veri gösterdiğini o an fark edersin.

Ortamları ayır ama aynı tut. Staging ve production aynı Collector config'ini ve aynı dashboard'ları kullanmalı; fark yalnızca deployment.environment.name ve sampling oranı gibi birkaç değer olmalı. Böylece staging'de görmediğin bir telemetry sorunu production'da sürpriz olmaz.

Sık Düşülen Tuzaklar

Head ve tail sampling'i birlikte kullanmak. Ölçtüğümüz gibi yavaş trace'lerin büyük kısmı kaybolur ve exemplar'lar azalır; tail sampling'in amacı boşa gider.

Trace backend'inden maliyet ya da sayı raporu çıkarmak. Sampling'li trace'ler bir örnek; sayı ve oran için metric'e bak.

Connection string'i ya da backend token'ını repo'ya koymak. Application Insights connection string'i ve OTLP endpoint'inin header'ları gizli değer; Key Vault ya da ortamın secret yönetimiyle verilmeli.

Yönetilen servisin varsayılan sampling'ini bilmemek. Microsoft OpenTelemetry Distro'nun saniyede 5 trace'lik varsayılanı, yüksek trafikli bir serviste trace'lerin çok küçük bir kısmını tutar; Application Map'teki sayılar sampling düzeltmesiyle hesaplanıyor ama tek tek trace aramalarında bunu hissedersin.

Küçük Bir Deneme

★☆☆ Collector'ın kendi metric'leriyle bir "telemetry hacmi" paneli hazırla: dakikada alınan span, log kaydı ve metric noktası. Trafik aracını çalıştırıp sipariş başına değerleri yukarıdaki tabloyla karşılaştır.

★★☆ Loki'de, bir log satırının ya da attribute'unun e-posta adresi içermesi durumunda çalışan bir alarm kuralı yaz (Loki ruler). OrderConfirmationSender'a maskelenmemiş bir alan ekleyip alarmın çaldığını göster.

★★★ Bookstore'u kind ile yerel bir Kubernetes cluster'ına deploy et, OpenTelemetry Operator'ü kur ve yukarıdaki Instrumentation nesnesiyle uygulamanın SDK'sını kapatıp otomatik instrumentation'ı aç. İki durumda Tempo'daki span ağacını karşılaştır: hangi span'lar kayboldu?

Production tarafı da tamamlandı. Serinin son yazısında farklı bir eksene bakıyoruz: .NET 8'den .NET 11'e observability API'lerinde gelenler; IMeterFactory, Gauge, yeni built-in metric'ler ve .NET 11'de ASP.NET Core'un HTTP span'ını instrumentation paketi olmadan kendisinin üretmesi.