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.
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