GUIDE · .NET · FREE

Monitor .NET background jobs: BackgroundService, Hangfire, and Quartz.NET

For a BackgroundService: wrap the PeriodicTimer tick body in try/catch, await _http.GetAsync on success, await _http.PostAsync to /fail on exception. For Hangfire: register a global IServerFilter so every recurring job is covered without touching individual job classes. For Quartz.NET: implement IJobListener and check context.JobException. No NuGet packages needed beyond what you already have. Free, no monitor cap.

The snippets

Create a monitor in the console, set the schedule plus a grace window, and add the ping URL to appsettings.json.

BackgroundService with PeriodicTimer

PeriodicTimer (.NET 6+) avoids timer drift. IHttpClientFactory manages connection pooling. The exception is logged and re-thrown as OperationCanceledException is not re-thrown to keep the service running:

public class NightlyReportService : BackgroundService { private const string PingUrl = "https://ping.lastping.dev/<your-monitor-id>"; private readonly IHttpClientFactory _factory; private readonly ILogger<NightlyReportService> _log; public NightlyReportService(IHttpClientFactory f, ILogger<NightlyReportService> log) => (_factory, _log) = (f, log); protected override async Task ExecuteAsync(CancellationToken ct) { using var timer = new PeriodicTimer(TimeSpan.FromHours(24)); while (await timer.WaitForNextTickAsync(ct)) { var http = _factory.CreateClient(); try { await RunAsync(ct); await http.GetAsync(PingUrl, ct); } catch (Exception ex) when (ex is not OperationCanceledException) { _log.LogError(ex, "Nightly report failed"); var body = ex.Message[..Math.Min(ex.Message.Length, 10_000)]; await http.PostAsync($"{PingUrl}/fail", new StringContent(body), ct); } } } private Task RunAsync(CancellationToken ct) => Task.CompletedTask; // your logic }

Hangfire — global IServerFilter

Registering a global filter covers every recurring job without modifying individual job classes. Register it in Program.cs via GlobalJobFilters.Filters.Add:

public class LastPingFilter : IServerFilter { private const string PingUrl = "https://ping.lastping.dev/<your-monitor-id>"; private static readonly HttpClient Http = new(); public void OnPerforming(PerformingContext context) { } public void OnPerformed(PerformedContext context) { if (context.Exception is null) { _ = Http.GetAsync(PingUrl); // fire-and-forget; use await in async filter } else { var msg = context.Exception.Message[..Math.Min(context.Exception.Message.Length, 10_000)]; _ = Http.PostAsync($"{PingUrl}/fail", new StringContent(msg)); } } } // Program.cs or Startup.cs: GlobalJobFilters.Filters.Add(new LastPingFilter());

Quartz.NET — IJobListener

Implement IJobListener and register it on your scheduler. JobWasExecuted is called after every job run; check jobException to decide which ping to send:

public class LastPingJobListener : IJobListener { private const string PingUrl = "https://ping.lastping.dev/<your-monitor-id>"; private static readonly HttpClient Http = new(); public string Name => "LastPingListener"; public Task JobToBeExecuted(IJobExecutionContext context, CancellationToken ct) => Task.CompletedTask; public Task JobExecutionVetoed(IJobExecutionContext context, CancellationToken ct) => Task.CompletedTask; public async Task JobWasExecuted( IJobExecutionContext context, JobExecutionException? jobException, CancellationToken ct) { if (jobException is null) { await Http.GetAsync(PingUrl, ct); } else { var msg = jobException.Message[..Math.Min(jobException.Message.Length, 10_000)]; await Http.PostAsync($"{PingUrl}/fail", new StringContent(msg), ct); } } } // Register when building your scheduler: scheduler.ListenerManager.AddJobListener( new LastPingJobListener(), GroupMatcher<JobKey>.AnyGroup());

One ping URL per job vs. global filter

A single ping URL works for a single job. For multiple jobs each needing its own monitor, pass the URL as job data (Hangfire: PerformContext.GetJobParameter; Quartz.NET: context.JobDetail.JobDataMap). The LastPing MCP server lets AI agents create and manage monitors programmatically. Also see the cron job guide for the OS-scheduler approach and the Java guide for Spring equivalents.


What this catches

The catch and listener report what they can; the silence reports the rest.

  • A thrown exception — the /fail ping opens an incident immediately with the exception message stored.
  • Hangfire job failureOnPerformed fires with a non-null exception; the global filter POSTs to /fail without touching job classes.
  • Quartz.NET job exceptionJobWasExecuted with a non-null jobException triggers the /fail ping.
  • Process kill / OOM — no ping fires; the missing ping opens the incident after the grace window.
  • Recovery — the next successful run closes the incident and sends a recovery notice automatically.

Questions people ask

Front-loaded answers — the most important fact first.

  • Do I need a NuGet package?

    No. System.Net.Http.HttpClient handles GET and POST. Register IHttpClientFactory in your DI container for connection pool management in long-running services.

  • How do I cover multiple Hangfire jobs with different monitors?

    Store the ping URL in the job's JobDataMap or as a job parameter. In the global filter, read it from context.GetJobParameter<string>("pingUrl") and use it instead of the constant.

  • What if the worker process is killed?

    The filter or listener never runs for a SIGKILL — no ping reaches LastPing. Absence-detection monitoring handles this: the missing ping opens the incident after the grace window without any working code on the failure side.

  • Is this free?

    Yes. LastPing is free and fully hosted with no monitor cap and no paid tier. See how it compares to Healthchecks.io and Cronitor.

FREE · FULLY HOSTED · NO PAID TIER

One global filter covers every Hangfire job you'll ever add.

Fifteen lines now beat debugging a silent batch job later. First alert wired up in about a minute — and LastPing monitors itself the same way.