Mert Özen Her Satırda Daha İleri
Backend Background Job'lar ve Scheduled Job'lar — #10

Job Dashboard, Monitoring ve Alerting: Hangfire Dashboard, Quartz 4 Dashboard ve OpenTelemetry

Mert Özen 8 Kas 2026 10 dk 3 görüntülenme
Job Dashboard, Monitoring ve Alerting: Hangfire Dashboard, Quartz 4 Dashboard ve OpenTelemetry

Hangfire Dashboard varsayılan olarak yalnız local isteklere açık, ama aynı makinedeki bir proxy'den gelen istek de local sayılıyor: Docker üzerinden gelen istek 200 aldı. Production'da dashboard'u bir authorization policy'ye ve read-only moda bağla; Quartz 4 dashboard'u yetkilendirme olmadan hiç başlamıyor. "Son başarılı çalışma 26 saatten eski" health check'i, Quartz 4'ün OpenTelemetry span ve metric'leri ve JobId taşıyan structured log'lar görünürlüğü tamamlar.

Sürüm notu: Bu yazıdaki kodlar .NET 10, Hangfire 1.8.25, Quartz.NET 4.7.2 (Quartz.Dashboard ve Quartz.AspNetCore dahil) ve OpenTelemetry 1.20.1 ile derlenip denenmiştir.

Önce Tahmin Et

Bookstore'un API'sine (Application Programming Interface) Hangfire Dashboard eklendi: app.UseHangfireDashboard(), başka hiçbir ayar yok. Dokümantasyon "varsayılan olarak yalnız local isteklere açık" diyor; ekip de bu yüzden rahat. Uygulama, aynı makinedeki bir reverse proxy'nin arkasında çalışıyor. İnternetten gelen bir istek /hangfire'a ulaşırsa 401 mi alır, 200 mü? Bir de: Quartz 4'ün dashboard'unu yetkilendirme ayarı yapmadan map edersen ne olur?

Hangfire Dashboard: Varsayılan Yalnız Local

builder.Services.AddHangfire(c => c
    .SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
    .UseSimpleAssemblyNameTypeSerializer()
    .UseRecommendedSerializerSettings()
    .UseSqlServerStorage(builder.Configuration.GetConnectionString("Hangfire")));
builder.Services.AddHangfireServer();

var app = builder.Build();
app.UseHangfireDashboard(); // varsayılan: /hangfire, yalnız local request
app.MapGet("/whoami", (HttpContext c) => $"remote={c.Connection.RemoteIpAddress} local={c.Connection.LocalIpAddress}");

Hangfire'ın dokümantasyonuna göre dashboard varsayılan olarak yalnız local isteklere izin veriyor. "Local", isteğin uzak IP (Internet Protocol) adresinin loopback ya da sunucunun kendi adresiyle aynı olması demek. Kendi makinemden ve bir Docker container'ından istek attım:

$ curl -s -o /dev/null -w "from 127.0.0.1: %{http_code}\n" http://127.0.0.1:17110/hangfire
from 127.0.0.1: 200

$ docker run --rm busybox:latest sh -c "wget -S -O /dev/null http://host.docker.internal:17110/hangfire 2>&1 | head -3"
Connecting to host.docker.internal:17110 (192.168.65.254:17110)
  HTTP/1.1 200 OK

$ docker run --rm busybox:latest sh -c "wget -q -O - http://host.docker.internal:17110/whoami"
remote=127.0.0.1 local=127.0.0.1

Container başka bir ağdaydı, ama isteği 200 aldı. /whoami sebebini gösteriyor: Docker'ın port yönlendirmesi bağlantıyı host'ta 127.0.0.1'den açıyor, uygulama da isteği local görüyor. Aynı makinedeki bir nginx, bir sidecar ya da bir SSH (Secure Shell) tüneli için de durum aynı: uygulamaya gelen bağlantının ucu local, isteğin gerçek kaynağı ise internetin herhangi bir yeri. "Yalnız local" bir güvenlik sınırı değil, geliştirme ortamı için makul bir varsayılan. Production'da ona güvenme.

Production'da Yetkilendirme: Policy ve Read-Only

Hangfire 1.8, dashboard'u ASP.NET Core'un authorization policy'lerine bağlayan bir endpoint mapping sunuyor. Dashboard'u /ops/jobs'ta, Ops ve Support rollerine açıp yalnız Ops'a değişiklik yetkisi verdim:

builder.Services.AddAuthentication().AddBearerToken();
builder.Services.AddAuthorizationBuilder()
    .AddPolicy("JobsViewer", p => p.RequireRole("Ops", "Support"));

var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();

app.MapHangfireDashboardWithAuthorizationPolicy("JobsViewer", "/ops/jobs", new DashboardOptions
{
    // Support rolü görür ama retry/delete/trigger yapamaz
    IsReadOnlyFunc = ctx => !ctx.GetHttpContext().User.IsInRole("Ops")
});

Token'ları demo amaçlı aynı uygulamanın içindeki bir endpoint üretiyor; gerçek bir uygulamada kimlik doğrulama (authentication) şirketin kimlik sağlayıcısından gelir. Dört farklı istek:

no token:        GET  /ops/jobs -> 401
Customer token:  GET  /ops/jobs -> 403
Support token:   GET  /ops/jobs -> 200
Support token:   POST /ops/jobs/jobs/succeeded/requeue -> 401
Ops token:       POST /ops/jobs/jobs/succeeded/requeue -> 204

Token'sız istek 401, yanlış rol 403 aldı. Support dashboard'u görebiliyor ama bir job'u yeniden kuyruğa yazmaya çalışınca 401 aldı; Ops için aynı istek 204 döndü. Read-only mod küçük bir ayrıntı gibi görünüyor ama önemli: dashboard'dan "Delete" ya da "Requeue" butonuna basmak, production verisi üzerinde bir işlem. Hata ayıklamak için dashboard'a bakan herkesin bu yetkiye sahip olması gerekmiyor.

Policy yerine kendi IDashboardAuthorizationFilter implementasyonunu da yazabilirsin; DashboardContext.GetHttpContext() ile HttpContext'e ulaşıp kullanıcıyı kontrol edersin. Endpoint mapping'in avantajı, uygulamanın geri kalanında kullandığın aynı authentication ve policy altyapısını kullanması. Web Güvenliği (OWASP Top 10) serisinin, IDOR (Insecure Direct Object Reference) açıklarını da işleyen "OWASP A01: Broken Access Control (1) — IDOR, Admin Endpoint'leri ve ASP.NET Core'da Yetkilendirme" yazısı, admin endpoint'lerini korumanın genel kurallarını anlatıyor; job dashboard'u da tam olarak bir admin endpoint'i. (OWASP: Open Worldwide Application Security Project.)

Quartz 4 Dashboard ve HTTP API: Yetkisiz Başlamaz

Quartz 4.0 ile bir Blazor dashboard'u ve bir HTTP (Hypertext Transfer Protocol) API'si geldi. İkisini de yetkilendirme ayarı yapmadan map etmeyi denedim:

builder.Services.AddQuartz();
builder.Services.AddQuartzDashboard();
var app = builder.Build();
app.MapQuartzDashboard();
app.Run();
fail: Microsoft.Extensions.Hosting.Internal.Host[11]
      Hosting failed to start
      System.InvalidOperationException: The Quartz dashboard is mapped with no authorization: 16 endpoints answer anonymously, and they can schedule, pause and shut down every scheduler in this process - including a job whose type is named by a string the request carries. Quartz refuses to start rather than serve that by accident. Say which you meant:
        - app.MapQuartzDashboard().RequireAuthorization() authorizes its pages and its live-events hub;
        - services.AddQuartzDashboard(options => options.AuthorizationPolicy = "...") authorizes those and the static assets and the Blazor circuit with them;
        - app.MapQuartzDashboard().AllowAnonymous() serves it to anyone, deliberately.

Uygulama hiç başlamadı. Hata mesajı gerekçeyi de söylüyor: dashboard ve HTTP API, scheduler'ları durdurabilir ve bir job'u adıyla, yani isteğin taşıdığı bir string'le schedule edebilir. Quartz'ın README'si bu kombinasyonun, Quartz.Jobs paketi uygulamada varsa bir process çalıştırabilen NativeJob'a kadar ulaşabileceğini yazıyor. Hangfire'ın "local ise izin ver" varsayılanının tersine, Quartz 4 "bir şey söylemediysen başlamam" diyor ve üç açık seçenek sunuyor: RequireAuthorization(), bir authorization policy ya da bilinçli bir AllowAnonymous(). Bu iyi bir tasarım; güvenli olmayan varsayılan yerine bir karar zorunluluğu.

Health Check: Dead Man's Switch

Dashboard'lar başarısız job'ları gösterir. Ama en tehlikeli job, hata vermeyen ama hiç çalışmayan job'dur: recurring tanımı silinmiş, cron ifadesi yanlış time zone'da, cluster'da lider seçilemiyor, worker'ın queue listesinde o queue yok. Bunu yakalamak için job'a "çalıştım" dedirtmek ve bu sinyalin gelmemesini alarm saymak gerekiyor; dead man's switch denen yaklaşım bu. Her job başarıyla bittiğinde bir zaman damgası bırakıyor, bir health check de bu damgaların yaşını kontrol ediyor:

// her job başarıyla bittiğinde zaman damgası bırakır (üretimde bir tabloda tutulur)
public sealed class JobHeartbeatStore(TimeProvider time)
{
    private readonly ConcurrentDictionary<string, DateTimeOffset> _lastSuccess = new();
    public void RecordSuccess(string job) => _lastSuccess[job] = time.GetUtcNow();
    public void Seed(string job, DateTimeOffset at) => _lastSuccess[job] = at;
    public DateTimeOffset? LastSuccess(string job) => _lastSuccess.TryGetValue(job, out var at) ? at : null;
}

// "dead man's switch": job hata vermese bile hiç çalışmadıysa alarm
public sealed class JobFreshnessHealthCheck(JobHeartbeatStore store, TimeProvider time, string job, TimeSpan maxAge)
    : IHealthCheck
{
    public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken ct = default)
    {
        DateTimeOffset? last = store.LastSuccess(job);
        if (last is null)
            return Task.FromResult(HealthCheckResult.Unhealthy($"{job} has never succeeded"));

        TimeSpan age = time.GetUtcNow() - last.Value;
        var data = new Dictionary<string, object> { ["lastSuccessUtc"] = last.Value.ToString("O"), ["ageHours"] = Math.Round(age.TotalHours, 1) };
        return Task.FromResult(age <= maxAge
            ? HealthCheckResult.Healthy($"{job} succeeded {age.TotalMinutes:F0} min ago", data)
            : HealthCheckResult.Unhealthy($"{job} last succeeded {age.TotalHours:F1} h ago (limit {maxAge.TotalHours} h)", data: data));
    }
}

[DisallowConcurrentExecution]
public sealed class StockSyncJob(JobHeartbeatStore heartbeats, ILogger<StockSyncJob> logger) : IJob
{
    public async ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken)
    {
        await Task.Delay(120, cancellationToken);
        logger.LogInformation("Stock synced for 312 books");
        heartbeats.RecordSuccess("stock-sync");
    }
}

Kayıt ASP.NET Core Web API serisinin "ASP.NET Core Web API'de Health Check: Liveness, Readiness ve Veritabanı Kontrolü" yazısındaki altyapıyla aynı; burada job'lar için ayrı bir tag ve endpoint kullanıyoruz:

builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton<JobHeartbeatStore>();
builder.Services.AddHealthChecks()
    .Add(new("stock-sync", sp => new JobFreshnessHealthCheck(
        sp.GetRequiredService<JobHeartbeatStore>(), TimeProvider.System, "stock-sync", TimeSpan.FromMinutes(15)),
        failureStatus: null, tags: ["jobs"]))
    .Add(new("nightly-sales-report", sp => new JobFreshnessHealthCheck(
        sp.GetRequiredService<JobHeartbeatStore>(), TimeProvider.System, "nightly-sales-report", TimeSpan.FromHours(26)),
        failureStatus: null, tags: ["jobs"]));

// ...

app.MapHealthChecks("/health/jobs", new HealthCheckOptions
{
    Predicate = r => r.Tags.Contains("jobs"),
    ResponseWriter = (ctx, report) => ctx.Response.WriteAsJsonAsync(new
    {
        status = report.Status.ToString(),
        checks = report.Entries.Select(e => new { name = e.Key, status = e.Value.Status.ToString(), e.Value.Description })
    })
});

Stok senkronizasyonu her 5 saniyede çalışıp damgasını yeniliyor; gece raporunu demo için 27 saat öncesine ayarladım:

$ curl -s -i http://127.0.0.1:17110/health/jobs
HTTP/1.1 503 Service Unavailable
{"status":"Unhealthy","checks":[{"name":"stock-sync","status":"Healthy","description":"stock-sync succeeded 0 min ago"},{"name":"nightly-sales-report","status":"Unhealthy","description":"nightly-sales-report last succeeded 27.0 h ago (limit 26 h)"}]}

503 ve hangi job'un neden alarm verdiği. 26 saat, 24 saatlik bir job'a 2 saatlik tolerans demek; sınırı job'un periyodu artı makul bir gecikme olarak seç. Bu endpoint'i Kubernetes liveness probe'una bağlama: rapor çalışmadı diye pod'u yeniden başlatmak sorunu çözmez, sadece gizler. Onun yerine bir izleme sisteminden periyodik olarak çağır ve alarm üret. Damgaları production'da bellekte değil bir tabloda tut; birden fazla replica'nın ve restart'ların aynı damgayı görmesi gerekiyor.

OpenTelemetry: Quartz 4'ün Span ve Metric'leri

Quartz 4, OpenTelemetry için kendi ActivitySource'unu ve Meter'ını yayınlıyor; isimleri QuartzInstrumentation sınıfında sabit olarak duruyor:

builder.Services.AddQuartz(q => q.ScheduleJob<StockSyncJob>(t => t
    .WithIdentity("stock-sync")
    .WithCronSchedule("0/5 * * ? * *")));
builder.Services.AddQuartzHostedService();
builder.Services.AddOpenTelemetry()
    .WithTracing(t => t.AddSource(QuartzInstrumentation.ActivitySourceName).AddConsoleExporter())
    .WithMetrics(m => m.AddMeter(QuartzInstrumentation.MeterName).AddConsoleExporter((_, reader) =>
        reader.PeriodicExportingMetricReaderOptions.ExportIntervalMilliseconds = 8000));

Console exporter'ın çıktısında bir job çalışmasının span'ı:

Activity.TraceId:            2bc730fb03f528b1174319c2b1568ed7
Activity.SpanId:             3991e390f6a583d3
Activity.TraceFlags:         Recorded
Activity.DisplayName:        Quartz.Job.Execute
Activity.Kind:               Internal
Activity.StartTime:          2026-11-08T02:30:05.0079340Z
Activity.Duration:           00:00:00.1230800
Activity.Tags:
    quartz.scheduler.name: QuartzScheduler
    quartz.scheduler.id: NON_CLUSTERED
    quartz.job.type: Bookstore.Api.StockSyncJob, p10
    quartz.fire.instance.id: 734269957552459
    quartz.trigger.group: DEFAULT
    quartz.trigger.name: stock-sync
    quartz.job.group: DEFAULT
    quartz.job.name: stock-sync
    quartz.job.result: succeeded
Instrumentation scope (ActivitySource):
    Name: Quartz
    Version: 4.7.2.0

Span, job'un adını, trigger'ını, tipini ve sonucunu (quartz.job.result: succeeded) taşıyor. Job store işlemleri (Quartz.JobStore.AcquireNextTriggers, Quartz.JobStore.TriggeredJobComplete) de ayrı span'lar olarak geliyor; veritabanı tabanlı bir store'da hangi adımın yavaş olduğunu buradan görürsün. Aynı koşuda yayınlanan metric'ler:

Metric Name: quartz.job.execution.active, Description: Number of jobs currently running, Unit: {job}, Metric Type: LongSumNonMonotonic
Metric Name: quartz.job.execution.duration, Description: Elapsed time spent executing a job, Unit: s, Metric Type: Histogram
Metric Name: quartz.jobstore.operation.duration, Description: Elapsed time spent on a job store operation, Unit: s, Metric Type: Histogram
Metric Name: quartz.trigger.acquired, Description: Number of triggers acquired for firing, Unit: {trigger}, Metric Type: LongSum
Metric Name: quartz.trigger.acquisition.duration, Description: Elapsed time spent acquiring the next batch of triggers, Unit: s, Metric Type: Histogram

Alarm için en değerlileri quartz.job.execution.duration histogram'ı (quartz.job.result etiketiyle başarılı ve başarısızları ayırıyor) ve quartz.job.execution.active. Önemli bir geçiş notu: Quartz 3 için yazılmış topluluk paketi OpenTelemetry.Instrumentation.Quartz'ın AddQuartzInstrumentation()'ı 4.x ile çalışmıyor. Quartz'ın XML (Extensible Markup Language) dokümantasyonuna göre bu paketin dinlediği eski listener ve activity source'lar 4.x'te artık veri üretmiyor; uygulama hata vermeden, hiçbir span görmeden çalışmaya devam ediyor. 4.x'te AddSource(QuartzInstrumentation.ActivitySourceName) kullan.

Hangfire'ın kendi içinde OpenTelemetry desteği yok; topluluğun OpenTelemetry.Instrumentation.Hangfire paketi var ama şu an yalnız beta sürümleri (1.20.2-beta.1) yayımlanmış. Bu yazı için onu denemedim. OpenTelemetry'nin kurulumu, collector ve backend seçimi ilerideki DevOps kategorisindeki Observability serisinin konusu.

Structured Log: JobId ve Deneme Sayısı

Bir job hata verdiğinde ilk soru "hangi job, kaçıncı deneme?" olur. Hangfire, job method'una PerformContext tipinde bir parametre eklersen çalışma anında onu dolduruyor; bununla her log satırına job id'sini ve retry sayısını scope olarak ekleyebilirsin:

public sealed class InvoiceJobs(ILogger<InvoiceJobs> logger)
{
    public Task SendAsync(int orderId, PerformContext? context)
    {
        // JobId ve deneme sayısı her log satırına scope olarak eklenir
        using IDisposable? scope = logger.BeginScope(new Dictionary<string, object?>
        {
            ["JobId"] = context?.BackgroundJob.Id,
            ["RetryCount"] = context?.GetJobParameter<int>("RetryCount") ?? 0
        });
        logger.LogInformation("Sending invoice for order {OrderId}", orderId);
        return Task.CompletedTask;
    }
}

app.MapPost("/orders/{id:int}/invoice", (int id, IBackgroundJobClient jobs) =>
    jobs.Enqueue<InvoiceJobs>(j => j.SendAsync(id, null)));

JSON (JavaScript Object Notation) console formatter'ıyla çıktı:

{"EventId":0,"LogLevel":"Information","Category":"Bookstore.Api.InvoiceJobs","Message":"Sending invoice for order 42","State":{"OrderId":42,"{OriginalFormat}":"Sending invoice for order {OrderId}"},"Scopes":[{"Message":"System.Collections.Generic.Dictionary`2[System.String,System.Object]","JobId":"1","RetryCount":0}]}

Log backend'inde artık JobId = "1" ile bir job'un bütün satırlarını, RetryCount > 0 ile yalnız tekrar denenen job'ları süzebilirsin. Hangi alanların log'a girmesi gerektiği, scope'ların maliyeti ve trace context ile log'ları birleştirmek ilerideki .NET'te Logging serisinin konusu.

Üretimde Dikkat: Alerting ve Güvenlik

Üç alarm çoğu sistem için yeterli bir başlangıç. Birincisi başarısız job: retry'ları tükenip Failed'a düşen her job bir insanın bakması gereken bir şey; Hangfire'da failed sayısını, Quartz'ta quartz.job.result etiketli duration metric'ini izle. İkincisi kuyruk büyümesi: enqueued sayısı bir süre boyunca sürekli artıyorsa worker'lar yetişemiyor. Üçüncüsü yukarıdaki freshness kontrolü: hata vermeyen ama çalışmayan job'lar. Retry'ı olan bir job'un ilk hatasında alarm üretme; retry'lar bunun için var. Alarm, retry'lar tükendiğinde ya da başarı oranı belirgin şekilde düştüğünde gelmeli.

Dashboard'u internete açık bırakma. Yetkilendirmeli olsa bile iç ağda ya da bir VPN (Virtual Private Network) arkasında tutmak, saldırı yüzeyini küçültür. Dashboard bir admin aracı; müşterinin göreceği bir ekran değil.

Job argümanlarındaki kişisel veriyi hatırla. Beşinci yazıda bir siparişin e-posta ve adresiyle birlikte job tablosuna yazıldığını gördük; dashboard bu argümanları herkese gösteriyor. Argüman olarak yalnız id geçmek hem bayatlık hem gizlilik sorununu çözüyor. Log'lar için de aynı kural geçerli: Web Güvenliği (OWASP Top 10) serisinin "OWASP A09: Security Logging and Alerting Failures — Neyi Loglamalı, Neyi Asla" yazısı neyin log'a girmemesi gerektiğini anlatıyor.

Sık Düşülen Tuzaklar

"Yalnız local" varsayılanına güvenmek. Aynı makinedeki bir proxy, bir sidecar ya da Docker'ın port yönlendirmesi her isteği local gösterir.

Herkese yazma yetkisi vermek. Dashboard'a bakan herkesin job silmesi ya da yeniden kuyruğa yazması gerekmiyor; IsReadOnlyFunc ile rolleri ayır.

Yalnız hataları izlemek. Hiç çalışmayan bir job hata üretmez; freshness kontrolü olmadan bunu haftalar sonra fark edersin.

Quartz 4'e geçip eski OpenTelemetry paketini bırakmak. AddQuartzInstrumentation() 4.x'te sessizce hiçbir şey üretmez.

Freshness health check'ini liveness probe'una bağlamak. Rapor çalışmadı diye pod'u yeniden başlatmak, sorunu bir restart döngüsüne çevirir.

Küçük Bir Deneme

★☆☆ Hangfire Dashboard'u IDashboardAuthorizationFilter ile koru: yalnız X-Ops-Key başlığı doğru olan isteklere izin ver. Docker container'ından gelen istek artık ne alıyor?

★★☆ JobHeartbeatStore'u bir veritabanı tablosuna taşı ve iki replica çalıştır. Replica'lardan yalnız biri job'u çalıştırırken iki replica'nın health check'i aynı sonucu veriyor mu?

★★★ Quartz'ın quartz.job.execution.duration metric'ini Prometheus formatında yayınla ve başarısız çalışma oranı son 15 dakikada yüzde 10'u geçince tetiklenen bir alarm kuralı yaz.

Serinin son yazısında zaman içinde geriye bakıyoruz: .NET 6'dan .NET 10'a BackgroundService ve host davranışında neler değişti, .NET 11'in getirdiği exit code değişikliği, Quartz 3'ten 4'e geçişin gerçek derleyici hataları ve eski kodu okurken karşına çıkacak IHostedService + Timer kalıbı.