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

KQL ile Application Insights ve Log Analytics'i Sorgulamak: where, summarize, bin, join, percentiles ve Alert Sorguları

Mert Özen 15 Kas 2026 20 dk 15 görüntülenme
KQL ile Application Insights ve Log Analytics'i Sorgulamak: where, summarize, bin, join, percentiles ve Alert Sorguları

Application Insights'ı KQL ile sorgularken sayıları count() ile değil sum(itemCount) ile toplamak gerekir; yüzde 25 sampling'de 300 checkout 82 satıra iniyor ve 7 başarısız checkout'un hiçbiri görünmüyor. Bookstore'un telemetry'sini Azure hesabı olmadan Application Insights şemasında toplayıp where, summarize, percentile, bin, make-series, join, lookup, parse/extract ve alert sorgularını gerçek verilerle deniyoruz.

Sürüm notu: Bu yazıdaki örnekler .NET 10, OpenTelemetry .NET 1.21, Azure.Monitor.OpenTelemetry.Exporter 1.10 ve yerel sorgu motoru KustoLoco 1.5.1 (Microsoft'un Kusto.Language 12.4.1 parser'ı ile) ile denenmiştir. Azure hesabı kullanılmadı: telemetry, Application Insights'ın ingestion endpoint'ini taklit eden yerel bir araca gönderildi ve sorgular bu veri üzerinde yerelde çalıştırıldı. Log search alert kuralı ve workbook kısmı çalıştırılmadı.

Önce Tahmin Et

Production yazısında Bookstore'u (sitedeki serilerin ortak örnek uygulaması, bir online kitapçı) Azure Monitor'a bağlamanın yollarına bakmıştık. Bu ek yazıda o verinin nasıl sorgulanacağına geçiyoruz. Application Insights'ın ve Log Analytics'in sorgu dili KQL (Kusto Query Language; Azure Monitor'daki log ve telemetry verisini sorgulamak için kullanılan dil). Senaryo şu: Bookstore.Api telemetry'sini yüzde 25 sampling ile Application Insights'a gönderiyor. Trafik aracıyla 300 sipariş verdik; ödeme sağlayıcısı yavaş olduğu için bunlardan 7'si 502 ile bitti. Aşağıdaki sorgu kaç döndürür, ve success == "False" olan kaç checkout satırı görürsün?

requests
| where name == "POST /api/orders/"
| count

Yerel Laboratuvar: Bookstore Telemetry'si Application Insights Şemasında

KQL'i gerçek veriyle öğrenmek için Bookstore'un kendi telemetry'sine ihtiyacımız var, ama bu yazıda Azure aboneliği kullanmıyoruz. Çözüm üç parçalı. Birincisi, API'ye Application Insights exporter'ını ekliyoruz. Bookstore resource, instrumentation ve OTLP (OpenTelemetry Protocol; telemetry taşıma protokolü) exporter'ıyla kendi OpenTelemetry kurulumunu koruduğu için production yazısındaki distro yerine yalnızca Azure.Monitor.OpenTelemetry.Exporter paketinin UseAzureMonitorExporter() method'unu çağırıyoruz; aynı span, metric ve log hem Collector'a hem Application Insights'a gidiyor:

IOpenTelemetryBuilder otel = builder.Services.AddOpenTelemetry()
    // ... resource, tracing, metrics ve logging önceki yazılardaki gibi ...
    .UseOtlpExporter();

// Application Insights ikinci hedef ve yalnızca connection string verilmişse devreye girer
// (production'da App Service ayarı ya da Key Vault; repo'da asla).
string? appInsights = builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"];
if (!string.IsNullOrEmpty(appInsights))
{
    otel.UseAzureMonitorExporter(options =>
    {
        options.ConnectionString = appInsights;
        // Varsayılan "saniyede 5 trace" yerine sabit oran; 1.0 her trace'i tutar.
        options.TracesPerSecond = null;
        options.SamplingRatio = 1.0F;
        // Exporter, trace içinde yazılan log'u yalnızca trace-flags tam olarak "sampled" (01) ise tutuyor;
        // OpenTelemetry 1.21 W3C random flag'ini de set ediyor (03), yani bu log'ların hepsi düşerdi.
        options.EnableTraceBasedLogsSampler = false;
        // AzureMonitor:SamplingRatio, AzureMonitor:EnableLiveMetrics... configuration'dan değiştirilebilir.
        builder.Configuration.GetSection("AzureMonitor").Bind(options);
    });
}

İkincisi, connection string'deki IngestionEndpoint'i yerel bir adrese çeviriyoruz. Exporter telemetry'yi bu adresin /v2.1/track path'ine JSON olarak POST ediyor; repo'daki tools/Bookstore.KqlLab gelen her kaydı bir dosyaya yazıyor. Üçüncüsü, aynı araç bu kayıtları Application Insights tablolarına ve workspace karşılıklarına çevirip KustoLoco'ya yüklüyor: Microsoft'un Kusto parser'ını kullanan ve sorguyu .NET süreci içinde çalıştıran açık kaynak bir KQL motoru. Adımlar:

# 1. terminal: ingestion endpoint'inin taklidi, gelen kayıtları telemetry klasörüne yazar
dotnet run --project tools/Bookstore.KqlLab -- capture telemetry http://localhost:5096

# 2. terminal: yavaş ödeme sağlayıcısı (yüzde 35 ihtimalle 5 saniye)
dotnet run --project tools/Bookstore.FakePaymentProvider -- --Provider:SlowRatio=0.35 --Provider:SlowDelayMs=5000

# 3. terminal: API telemetry'yi yerel endpoint'e gönderir
$env:APPLICATIONINSIGHTS_CONNECTION_STRING = "InstrumentationKey=00000000-0000-0000-0000-000000000000;IngestionEndpoint=http://localhost:5096/"
$env:AzureMonitor__EnableLiveMetrics = "false"
$env:APPLICATIONINSIGHTS_STATSBEAT_DISABLED = "true"
$env:APPLICATIONINSIGHTS_SDKSTATS_DISABLED = "true"
dotnet run --project src/Bookstore.Api

# 4. terminal: 300 sipariş, sonra ilk sorgu
dotnet run --project tools/Bookstore.Traffic -- 300 4
dotnet run --project tools/Bookstore.KqlLab -- query deploy/kql/tables-overview.kql telemetry

Live Metrics ve SDK istatistikleri Microsoft'un endpoint'lerine bağlandığı için yerelde kapalı; koşu sırasında API'nin dışarıya bağlantı açmadığını kontrol ettik. Instrumentation key sıfırlardan oluşan bir yer tutucu. İki koşu yaptık, biri sampling'siz, biri $env:AzureMonitor__SamplingRatio = "0.25" ile; ikisinde de 300 sipariş ve var olmayan bir ISBN'e (kitabın uluslararası standart numarası) 20 istek. Aksi belirtilmedikçe çıktılar sampling'siz koşudan.

İlk denemede traces tablosuna yalnızca uygulamanın başlangıç log'ları düştü, sipariş log'larının hiçbiri gelmedi. Sebep, exporter'da varsayılan olarak açık olan trace tabanlı log sampler: trace içinde yazılan log'u yalnızca trace-flags tam olarak "sampled" (01) ise gönderiyor ve exporter'ın kaynağındaki LogFilteringProcessor bunu eşitlikle kontrol ediyor. OpenTelemetry .NET 1.21 ise W3C (web standartlarını yayımlayan kuruluş) Trace Context Level 2'deki random flag'i de set ediyor; ödeme sağlayıcısına giden traceparent 1.20'de 01 ile, 1.21'de 03 ile bitiyor. Sonuçta istek içindeki hiçbir log Application Insights'a gitmiyor. Koddaki EnableTraceBasedLogsSampler = false bu yüzden var; bedeli, sampling'de atılan trace'lerin log'larının da gönderilmesi.

Bu laboratuvar Log Analytics'in kendisi değil. Farkları baştan bilmek çıktıları doğru okumayı sağlar:

  • Tabloları exporter'ın gönderdiği kayıtlardan bizim çeviricimiz kuruyor. Ingestion servisinin eklediği kolonlar (client_*, performanceBucket, appId, problemId) yok; itemCount Microsoft'un anlattığı gibi 100 / sampleRate.
  • KustoLoco'da parse ve çok değerli percentiles() yok, make-series from olmadan çalışmıyor, mv-expand'in kind=array biçimi boş satırlar döndürüyor. Bütün sorguları ayrıca Microsoft'un Kusto.Language parser'ıyla Application Insights şemasına karşı analiz ettik; hata yok.
  • has farklı davranıyor (tuzaklar bölümünde); eşleşme olmayan grupta sumif boş dönüyor; render grafik çizmiyor.
  • app(), workspace(), _ResourceId, portalın zaman seçicisi, alert kuralı ve workbook yerelde yok.

KQL'in Modeli: Tablo, Pipe ve İki Ayrı Tablo Adı

Bir KQL sorgusu bir tabloyla başlar ve her | bir önceki adımın sonucunu bir sonraki operator'e verir. SQL'deki gibi tek bir SELECT cümlesi yok; veri yukarıdan aşağı akar ve her satır tek bir iş yapar: tabloyu al, filtrele, grupla, sırala. İlk sorgu beş tablonun içinde ne olduğunu gösteriyor:

// Beş tabloda ne var? Telemetry türü başına bir satır.
union requests, dependencies, traces, exceptions, customMetrics
| where timestamp > ago(1d)
| summarize rows = count(), first_seen = min(timestamp), last_seen = max(timestamp) by itemType
| order by rows desc
itemType      rows  first_seen                last_seen
------------  ----  ------------------------  ------------------------
dependency    9016  2026-11-14T21:15:25.148Z  2026-11-14T21:18:55.053Z
trace         2318  2026-11-14T21:15:25.058Z  2026-11-14T21:17:52.767Z
request       1842  2026-11-14T21:15:25.374Z  2026-11-14T21:17:52.865Z
exception     423   2026-11-14T21:15:29.839Z  2026-11-14T21:17:52.386Z
customMetric  261   2026-11-14T21:16:25.032Z  2026-11-14T21:18:25.030Z

Server span'ları requests'e, client ve internal span'lar dependencies'e (kendi açtığımız "check availability" gibi span'lar InProc tipiyle), ILogger kayıtları traces'e, exception taşıyan log'lar ve span exception event'leri exceptions'a, metric'ler customMetrics'e yazılıyor. Distributed tracing yazısındaki trace id burada operation_Id, parent span id operation_ParentId.

Tablo adları sorguyu nereden çalıştırdığına göre değişir. Application Insights kaynağının Logs ekranı klasik adları kabul eder. Workspace-based bir kaynakta veri aslında Log Analytics workspace'inde App* tablolarında durur ve workspace'in Logs ekranında bu adları kullanmak zorundasın. Microsoft Learn'deki eşleme tablosunun bizi ilgilendiren kısmı:

Application Insights (klasik)Log Analytics workspaceNot
requests, dependencies, tracesAppRequests, AppDependencies, AppTracesTablo adları
exceptions, customMetricsAppExceptions, AppMetricsAppMetrics'te value yok; Sum, Min, Max ve ItemCount var
timestampTimeGeneratedZaman filtresi bu kolona yazılır
durationDurationMsİkisi de milisaniye
success (string)Success (bool)Sorgu taşınırken karşılaştırma değişir
operation_Id, operation_ParentIdOperationId, ParentIdTrace id ve parent span id
customDimensionsPropertiesSpan ve log attribute'ları
cloud_RoleName, itemCountAppRoleName, ItemCountservice.name ve sampling ağırlığı

KQL büyük-küçük harf duyarlı olduğu için yalnızca tablo adlarını değil kolon adlarını da değiştirmen gerekir. Klasik tablolarda success bir string ("True"/"False"); Microsoft'un log-based metric sorguları da success == 'False' yazıyor. İlk gerçek sorgu, endpoint başına istek ve hata sayısı:

// Son bir günde endpoint başına istek; sampling'li veride doğru saymak için count() değil sum(itemCount).
requests
| where timestamp > ago(1d)
| summarize requests = sum(itemCount), failed = sumif(itemCount, success == "False") by name
| order by requests desc
name                                       requests  failed
-----------------------------------------  --------  ------
PUT /api/carts/{cartId:guid}/items/{isbn}  914
POST /api/carts/                           300
POST /api/orders/                          300       12
order-confirmations process                288
GET /api/books/{isbn}                      21        21
POST /api/inventory/receipts               18
GET /api/books                             1

where satırları filtreliyor, summarize grupluyor, order by sıralıyor. GET /api/books/{isbn}'in 21 isteğinin hepsi başarısız görünüyor: var olmayan ISBN için 404 döndü ve Application Insights 401 dışındaki 4xx'leri de failed sayar. "order-confirmations process" bir HTTP endpoint'i değil; onay e-postasını gönderen background consumer'ın span'ı, consumer span'ları da requests'e düşüyor.

summarize, percentile, bin ve make-series

Ortalama süre, yavaş isteklerin hikâyesini gizler. Metric üretme yazısında histogram'ı bu yüzden seçmiştik; KQL'de aynı soruyu ham istek kayıtları üzerinden percentile() ile soruyoruz:

// Endpoint başına süre: duration (milisaniye) için p50, p95 ve p99.
requests
| where timestamp > ago(1d)
| summarize requests = sum(itemCount), p50 = percentile(duration, 50), p95 = percentile(duration, 95),
    p99 = percentile(duration, 99) by name
| order by p95 desc
name                                       requests  p50     p95      p99
-----------------------------------------  --------  ------  -------  --------
POST /api/orders/                          300       116.93  8604.01  10031.52
order-confirmations process                288       39.97   57.29    59.37
GET /api/books/{isbn}                      21        1.06    38.05    82.53
GET /api/books                             1         18.43   18.43    18.43
POST /api/inventory/receipts               18        2.93    15.96    23
PUT /api/carts/{cartId:guid}/items/{isbn}  914       5.88    11.13    16.68
POST /api/carts/                           300       2.79    5.19     86.27

Checkout'un medyanı 117 ms, ama p95 8,6 saniye: yavaş ödeme sağlayıcısı uzun bir kuyruk oluşturuyor. Trafik aracının kendi ölçümü de p95 için 8589 ms diyordu. Log Analytics'te üç değeri tek seferde percentiles(duration, 50, 95, 99) ile alabilirsin; sonuç kolonları percentile_duration_95 gibi adlandırılır. Yerel motor bu hâli desteklemediği için tek tek yazdık.

Zaman ekseni için bin() kullanılır: bin(timestamp, 1m) her kaydı ait olduğu dakikanın başına yuvarlar ve summarize ... by ile birlikte dakika başına bir satır üretir:

// Dakika başına exception: hangi tip ve kim raporladı (bir log kategorisi ya da span üzerindeki exception event).
exceptions
| where timestamp > ago(1d)
| extend source = iff(isempty(tostring(customDimensions.CategoryName)), "(span event)", tostring(customDimensions.CategoryName))
| summarize exceptions = sum(itemCount) by bin(timestamp, 1m), type, source
| order by timestamp asc, exceptions desc
timestamp             type                                          source                           exceptions
--------------------  --------------------------------------------  -------------------------------  ----------
2026-11-14T21:15:00Z  Polly.Timeout.TimeoutRejectedException        Polly                            56
2026-11-14T21:15:00Z  System.Threading.Tasks.TaskCanceledException  (span event)                     34
2026-11-14T21:15:00Z  System.Threading.Tasks.TaskCanceledException  Polly                            6
2026-11-14T21:15:00Z  Polly.Timeout.TimeoutRejectedException        Bookstore.Ordering.OrderService  6
2026-11-14T21:15:00Z  Polly.Timeout.TimeoutRejectedException        (span event)                     6
2026-11-14T21:16:00Z  Polly.Timeout.TimeoutRejectedException        Polly                            104
2026-11-14T21:16:00Z  System.Threading.Tasks.TaskCanceledException  (span event)                     54
2026-11-14T21:16:00Z  Polly.Timeout.TimeoutRejectedException        (span event)                     2
2026-11-14T21:16:00Z  System.Threading.Tasks.TaskCanceledException  Polly                            2
2026-11-14T21:16:00Z  Polly.Timeout.TimeoutRejectedException        Bookstore.Ordering.OrderService  2
2026-11-14T21:17:00Z  Polly.Timeout.TimeoutRejectedException        Polly                            90
2026-11-14T21:17:00Z  System.Threading.Tasks.TaskCanceledException  (span event)                     49
2026-11-14T21:17:00Z  System.Threading.Tasks.TaskCanceledException  Polly                            4
2026-11-14T21:17:00Z  Polly.Timeout.TimeoutRejectedException        Bookstore.Ordering.OrderService  4
2026-11-14T21:17:00Z  Polly.Timeout.TimeoutRejectedException        (span event)                     4

12 başarısız checkout için exceptions'ta 423 kayıt var: 137 zaman aşımı, HttpClient span'larındaki 137 exception event'i ve Polly'nin 262 log kaydı olarak yazılıyor; kalan 24 kayıt OrderService'in kendi span'ı ve log'u. Exception sayısı hata sayısı değil, hatanın kaç yerde raporlandığı; hata oranını requests'ten ya da metric'ten hesapla. Grafik için make-series daha uygun: boş dakikaları default değeriyle doldurur ve her grup için bir dizi döndürür:

// Aynı trend bir seri olarak: tip başına bir dizi, boş dakikalar 0, grafiğe hazır.
let start = bin(ago(1h), 1m);
exceptions
| where timestamp > start
| make-series exceptions = sum(itemCount) default = 0 on timestamp from start to now() step 1m by type
| project type, exceptions
| render timechart
type                                          exceptions
--------------------------------------------  -------------------------------------------------------------------------------------------------------------------------------
Polly.Timeout.TimeoutRejectedException        [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,68,108,98,0,0,0,0,0,0,0,0,0,0,0]
System.Threading.Tasks.TaskCanceledException  [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,40,56,53,0,0,0,0,0,0,0,0,0,0,0]
render: timechart

Portalda render timechart bu dizileri çizer. summarize ... by bin() veri olmayan dakikaları hiç döndürmediği için grafikte çizgi boşluğun üzerinden birleşir; make-series onları 0 ile doldurur.

Şimdi Önce Tahmin Et'in cevabı. Aynı sorguyu iki koşuda çalıştırdık:

// Saklanan satır ve temsil ettiği istek: count() satırı sayar, sum(itemCount) gerçek sayıyı tahmin eder.
requests
| where timestamp > ago(1d) and name == "POST /api/orders/"
| summarize rows = count(), estimated = sum(itemCount), p95 = round(percentile(duration, 95), 0),
    failed_rows = countif(success == "False"), failed_estimated = sum(iff(success == "False", itemCount, 0))
# AzureMonitor:SamplingRatio = 1.0 (sampling yok)
rows  estimated  p95   failed_rows  failed_estimated
----  ---------  ----  -----------  ----------------
300   300        8604  12           12

# AzureMonitor:SamplingRatio = 0.25
rows  estimated  p95   failed_rows  failed_estimated
----  ---------  ----  -----------  ----------------
82    328        8414  0            0

Yüzde 25 sampling'de 300 checkout'tan 82 satır kaldı; count() 82 der. Application Insights sampling'i trace bazında yapar ve saklanan her kayda kaç kaydı temsil ettiğini itemCount ile yazar; yüzde 25'te bu değer 4. sum(itemCount) bu yüzden 328 diyor, gerçek sayı 300. Daha önemlisi, 7 başarısız checkout'un hiçbiri örneklemde yok: hata oranı sorgusu yüzde 0 döndürür. Aynı koşuda metric tarafı ise tam:

// Checkout metric'i sampling'den etkilenmez: her checkout histogram'ın count'unda.
customMetrics
| where timestamp > ago(1d) and name == "bookstore.checkout.duration"
| summarize checkouts = sum(valueCount) by outcome = tostring(customDimensions["bookstore.checkout.outcome"])
outcome         checkouts
--------------  ---------
placed          293
payment_failed  7

Metric'ler SDK'da önceden toplanıyor ve sampling'den etkilenmiyor. Sayı ve oran sorularını metric'e, tek tek isteğin hikâyesini trace ve log'a sor; production yazısında Tempo için söylediğimiz kural burada da geçerli.

join, lookup ve Bir Checkout'un Uçtan Uca Hikâyesi

Tablolar operation_Id üzerinden birleşir. Trace backend yazısında ödeme çağrısına attempt timeout ve retry eklemiştik; her checkout'un kaç deneme gerektirdiğini ve müşteriye ne kadar beklettiğini bir join ile görebiliriz:

// Her checkout kaç ödeme denemesi gerektirdi ve bu müşteriye kaça mal oldu?
requests
| where timestamp > ago(1d) and name == "POST /api/orders/"
| project operation_Id, order_ms = duration, resultCode
| join kind=inner (
    dependencies
    | where timestamp > ago(1d) and type == "Http" and name == "POST /v1/charges"
    | summarize attempts = count(), timeouts = countif(success == "False") by operation_Id
  ) on operation_Id
| summarize orders = count(), failed_orders = countif(resultCode == "502"), p50_ms = round(percentile(order_ms, 50), 0),
    max_ms = round(max(order_ms), 0) by attempts
| order by attempts asc
attempts  orders  failed_orders  p50_ms  max_ms
--------  ------  -------------  ------  ------
1         205     0              98      223
2         65      0              4286    4374
3         30      12             8604    10046

Checkout'ların yüzde 68'i tek denemede ve 100 ms civarında bitiyor. İkinci deneme gerekince süre 4,3 saniyeye çıkıyor; üç deneme gerekenlerin 12'si 10 saniyelik toplam timeout'a takılıp başarısız oluyor. kind=inner'ı bilerek yazdık: KQL'de join'in varsayılanı innerunique, sol taraftaki tekrar eden anahtarları önce teke indirir ve SQL alışkanlığıyla yazılan sorgularda satır kaybettirir.

Sağ taraf küçük bir sözlük tablosuysa lookup daha uygun. Microsoft'un önerisi de büyük bir tabloyu küçük bir boyut tablosuyla zenginleştirirken join yerine lookup kullanmak:

// lookup: sonucu küçük bir boyut tablosuyla zenginleştir (hangi endpoint hangi module'ün).
let owners = datatable(name: string, module: string) [
    "POST /api/orders/", "Ordering",
    "POST /api/carts/", "Ordering",
    "PUT /api/carts/{cartId:guid}/items/{isbn}", "Ordering",
    "GET /api/books", "Catalog",
    "GET /api/books/{isbn}", "Catalog",
    "POST /api/inventory/receipts", "Inventory",
    "order-confirmations process", "Notifications"
];
requests
| where timestamp > ago(1d)
| summarize requests = sum(itemCount), p95 = round(percentile(duration, 95), 0) by name
| lookup kind=leftouter owners on name
| summarize requests = sum(requests), worst_p95 = max(p95) by module
| order by requests desc
module         requests  worst_p95
-------------  --------  ---------
Ordering       1514      8604
Notifications  288       57
Catalog        22        38
Inventory      18        16

let bir ifadeye ad verir; datatable sorgunun içinde küçük bir tablo tanımlar. Aynı yöntemle tek bir isteğin bütün kayıtlarını sırayla dizebiliriz. Debugging ve Problem Çözme serisinde bir trace id ile olayların zaman çizelgesini çıkarmıştık; KQL'deki karşılığı toscalar ile seçilen bir operation_Id ve dört tablonun union'ı:

// Tek bir operation'ın hikâyesi: en yavaş checkout adım adım (SQL çağrıları ve HttpClient'ın kendi log'ları hariç).
let slowest = toscalar(
    requests
    | where timestamp > ago(1d) and name == "POST /api/orders/"
    | top 1 by duration desc
    | project operation_Id);
union requests, dependencies, traces, exceptions
| where timestamp > ago(1d) and operation_Id == slowest
| where not(itemType == "dependency" and type == "SQL")
| where not(tostring(customDimensions.CategoryName) startswith "System.Net.Http")
| extend what = substring(case(itemType == "trace", message, itemType == "exception", type, name), 0, 48)
| project timestamp, itemType, what, duration = round(duration, 0), success
| order by timestamp asc
timestamp                 itemType    what                                              duration  success
------------------------  ----------  ------------------------------------------------  --------  -------
2026-11-14T21:15:34.349Z  request     POST /api/orders/                                 10046     False
2026-11-14T21:15:34.352Z  dependency  check availability                                1         True
2026-11-14T21:15:34.354Z  dependency  reserve stock                                     3         True
2026-11-14T21:15:34.363Z  dependency  authorize payment                                 10030     False
2026-11-14T21:15:34.363Z  dependency  POST /v1/charges                                  4002      False
2026-11-14T21:15:38.365Z  exception   System.Threading.Tasks.TaskCanceledException
2026-11-14T21:15:38.365Z  trace       Resilience event occurred. EventName: 'OnTimeout
2026-11-14T21:15:38.366Z  exception   Polly.Timeout.TimeoutRejectedException
2026-11-14T21:15:38.367Z  exception   Polly.Timeout.TimeoutRejectedException
2026-11-14T21:15:38.547Z  dependency  POST /v1/charges                                  4002      False
2026-11-14T21:15:42.549Z  exception   System.Threading.Tasks.TaskCanceledException
2026-11-14T21:15:42.550Z  trace       Resilience event occurred. EventName: 'OnTimeout
2026-11-14T21:15:42.551Z  exception   Polly.Timeout.TimeoutRejectedException
2026-11-14T21:15:42.552Z  exception   Polly.Timeout.TimeoutRejectedException
2026-11-14T21:15:42.655Z  dependency  POST /v1/charges                                  1709      False
2026-11-14T21:15:44.364Z  exception   System.Threading.Tasks.TaskCanceledException
2026-11-14T21:15:44.365Z  exception   System.Threading.Tasks.TaskCanceledException
2026-11-14T21:15:44.365Z  trace       Resilience event occurred. EventName: 'OnTimeout
2026-11-14T21:15:44.366Z  exception   Polly.Timeout.TimeoutRejectedException
2026-11-14T21:15:44.367Z  exception   Polly.Timeout.TimeoutRejectedException
2026-11-14T21:15:44.393Z  trace       Order not placed: PaymentFailed, took 10044 ms

İlk iki deneme 4 saniyede zaman aşımına uğruyor, üçüncüsü 10 saniyelik toplam bütçenin kalan 1,7 saniyesinde kesiliyor ve sipariş PaymentFailed ile bitiyor. Her zaman aşımının yanında birden fazla exception kaydı var; 423 rakamı bu tekrarlardan geliyor.

Log Metninden Alan Çıkarmak: mv-expand, parse ve extract

Log backend'leri yazısında structured log'un değerini görmüştük: ILogger'ın message template'indeki her değer bir attribute olarak taşınıyor. Application Insights'ta bunlar customDimensions'ta duruyor. Bir kaydın hangi alanları taşıdığını mv-expand ile görebilirsin; property bag'i tek anahtarlı bag'lere açar:

// Önce structured alanlar: "order placed" log kaydı hangi property'leri taşıyor?
traces
| where timestamp > ago(1d) and message matches regex @"^Order \d+ placed"
| take 1
| mv-expand field = customDimensions
| extend key = tostring(bag_keys(field)[0])
| project key, field
key           field
------------  --------------------------------------------------
OrderId       {"OrderId":"19"}
ItemCount     {"ItemCount":"2"}
Total         {"Total":"608.90"}
Currency      {"Currency":"TRY"}
ElapsedMs     {"ElapsedMs":"200"}
CategoryName  {"CategoryName":"Bookstore.Ordering.OrderService"}

Bu alanlar varken metni ayrıştırmaya gerek yok: toint(customDimensions.ElapsedMs) doğrudan sayı verir. Yalnızca metni olan log'lar için aynı formatta tekrar eden metinde parse, formatı değişende extract kullanılır. Yerel motorda parse olmadığı için bu sorguyu yalnızca Microsoft'un parser'ıyla doğruladık:

// parse: yalnızca metni olan log'lar için (eski kod, üçüncü parti library'ler).
traces
| where timestamp > ago(1d) and message matches regex @"^Order \d+ placed"
| parse message with "Order " order_id:long " placed: " items:int " items, " total:real " " currency:string ", took " elapsed_ms:long " ms"
| extend order_type = iff(items >= 10, "institutional", "retail")
| summarize orders = count(), avg_total = round(avg(total), 2), p95_ms = percentile(elapsed_ms, 95) by order_type, currency

Aynı işi extract ile yerelde çalıştırdık:

// Aynısı extract ile (regular expression): her değer için bir capture group.
traces
| where timestamp > ago(1d) and message matches regex @"^Order \d+ placed"
| extend items = toint(extract(@"placed: (\d+) items", 1, message)),
    total = todouble(extract(@"items, ([\d.]+) ", 1, message)),
    currency = extract(@"items, [\d.]+ ([A-Z]{3})", 1, message),
    elapsed_ms = tolong(extract(@"took (\d+) ms", 1, message))
| extend order_type = iff(items >= 10, "institutional", "retail")
| summarize orders = count(), avg_total = round(avg(total), 2), p95_ms = percentile(elapsed_ms, 95) by order_type, currency
order_type     currency  orders  avg_total  p95_ms
-------------  --------  ------  ---------  ------
retail         TRY       258     656.88     8386
institutional  TRY       30      3854.64    8483

Metin işleme kişisel veriyi de bulmak için kullanılır. Production yazısında Loki'de e-posta adresi aramıştık; KQL karşılığı:

// Production yazısındaki PII kontrolünün KQL hâli: log mesajlarında e-posta adresi.
traces
| where timestamp > ago(1d)
| extend email = extract(@"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}", 0, message)
| where isnotempty(email)
| summarize records = count(), distinct_addresses = dcount(email) by category = tostring(customDimensions.CategoryName)
category                                         records  distinct_addresses
-----------------------------------------------  -------  ------------------
Bookstore.Notifications.OrderConfirmationSender  288      50

Production yazısında Collector e-posta adresini hash'liyordu, ama UseAzureMonitorExporter() uygulamanın içinden doğrudan gönderdiği için bu yol Collector'dan geçmiyor: onay log'unun 288 kaydında 50 farklı adres var. Ya adresi log'a hiç yazma ya da Azure'a da Collector üzerinden gönder; maskelemenin yeri tek olmalı.

customMetrics ve PromQL Karşılıkları

Metric üretme yazısındaki bookstore.checkout.duration histogram'ı customMetrics'te bucket'larıyla değil, her export aralığı için toplam, sayı, en küçük ve en büyük değerle duruyor:

// customMetrics önceden toplanmış noktalar tutar: export aralığı başına sum, count, min, max; bucket yok, yani p95 yok.
customMetrics
| where timestamp > ago(1d) and name == "bookstore.checkout.duration"
| extend outcome = tostring(customDimensions["bookstore.checkout.outcome"]), order_type = tostring(customDimensions["bookstore.order.type"])
| summarize checkouts = sum(valueCount), avg_s = round(sum(valueSum) / sum(valueCount), 3), max_s = round(max(valueMax), 3) by outcome, order_type
| order by checkouts desc
outcome         order_type     checkouts  avg_s  max_s
--------------  -------------  ---------  -----  -----
placed          retail         258        1.54   8.66
placed          institutional  30         1.78   8.62
payment_failed  retail         12         10.03  10.04

Ortalama ve en büyük değer buradan çıkar, ama p95 çıkmaz: bucket olmadığı için histogram_quantile'ın bir karşılığı yok. Percentile için ya requests'e (sampling'li ham kayıtlar) ya da Prometheus'a bakarsın. Prometheus yazısındaki sorgularla kabaca eşleşme:

SoruPromQLKQL (Application Insights)
Saniyede isteksum(rate(http_server_request_duration_seconds_count[5m]))requests | summarize sum(itemCount) / 300.0 by bin(timestamp, 5m)
Endpoint kırılımısum by (http_route) (...)summarize ... by name
Servis filtresi{job="bookstore-api"}where cloud_RoleName == "bookstore-api"
Hata oranırate(..._count{http_response_status_code=~"5.."}[5m]) / rate(..._count[5m])sum(iff(toint(resultCode) >= 500, itemCount, 0)) * 100.0 / sum(itemCount)
p95histogram_quantile(0.95, sum by (le) (rate(..._bucket[5m])))percentile(duration, 95), ham kayıtlardan
Sayaç artışıincrease(bookstore_orders_placed_total[1h])customMetrics | where name == "bookstore.orders.placed" | summarize sum(valueSum)

Son satır 288 döndürdü, başarılı sipariş sayısıyla aynı. Temel fark: PromQL zaman serileri üzerinde çalışır, KQL olay kayıtları üzerinde; pencereyi bin() ile sen kurarsın.

Alert Sorguları, Workbook'lar ve Sorgu Maliyeti

Log search alert, bir KQL sorgusunu belli aralıklarla çalıştırıp sonucu bir eşikle karşılaştırır. Alerting yazısındaki ilke aynı: nedene değil, kullanıcının gördüğü semptoma alarm kur. Checkout hata oranı için sorgu:

// Log search alert: kuralın verdiği pencerede checkout hata oranı (5xx); error_rate > 2 olunca alarm.
requests
| where name == "POST /api/orders/"
| summarize total = sum(itemCount), failed = sum(iff(toint(resultCode) >= 500, itemCount, 0))
| extend error_rate = round(100.0 * failed / total, 2)
total  failed  error_rate
-----  ------  ----------
300    12      4

Pencereyi kural veriyor: Measure error_rate, Aggregation type Maximum, Aggregation granularity 15 dakika, eşik 2, Frequency of evaluation 5 dakika. Dokümana göre frekans 1 dakika ile 1 gün arasında olabilir ve alert sorgusunda ago() yalnızca sabit bir timespan alır. Bizim verimizde oran yüzde 4; kural çalardı. SRE (Site Reliability Engineering; güvenilirliği mühendislik yöntemleriyle yönetme) ve Production Incident'ları serisindeki burn-rate alarmı da aynı kalıpla iki pencerede kurulur. Yüzde 25 sampling'de bu sorgu 0 döndürürdü; seyrek hatalar için sampling'den etkilenmeyen standard metric alarmı (Failed requests) daha güvenilir.

Workbook'lar aynı sorguları parametrelerle (zaman aralığı, cloud_RoleName) bir rapor sayfasında birleştirir. Grafana yazısındaki dashboard'lar gibi workbook JSON'unu da Git'te tut. Bu kısmı Azure olmadan çalıştıramadık.

Maliyet ve hız, sorgunun ne kadar veri taradığına bağlı. Microsoft'un best practices sayfasındaki sıra: önce datetime kolonu üzerinde filtre (Kusto çoğu zaman ilgisiz veri parçalarını hiç okumaz), sonra term seviyesindeki string filtreleri (has), en son tarama gerektirenler (contains). Hacimli bir tabloyu (örneğin AppTraces) Analytics'ten Basic ya da Auxiliary plan'a almak ingestion'ı ucuzlatır, ama sorgular ayrıca faturalanır ve Auxiliary'de alert çalışmaz.

Üretimde Dikkat

Sorguları kod gibi yönet. deploy/kql altındaki dosyalar alert kurallarının, workbook'ların ve runbook'ların kaynağı; bir sorgu değişince review'dan geçsin. Alerting yazısındaki runbook'lara, hangi KQL sorgusuyla incelemeye başlanacağını yaz.

Paket yükseltmelerinden sonra tabloları kontrol et. Bu yazıda gördüğümüz gibi OpenTelemetry'de küçük bir sürüm değişikliği exporter'ın log filtresiyle birleşince bütün uygulama log'larını sessizce kesebiliyor. traces | summarize count() by bin(timestamp, 1h) gibi bir sorguyla "log gelmiyor" alarmı kurmak, alerting yazısındaki "telemetry yok" alarmının Azure karşılığı.

Sık Düşülen Tuzaklar

Zaman filtresiz sorgu yazmak. Portalda zaman seçicisi seni korur, ama aynı sorgu API'den, bir workbook'tan ya da bir script'ten çalışınca bütün saklama süresini tarar. Her sorguya where timestamp > ago(...) ile başla.

contains ile has'ı aynı sanmak. has bütün bir term arar: Kusto bir string'i alfanümerik karakter dizilerine böler ve en az 3 karakterli term'leri indeksler. "grace1@example.com"un term'leri grace1, example ve com; has "grace" false, contains "grace" true döner. has indeksi kullandığı için hızlıdır, contains kolonu tarar. Yerel motorumuz burada Kusto'dan ayrılıyor:

// has bütün bir term arar; contains metnin herhangi bir yerinde substring arar.
traces
| where timestamp > ago(1d)
| summarize has_grace = countif(message has "grace"), contains_grace = countif(message contains "grace"),
    has_grace1 = countif(message has "grace1"), has_example = countif(message has "example")
has_grace  contains_grace  has_grace1  has_example
---------  --------------  ----------  -----------
288        288             63          288

Kusto'nun term tanımına göre beklenen sonuç sırasıyla 0, 288, 6 ve 288; KustoLoco has'ı term'in başıyla eşleştirdiği için 288 ve 63 buldu. has davranışını yerel motorla değil, dokümanla ve gerçek workspace ile doğrula.

Sampling'li veride count() kullanmak. Satır sayısı istek sayısı değil; sum(itemCount) kullan, seyrek olayları (hata, timeout) zaten metric'ten say.

Küçük Bir Deneme

★☆☆ failed-dependencies.kql'i Log Analytics workspace'inin tablo ve kolon adlarıyla (AppDependencies, DependencyType, Success, ItemCount) yeniden yaz ve laboratuvarda iki sürümün aynı sonucu verdiğini göster.

★★☆ exceptions'tan "gerçek hata" sayısını çıkar: operation_Id başına tek satıra indir ve yalnızca requests'te başarısız olan operation'ları say. Sonucun 12 olduğunu doğrula.

★★★ Checkout için iki pencereli bir burn-rate sorgusu yaz (1 saat ve 5 dakika, yüzde 99,5 hedef) ve iki hata oranını tek satırda hesapla. Aynı sorguyu yüzde 25 sampling'li veride çalıştırıp customMetrics tabanlı bir sürümle karşılaştır.

Bu ek yazıyla seri, açık kaynak stack'te PromQL, TraceQL ve LogQL ile sorduğumuz soruların Azure tarafındaki karşılığını da tamamlamış oldu. Hangi backend'i seçersen seç soru aynı kalıyor: kullanıcı neyi yaşadı ve bunu hangi sinyal en doğru gösteriyor?