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.
Your assistant can do this for you
Monitor my .NET jobs with LastPing
On this page
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
/failping opens an incident immediately with the exception message stored. - Hangfire job failure:
OnPerformedfires with a non-null exception; the global filter POSTs to/failwithout touching job classes. - Quartz.NET job exception:
JobWasExecutedwith a non-nulljobExceptiontriggers the/failping. - 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.
-
How do I monitor a .NET BackgroundService?
In
ExecuteAsync, run aPeriodicTimerloop. In each tick, wrap the work in try/catch: on successawait _http.GetAsync(pingUrl); on exceptionawait _http.PostAsync(pingUrl + "/fail", new StringContent(ex.Message.Substring(0, Math.Min(...))))and log the error. -
How do I monitor a Hangfire recurring job?
Implement
IServerFilterand register it as a global filter. InOnPerformed, checkcontext.Exception == null: if null GET the ping URL, else POST the exception message to/fail. This covers all recurring jobs without changing individual job classes. -
How do I cover multiple Hangfire jobs with different monitors?
Store the ping URL in the job's
JobDataMapor as a job parameter. In the global filter, read it fromcontext.GetJobParameter<string>("pingUrl")and use it instead of the constant. -
Does this work with Quartz.NET?
Yes. Implement
IJobListenerand inJobWasExecutedcheckcontext.JobException != null. If there is an exception POST to/fail; otherwise GET the success URL. Register the listener on your scheduler withAddJobListeneror as a global listener. -
Do I need a NuGet package?
No.
System.Net.Http.HttpClienthandles GET and POST without additional packages. RegisterIHttpClientFactoryin your DI container for connection pool management in long-running services and production use. -
What if the worker process is killed?
The filter or listener never runs for a SIGKILL, so 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 .NET job monitoring free?
Yes. LastPing is free for individuals with no monitor cap. See how it compares to Healthchecks.io and Cronitor.
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.