Skip to content
LastPing
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.

Your assistant can do this for you

Monitor my .NET jobs with LastPing

On this page
  1. The snippets
  2. What this catches
  3. Questions people ask

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 failure: OnPerformed fires with a non-null exception; the global filter POSTs to /fail without touching job classes.
  • Quartz.NET job exception: JobWasExecuted 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.

  • How do I monitor a .NET BackgroundService?

    In ExecuteAsync, run a PeriodicTimer loop. In each tick, wrap the work in try/catch: on success await _http.GetAsync(pingUrl); on exception await _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 IServerFilter and register it as a global filter. In OnPerformed, check context.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 JobDataMap or as a job parameter. In the global filter, read it from context.GetJobParameter<string>("pingUrl") and use it instead of the constant.

  • Does this work with Quartz.NET?

    Yes. Implement IJobListener and in JobWasExecuted check context.JobException != null. If there is an exception POST to /fail; otherwise GET the success URL. Register the listener on your scheduler with AddJobListener or as a global listener.

  • Do I need a NuGet package?

    No. System.Net.Http.HttpClient handles GET and POST without additional packages. Register IHttpClientFactory in 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.

FREE FOR INDIVIDUALS · FULLY HOSTED

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.