Seq ile Structured Log'ları Okumak: Docker Kurulumu, Sorgular, Signal'lar ve Alert'ler
Seq'i Docker'da parola ve ayrı ingestion portuyla kuruyor, yalnız Ingest yetkili API key ile Serilog ve OTLP üzerinden log gönderiyoruz. Filtreler, group by ve percentile sorguları, aynı TraceId'deki log ve span'ler, signal, alert ve signal'a bağlı retention gerçek sonuçlarla; 8 hatanın neden 16 event olduğu ve OTLP'deki ERROR seviyesi tuzağı dahil.
Sürüm notu: Bu yazıdaki kodlar .NET 10, Seq 2026.1, Serilog.AspNetCore 10.0, Serilog.Sinks.Seq 9.1 ve OpenTelemetry .NET 1.20 ile derlenip denenmiştir.
Önce Tahmin Et
Bookstore API'sine (Application Programming Interface) 210 istek gönderiyoruz: 120 kitap sorgusu, 60 sipariş ve 30 arama. Siparişlerin 8'i ödeme gateway'i yüzünden 500 dönüyor. Seq'te "hangi endpoint en çok hata verdi?" diye soruyorsun ve /api/orders için 16 sayısını görüyorsun. 8 başarısız istekten 16 hata nasıl çıktı? İkinci soru: aynı sorguyu RequestPath yerine "hangi endpoint en yavaş?" diye kurduğunda neden üç değil yüzden fazla satır geliyor? Üçüncüsü: @Level = 'Error' filtresi, OpenTelemetry üzerinden gelen hata log'larında neden 0 sonuç veriyor?
Üç cevap da bu yazıda, gerçek sorgu sonuçlarıyla duruyor.
Seq'i Docker'da Ayağa Kaldırmak
Seq, Datalust'ın structured log'lar için yazdığı bir log sunucusu: event'leri property'leriyle birlikte saklıyor, üstünde filtre ve SQL (Structured Query Language) benzeri sorgu çalıştırmanı sağlıyor. Serinin önceki yazılarında Serilog'un Seq sink'ini kullandık; bu yazıda Seq'in kendisini okumayı öğreniyoruz. Geliştirme makinesinde en kolay yol Docker:
docker run --name lgwork-seq-05 -d \
-e ACCEPT_EULA=Y \
-e SEQ_FIRSTRUN_ADMINPASSWORD="$SEQ_ADMIN_PASSWORD" \
-v "$PWD/seqdata:/data" \
-p 17498:80 -p 17499:5341 \
datalust/seq:2026.1
ACCEPT_EULA=Y zorunlu; EULA (End User License Agreement) kabul edilmezse container başlamıyor. Container içinde iki HTTP (HyperText Transfer Protocol) portu var: 80 hem web arayüzünü hem de API'yi sunuyor, 5341 ise yalnız ingestion için. İkisini ayrı açmak, production'da log gönderen uygulamalara yalnız 5341'i, insanlara da yalnız arayüzü açabilmen anlamına geliyor. /data volume'u event'leri ve ayarları tutuyor; onu bağlamazsan container silindiğinde bütün log'lar da gider. Parolayı komut satırına değil bir ortam değişkenine koydum, böylece shell geçmişine düşmüyor. Container'ın kendi log'u:
[02:27:01 INF] Seq 2026.1.17526 running on OS Ubuntu 24.04.5 LTS
[02:27:01 INF] Seq listening on ["http://localhost/", "https://localhost/", "http://localhost:5341/", "https://localhost:45341/"]
[02:27:01 INF] Opening event store at /data/Stream/stream.flare
[02:27:01 INF] Opening metastore /data/Documents/metastore.flare
[02:27:01 INF] Initializing a new metastore
[02:27:01 INF] Enabling username/password authentication, and using the supplied default admin password
[02:27:02 INF] Creating default workspace for user-admin
[02:27:02 INF] Storage subsystem available
[02:27:02 INF] Ingestion enabled
Tanıdık geldi mi? Seq kendi log'unu da Serilog'un console formatıyla yazıyor. İlk girişte admin kullanıcısı parolayı değiştirmek zorunda; API ile giriş denediğimde aldığım cevap {"Error":"A password change is required.","MustChangePassword":true} oldu.
API Key ile Log Göndermek: Serilog.Sinks.Seq
Uygulamaların Seq'e admin parolasıyla değil, yalnız Ingest yetkisi olan bir API key ile log göndermesi gerekiyor. Key'i arayüzden (Settings › API Keys) ya da seqcli ile oluşturabilirsin. seqcli'yi de container olarak çalıştırdım:
docker run --rm -i datalust/seqcli:2026.1 apikey create \
-t Bookstore.Api --permissions=Ingest \
--property=Application=Bookstore.Api --minimum-level=Information \
-s http://host.docker.internal:17498 \
--connect-username=admin --connect-password-stdin
Buradaki iki parametre işe yarar ayrıntılar. --property, bu key ile gelen her event'e sunucu tarafında Application = 'Bookstore.Api' ekliyor; uygulama bunu kendisi göndermese bile hangi servisten geldiği belli oluyor. --minimum-level ise Information'ın altını sunucuda reddediyor; biri yanlışlıkla Debug'ı açarsa Seq'in diski şişmiyor. Komut çıktı olarak yalnız key'in kendisini yazıyor; onu ortam değişkenine koyup uygulamaya veriyorum (Seq__ApiKey).
Uygulama tarafı serinin "Serilog Derinlemesine" yazısındaki kurulumun aynısı, yanına bir Seq sink'i ekleniyor:
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
string seqUrl = builder.Configuration["Seq:ServerUrl"]!;
string? apiKey = builder.Configuration["Seq:ApiKey"];
builder.Services.AddSerilog((services, lc) => lc
.ReadFrom.Configuration(builder.Configuration)
.Enrich.FromLogContext()
.WriteTo.Console()
.WriteTo.Seq(seqUrl, apiKey: apiKey));
appsettings.json içinde Seq:ServerUrl ingestion portuna (http://localhost:17499) bakıyor, API key boş duruyor ve ortam değişkeninden geliyor. Request logging'e de bir property ekledim, nedenini sorgulara geçince göreceksin:
app.UseSerilogRequestLogging(options =>
{
// id'li path yerine route template: /api/books/{id:int}
options.EnrichDiagnosticContext = (diagnostics, httpContext) =>
diagnostics.Set("RoutePattern", (httpContext.GetEndpoint() as RouteEndpoint)?.RoutePattern.RawText);
});
Alternatif: OTLP ile Seq'e Göndermek
Seq, OTLP'yi (OpenTelemetry Protocol) de kabul ediyor. Serilog kullanmıyorsan, yalnız Microsoft.Extensions.Logging ve OpenTelemetry exporter'ı ile de log gönderebilirsin. Log'lar için endpoint /ingest/otlp/v1/logs, trace'ler için /ingest/otlp/v1/traces; API key X-Seq-ApiKey header'ında gidiyor:
builder.Logging.AddOpenTelemetry(logging =>
{
logging.IncludeFormattedMessage = true;
logging.AddOtlpExporter(otlp =>
{
otlp.Endpoint = new Uri($"{seqUrl}/ingest/otlp/v1/logs");
otlp.Protocol = OtlpExportProtocol.HttpProtobuf;
otlp.Headers = $"X-Seq-ApiKey={apiKey}";
});
});
builder.Services.AddOpenTelemetry()
.ConfigureResource(resource => resource.AddService("Bookstore.Api"))
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation()
.AddOtlpExporter(otlp =>
{
otlp.Endpoint = new Uri($"{seqUrl}/ingest/otlp/v1/traces");
otlp.Protocol = OtlpExportProtocol.HttpProtobuf;
otlp.Headers = $"X-Seq-ApiKey={apiKey}";
}));
Protokol olarak HttpProtobuf seçtim, çünkü Seq'in dokümanına göre gRPC (gRPC Remote Procedure Calls) ile ingestion yalnız HTTPS (HTTP Secure) üzerinden çalışıyor. Tracing kısmını iki modda da açık tuttum; trace görünümü için ona ihtiyacımız olacak. Bu iki yolun farkı sorgularda ortaya çıkıyor, onu tuzaklar kısmında göstereceğim.
Örnek trafik için küçük bir shell script'i API'ye 120 kitap isteği (20'si 404), 60 sipariş (6'sı stok yüzünden 409, 8'i ödeme gateway'i yüzünden 500) ve 30 arama isteği gönderiyor. Aşağıdaki bütün sonuçlar bu tek koşunun verisi üzerinde.
Filtrelerle Aramak
Seq arayüzündeki arama kutusu bir filtre ifadesi bekliyor. Property'ler adlarıyla, Seq'in kendi alanları @ önekiyle yazılıyor: @Level, @Message, @MessageTemplate, @Exception, @Timestamp, @TraceId. Aynı ifadeleri API'den de çalıştırabilirsin; sonuçları burada öyle aldım. Bu koşuda denediğim filtreler ve eşleşen event sayıları:
CustomerId = 41 -> 1 ("Order 01a1b8fe-9281-7db5-8ad1-ae61216ffec0 placed for customer 41: 1 x 978-0135957059")
CustomerId = 42 -> 0
RequestPath like '/api/books/%' and StatusCode = 404 -> 20
@Message like '%timed out%' -> 0
@Exception like '%timed out%' -> 16
@Exception like '%PaymentGatewayException%' -> 16
Bu küçük listede üç ders var. Birincisi, CustomerId = 41 structured logging'in getirisi: metin araması değil, property karşılaştırması. İkincisi, CustomerId = 42 hiçbir şey döndürmüyor, çünkü 42 numaralı müşterinin siparişi 500 aldı ve o isteğin hata log'larının hiçbirinde CustomerId yok. Serinin ilk yazısında "bağlamı exception'ı tekrar loglayarak değil, scope ya da trace id ile çöz" demiştik; işte o eksikliğin Seq'teki görüntüsü. Üçüncüsü, "timed out" metni @Message'da değil @Exception'da; exception handler'ın mesajı yalnız "An unhandled exception has occurred while executing the request." diyor.
Filtreyi kaydetmenin yolu signal; ona birazdan geleceğiz.
SQL Tarzı Sorgular: group by, count ve percentile
Seq'te filtrenin yanında select ... from stream ile başlayan sorgular da yazabiliyorsun. Plandaki klasik soru: "son 1 saatte en çok hata veren endpoint hangisi?"
select count(*) as Errors from stream
where @Level = 'Error' and @Timestamp > Now() - 1h
group by RequestPath order by Errors desc limit 5
{"Columns":["RequestPath","Errors"],"Rows":[["/api/orders",16]]}
İlk sorunun cevabı burada: 8 başarısız istek, 16 Error event'i. Her başarısız istek iki kez Error seviyesinde loglanıyor: biri exception handler'ın "An unhandled exception" satırı, diğeri UseSerilogRequestLogging'in 500 yanıtı için yazdığı özet satır. İkisi de hatalı değil, ama hata sayısı soruyorsan event'i değil isteği saymalısın:
select count(*) as Errors, count(distinct(RequestId)) as Requests from stream
where @Level = 'Error' group by RequestPath
{"Columns":["RequestPath","Errors","Requests"],"Rows":[["/api/orders",16,8]]}
RequestId, ASP.NET Core'un her isteğe açtığı scope'tan geliyor; Serilog bu scope'u property'ye çeviriyor. Alert kuracaksan bu farkı bilmek kritik; aksi hâlde eşiği gerçeğin iki katına göre ayarlarsın.
Şimdi ikinci soru. "Hangi endpoint ne kadar yavaş?" sorusunu RequestPath ile kurarsan:
select count(*) as Requests from stream where Has(StatusCode) group by RequestPath limit 4
{"Columns":["RequestPath","Requests"],"Rows":[["/api/books/69",1],["/api/books/67",1],["/api/books/74",1],["/api/books/56",1]]}
Limit koymasaydım her kitap id'si için ayrı bir satır gelecekti; sorgu 120 kitap isteğini 120 gruba bölüyor. RequestPath gerçek path, id dahil. Gruplama için gereken route template ve request logging'e eklediğim RoutePattern tam bunu taşıyor:
select count(*) as Requests, percentile(Elapsed, 95) as P95 from stream
where Has(StatusCode) group by RoutePattern order by P95 desc
{"Columns":["RoutePattern","Requests","P95"],"Rows":[["/api/search",30,907.122059579768],["/api/orders",60,0.10752581343173],["/api/books/{id:int}",120,0.03370655317554697]]}
Üç satır, okunabilir bir sonuç: arama endpoint'inin 95. yüzdelik süresi 907 milisaniye, diğerleri milisaniyenin altında. Aynı property ile durum kodlarının dağılımı:
select count(*) as Requests from stream where Has(StatusCode) group by RoutePattern, StatusCode
{"Columns":["RoutePattern","StatusCode","Requests"],"Rows":[["/api/search",200,30],["/api/books/{id:int}",404,20],["/api/orders",500,8],["/api/books/{id:int}",200,100],["/api/orders",409,6],["/api/orders",201,46]]}
Bu sorguları arayüzde çalıştırıp sonucu grafik olarak bir dashboard'a sabitleyebilirsin; group by time(1m) eklediğinde sonuç zaman serisine dönüyor. Yalnız şunu unutma: bu tür sayılar aslında metric. Seq'te log'dan sayı çıkarmak bir incident sırasında çok işe yarar, ama sürekli izlenen bir p95 süresi için doğru yer metric altyapısı; onu ilerideki DevOps › Observability serisine bırakıyoruz.
Trace Görünümü: Log ve Span'ı Aynı TraceId'de Görmek
Serilog 4, o anki Activity'nin trace ve span id'sini her event'e kendiliğinden ekliyor; Seq sink'i de bunları gönderiyor. Tracing'i OTLP ile Seq'e açtığım için aynı Seq'te span'ler de var. Bir 500'ün trace id'siyle sorgu:
select @Level, @SpanKind, @Message from stream where @TraceId = '4eec0e6c3a26ce56d864762d1a553571'
{"Columns":["@Level","@SpanKind","@Message"],"Rows":[["Error",null,"HTTP POST /api/orders responded 500 in 0.0547 ms"],["Error",null,"An unhandled exception has occurred while executing the request."],["ERROR","Server","POST /api/orders"]]}
İlk iki satır Serilog'dan gelen log event'leri, üçüncüsü ASP.NET Core instrumentation'ının ürettiği server span'i. Arayüzde bu event'lerden birini açtığında "Trace" menüsü çıkıyor ve isteğin span ağacını süreleriyle gösteriyor. Span'in property'leri OpenTelemetry semantic convention adlarını taşıyor (http.request.method, http.response.status_code, url.path); servis adı ve SDK (Software Development Kit) sürümü gibi resource attribute'ları ise @Resource altında duruyor. Trace id'nin servisler ve queue'lar arasında nasıl taşındığını bir sonraki yazıda, "Correlation ID ve Trace Context" yazısında kuracağız.
Signal, Alert ve Retention
Signal, kaydedilmiş bir filtre. Arayüzde bir filtre yazıp "Save as signal" diyebilirsin; aynı şeyi API'ye şu gövdeyle gönderdim:
{
"Title": "Bookstore.Api errors",
"Filters": [
{ "Filter": "Application = 'Bookstore.Api' and @Level = 'Error'" }
]
}
Signal'lar arayüzün sağ panelinde birer tık uzaklıkta duruyor ve birden fazlası seçilerek birleştirilebiliyor. Seq signal'ları indeksleyerek tuttuğu için sık kullanılan bir filtreyi signal yapmak sorguları da hızlandırıyor.
Alert, periyodik çalışan bir sorgu. "Son 5 dakikada bir route için 5'ten fazla 5xx" kuralı:
{
"Title": "Bookstore.Api 5xx spike",
"Where": "Application = 'Bookstore.Api' and StatusCode >= 500",
"GroupBy": [ { "Value": "RoutePattern" } ],
"TimeGrouping": "00:05:00",
"Select": [ { "Value": "count(*)", "Label": "Failures" } ],
"Having": "Failures > 5",
"NotificationLevel": "Error",
"SuppressionTime": "00:15:00"
}
Burada @Level = 'Error' yerine StatusCode >= 500 kullandım, yani her başarısız isteği bir kez sayan request logging event'lerini. Eşik 5, trafik koşusunda 8 tane 500 vardı. Alert'in durumu (/api/alertstate) bir dakika içinde şuna döndü:
"LastCheck": "2026-11-08T02:32:02.2262476Z",
"LastCheckTriggered": true,
"SuppressedUntil": "2026-11-08T02:52:02.2262476Z",
"RecentOccurrences": [
{
"DetectedAt": "2026-11-08T02:32:02.2262476Z",
"DetectedOverRange": {
"Start": "2026-11-08T02:26:32.2262476Z",
"End": "2026-11-08T02:31:32.2262476Z"
},
"NotificationLevel": "Error",
"Notifications": []
}
]
Notifications boş, çünkü alert'e bir bildirim kanalı bağlamadım. Gerçek kurulumda Seq'e e-posta, Slack ya da Teams app'i ekleyip alert'i ona yönlendiriyorsun. SuppressionTime'ı da atlama: o olmadan aynı sorun devam ettiği sürece her kontrolde yeni bildirim gelir ve ekip kanalı susturur.
Retention, diskin dolmasını önleyen kural. Seq'te retention politikası bir signal'a bağlanabiliyor: başarılı isteklerin özet satırlarını 7 gün sonra sil, hataları daha uzun tut. Önce Has(StatusCode) and StatusCode < 400 filtresiyle "Request completion events" signal'ını oluşturdum, sonra politikayı:
{ "RetentionTime": "7.00:00:00", "RemovedSignalExpression": { "SignalId": "signal-27", "Kind": "Signal" } }
Hacmin büyük kısmı başarılı istek satırları olduğu için bu tek kural diskin çoğunu kurtarıyor. Kişisel veri içeren log'ların ne kadar tutulacağı ise teknik değil hukuki bir karar; onu serinin hassas veri yazısında konuşacağız.
Üretimde Dikkat
Lisans. Yazım tarihinde (Kasım 2026) Datalust'ın fiyat sayfasına göre Individual lisans ücretsiz: tek kullanıcı, 50 GB depolama. Sayfa, Individual lisanslı bir kurulumun web arayüzüne birden fazla kişinin erişemeyeceğini açıkça yazıyor. Ekip için Team lisansı yılda 1.090 USD (en fazla 10 kullanıcı, 100 GB), Standard 2.990 USD, Datacenter 10.990 USD. Yani "herkes ortak admin hesabıyla girsin" bir çözüm değil, lisans ihlali. Fiyatlar değişebilir; satın almadan önce sayfayı kontrol et.
Seq ne zaman yetmez? Seq tek sunucuda çok iyi çalışan, kurulumu dakikalar süren bir araç. Günde yüzlerce GB log, çok bölgeli kurulum ya da log'u metric ve trace ile aynı yerde, uzun süre ve ucuz depolama ihtiyacı başladığında Elasticsearch ya da Grafana Loki gibi yatay ölçeklenen sistemlere bakman gerekir. Bu altyapıyı ilerideki DevOps › Observability serisinde kuracağız. Uygulama tarafında doğru yaptığın şey, yani structured event, sabit template ve OTLP, hedef değiştiğinde de aynı kalır; değişen sadece exporter'ın adresi olur.
Ağ ve güvenlik. Ingestion portunu (5341) yalnız uygulamaların bulunduğu ağa, arayüz portunu yalnız insanlara aç. API key'leri uygulama başına ayrı oluştur: bir key sızdığında yalnız onu iptal edersin, Application property'si de hangi servisin key'i olduğunu söyler.
Sık Düşülen Tuzaklar
Birincisi, OTLP'den gelen seviye adları. Uygulamayı Serilog yerine OTLP ile çalıştırıp aynı sorguları denediğimde:
select count(*) from stream where @Level = 'Error' -> 0
select count(*) from stream where @Level = 'Error' ci -> 2
select count(*) from stream group by @Level -> INFO 23, ERROR 2, OK 2
Serilog seviyeyi Error, OTLP ise ERROR olarak taşıyor ve Seq'te = büyük küçük harfe duyarlı. Üçüncü sorunun cevabı bu. ci (case-insensitive) ekini kullan ya da bir sistemde tek bir gönderim yolunda karar kıl. Span'lerin @Level'ı da OK ya da ERROR; seviye bazlı sayımlarda span'leri Has(@SpanKind) ile ayır.
İkincisi, OTLP yolunda filtrelerin farklı yerden okunması. Serilog:MinimumLevel:Override ayarı Microsoft.Extensions.Logging provider'larını etkilemiyor. OTLP modunda Logging:LogLevel bölümü olmadığı için her sipariş isteği Seq'e 5 ile 7 satır arasında log gönderdi ("Request starting", "Executing endpoint", "Setting HTTP status code 201." ...). Hangi yolu seçersen seç, filtreleri o yolun configuration'ına yaz.
Üçüncüsü, yüksek kardinaliteli property ile gruplamak. RequestPath, OrderId ya da kullanıcı e-postasıyla group by yapmak her değer için bir satır üretir. Gruplama için route template, durum kodu, event tipi gibi az sayıda değeri olan property'ler kullan.
Dördüncüsü, Seq'i log arşivi sanmak. Seq'in hedefi hızlı arama ve incident; yıllarca saklanması gereken audit kaydı için retention'ı sonsuz yapmak yerine o kayıtları ayrı ve değiştirilemez bir depoya yaz.
Küçük Bir Deneme
★☆☆ Seq'i bu yazıdaki komutla ayağa kaldır, serinin önceki yazılarından birinin uygulamasını Seq sink'ine bağla ve select count(*) from stream group by @Level sorgusunu çalıştır. Hangi seviyenin hacmin çoğunu oluşturduğuna bak.
★★☆ "Son 15 dakikada en yavaş 5 istek" sorgusunu RoutePattern, Elapsed ve @TraceId sütunlarıyla yaz. Sonuçtaki bir trace id'yi filtre olarak kullan ve o isteğin bütün event'lerini getir.
★★★ Ödeme hataları için iki alert kur: biri @Level = 'Error' üzerinden event sayan, diğeri StatusCode >= 500 üzerinden istek sayan. Aynı trafik altında hangisinin ne zaman tetiklendiğini /api/alertstate ile karşılaştır ve eşiği nasıl ayarlaman gerektiğini yaz.
Bu yazıda CustomerId = 42 sorgusunun boş döndüğünü, çünkü hata log'larının isteğin iş bağlamını taşımadığını gördük. Bir sonraki yazıda bu boşluğu kapatıyoruz: Activity, W3C (World Wide Web Consortium) traceparent ve correlation id ile bir isteğin bütün log'larını API'den dış servise ve queue'daki worker'a kadar aynı kimlikle bağlamak.