Knowledge Base
Software
2025-08-09
8 min

Monitoring .NET Applications with a Custom System Health Background Service

Monitor .NET microservices using a lightweight background service that logs thread pool usage, memory pressure, GC stats, and more.

.NET
Background Services
Observability
Monitoring
Performance
Garbage Collection
ThreadPool

When deploying .NET services in production β€” especially in containerized or orchestrated environments like Kubernetes β€” it's critical to have observability hooks in place. While you can integrate third-party APM tools like Application Insights or Prometheus exporters, sometimes a simple, custom-built monitor can give you exactly what you need without the bloat.

In this article, we explore a lean but powerful BackgroundService built in C# that logs system health metrics such as thread pool usage, GC activity, heap size, memory fragmentation, and the working set of your application process.

πŸ“Œ Why Build a Custom Health Monitor?

Out-of-the-box solutions are often black-boxed, come with licensing concerns, or require operational overhead. We wanted a mechanism to:

  • πŸ” Log actionable runtime statistics at regular intervals
  • πŸ“¦ Embed directly into .NET services β€” no agents required
  • πŸ“ˆ Detect unhealthy memory patterns, GC pressure, or thread pool exhaustion
  • βš™οΈ Stay lightweight and easy to extend

🧱 Core Implementation

Below is the heart of the system health monitor: a BackgroundService that runs in your app and logs useful metrics every 30 seconds (or custom interval).

public class SystemHealthBackgroundService : BackgroundService
{
    private readonly ILogger<SystemHealthBackgroundService> _logger;
    private readonly string _serviceName;
    private readonly TimeSpan _interval;

    public SystemHealthBackgroundService(
        ILogger<SystemHealthBackgroundService> logger,
        string serviceName = "GENERAL",
        TimeSpan? interval = null)
    {
        _logger = logger;
        _serviceName = serviceName;
        _interval = interval ?? TimeSpan.FromSeconds(30);
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        _logger.LogInformation("System Health Monitor started for {ServiceName}", _serviceName);

        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                ThreadPool.GetAvailableThreads(out var availWorker, out var availIO);
                ThreadPool.GetMaxThreads(out var maxWorker, out var maxIO);
                var usedWorker = maxWorker - availWorker;
                var usedIO = maxIO - availIO;

                var managedThreads = Process.GetCurrentProcess().Threads.Count;
                var totalMemMB = GC.GetTotalMemory(false) / 1024.0 / 1024.0;
                var gcInfo = GC.GetGCMemoryInfo();
                var heapSizeMB = gcInfo.HeapSizeBytes / 1024.0 / 1024.0;
                var fragmentationMB = gcInfo.FragmentedBytes / 1024.0 / 1024.0;
                var workingSetMB = Process.GetCurrentProcess().WorkingSet64 / 1024.0 / 1024.0;

                var gen0 = GC.CollectionCount(0);
                var gen1 = GC.CollectionCount(1);
                var gen2 = GC.CollectionCount(2);

                _logger.LogInformation(
                    "[{ServiceName} System Health] Threads: Worker {UsedWorker}/{MaxWorker}, IO {UsedIO}/{MaxIO}, Managed {Managed} | " +
                    "Memory: Total {Total:N2} MB, Heap {Heap:N2} MB, Fragmentation {Fragmentation:N2} MB, WorkingSet {WorkingSet:N2} MB | " +
                    "GC: Gen0={Gen0}, Gen1={Gen1}, Gen2={Gen2}",
                    _serviceName,
                    usedWorker, maxWorker,
                    usedIO, maxIO,
                    managedThreads,
                    totalMemMB,
                    heapSizeMB,
                    fragmentationMB,
                    workingSetMB,
                    gen0, gen1, gen2);

                if (totalMemMB > 1024)
                    _logger.LogWarning("High memory usage in {ServiceName}: {Total:N2} MB", _serviceName, totalMemMB);

                if (usedWorker > maxWorker * 0.9)
                    _logger.LogWarning("High worker thread usage in {ServiceName}: {Used}/{Max}", _serviceName, usedWorker, maxWorker);

                await Task.Delay(_interval, stoppingToken);
            }
            catch (TaskCanceledException)
            {
                // Graceful shutdown
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Error in System Health Monitor");
            }
        }

        _logger.LogInformation("System Health Monitor for {ServiceName} stopping.", _serviceName);
    }
}

πŸ”Œ Usage in Your App

Register the background service during startup:

services.AddHostedService(sp => 
    new SystemHealthBackgroundService(
        sp.GetRequiredService<ILogger<SystemHealthBackgroundService>>(),
        "MyService"));

You can optionally inject configuration for the interval using options or environment variables.

πŸ“ˆ What Metrics Are Logged?

  • πŸ’‘ Worker & IO Threads: Current vs max thread usage
  • 🧡 Managed Thread Count: Total threads alive
  • 🧠 GC Memory Info: Total memory, heap size, fragmentation
  • πŸ“¦ Working Set: Physical memory used by the process
  • ♻️ GC Collection Counts: Gen0, Gen1, Gen2 events
  • ⚠️ Warnings: Memory or thread pressure beyond thresholds

🌐 View the Code

The SystemHealthBackgroundService is part of the net-utils repository β€” a growing collection of utility services and extensions for .NET developers. This library is designed for reuse across microservices, console apps, and backend APIs, and will soon be packaged as a publicly available NuGet library.

Explore the source code, contribute, or star the repo here:
github.com/wesleybasson/net-utils

🧠 Final Thoughts

Having basic telemetry inside your .NET services β€” even without external observability stacks β€” can surface hidden issues early. Whether you use this for spot diagnostics, regression detection, or just peace of mind, it’s a valuable addition to your production readiness checklist.

πŸš€ Ideas for Future Enhancements

  • 🟒 Export as Prometheus metrics endpoint
  • πŸ“Š Integrate with OpenTelemetry
  • πŸ“§ Emit alerts via email, Slack, or webhook on thresholds
  • 🧱 Support for tracking thread starvation or async deadlocks

Built with performance, transparency, and service resilience in mind.

Filed under: .NET, Background Services, Observability, Monitoring, Performance, Garbage Collection, ThreadPool

Last updated: 2025-08-09