Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Build artifacts go to `/artifacts/bin/` (configured in `Directory.Build.props`).
|---|---|
| `Abstractions/` | `IHealthMonitor`, `IStopwatch`, `ISystemTimeProvider` |
| `Detection/` | `RealStopwatch`, `SystemTimeProvider` — production implementations of the abstractions |
| `Monitors/` | `HealthMonitorBase` (state machine), `NamedHealthMonitor`, `HealthMonitorCoordinator` |
| `Monitors/` | `HealthMonitorBase` (state machine), `NamedHealthMonitor`, `HealthMonitorCoordinator`, `DynamicHealthMonitorManager` |
| `Services/` | `HealthMonitorHostedService` — background loop, ticks coordinator at `MinCheckInterval` |
| `Configuration/` | `HealthMonitorOptions`, `HealthMonitorRegistration` |
| `Extensions/` | `AddHealthMonitor()` DI extension |
Expand All @@ -39,9 +39,13 @@ Build artifacts go to `/artifacts/bin/` (configured in `Directory.Build.props`).

`AddHealthMonitor()` registers monitors as keyed services (by name, .NET 8+) and via `IEnumerable<IHealthMonitor>` on all targets. The hosted service resolves all monitors through the coordinator.

### Dynamic registration (no DI)

`DynamicHealthMonitorManager` is a standalone `IDisposable` class for runtime monitor management. Each monitor added via `Add(name, options?)` gets its own `System.Threading.Timer` firing at its `CheckInterval`. The manager exposes aggregate `Degraded` / `Recovered` events that forward from all registered monitors — subscribers added before or after `Add()` both receive events. `Remove()` disposes the timer and unsubscribes event forwarding atomically under a lock.

### Testability

`IStopwatch` and `ISystemTimeProvider` abstractions allow time-controlled unit tests. The `tests/HealthMonitor.Tests/Fakes/` directory provides `FakeStopwatch` and related fakes.
`IStopwatch` and `ISystemTimeProvider` abstractions allow time-controlled unit tests. The `tests/HealthMonitor.Tests/Fakes/` directory provides `FakeStopwatch` and related fakes. Tests use **xunit.v3** (not v2 — the assertion API differs).

### Multi-targeting

Expand Down
78 changes: 78 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ A lightweight .NET library for monitoring application health via periodic heartb
- **Multiple monitors** — register any number of independent monitors, each with its own degraded threshold and check interval
- **Two events** — `Degraded` fires when no heartbeat is received within the threshold; `Recovered` fires immediately when a signal arrives while degraded
- **DI-first** — integrates with `IServiceCollection` and runs as a `BackgroundService`
- **Dynamic management** — `DynamicHealthMonitorManager` adds and removes monitors at runtime; each runs its own `Timer` with per-monitor options
- **Keyed injection** — resolve a specific monitor by name via `IServiceProvider.GetRequiredKeyedService<IHealthMonitor>("name")` (.NET 8+)
- **Testable** — `IStopwatch` and `ISystemTimeProvider` abstractions decouple timing from wall-clock time

Expand Down Expand Up @@ -98,6 +99,44 @@ monitor.Signal();
| `Timestamp` | `DateTimeOffset` | UTC time of the transition |
| `DegradedDuration` | `TimeSpan` | How long the monitor was degraded before recovering |

## Dynamic Monitor Management

`DynamicHealthMonitorManager` lets you register and remove monitors at runtime — no DI or hosted service required. Each monitor owns its own `System.Threading.Timer` that fires at its configured `CheckInterval`.

```csharp
using var manager = new DynamicHealthMonitorManager();

// Subscribe once on the manager — fires for all monitors, including those added later
manager.Degraded += (_, e) => Console.WriteLine($"{e.MonitorName} degraded");
manager.Recovered += (_, e) => Console.WriteLine($"{e.MonitorName} recovered");

// Add monitors at any time; each runs its own Timer at its own CheckInterval
var db = manager.Add("database", new HealthMonitorOptions
{
DegradedThreshold = TimeSpan.FromSeconds(10),
CheckInterval = TimeSpan.FromSeconds(2),
});

// Heartbeat from your business logic
db.Signal();

// Add more monitors dynamically while the app runs
var cache = manager.Add("cache", new HealthMonitorOptions
{
DegradedThreshold = TimeSpan.FromSeconds(5),
CheckInterval = TimeSpan.FromSeconds(1),
});

// Remove a monitor — disposes its timer and unsubscribes event forwarding
manager.Remove("cache");

// Enumerate all active monitors
foreach (var m in manager.Monitors)
Console.WriteLine($"{m.Name}: {(m.IsHealthy ? "healthy" : "degraded")}");
```

`DynamicHealthMonitorManager` implements `IDisposable`; disposing it stops all timers.

## State Machine

```
Expand Down Expand Up @@ -133,6 +172,7 @@ Build outputs land in `./artifacts/bin/`.
- **多监控器** — 可注册任意数量的独立监控器,每个监控器拥有独立的降级阈值和检查间隔
- **两种事件** — 心跳超时触发 `Degraded`;降级期间收到信号立即触发 `Recovered`
- **DI 优先** — 集成 `IServiceCollection`,以 `BackgroundService` 形式运行
- **动态管理** — `DynamicHealthMonitorManager` 支持运行时动态添加/移除监控器,每个监控器有独立 `Timer` 和配置
- **按名称注入** — 通过 `IServiceProvider.GetRequiredKeyedService<IHealthMonitor>("name")` 按名称解析(.NET 8+)
- **易于测试** — `IStopwatch` 和 `ISystemTimeProvider` 抽象解耦了时间依赖

Expand Down Expand Up @@ -220,6 +260,44 @@ monitor.Signal();
| `Timestamp` | `DateTimeOffset` | 状态切换的 UTC 时间 |
| `DegradedDuration` | `TimeSpan` | 恢复前的降级持续时长 |

## 动态监控管理

`DynamicHealthMonitorManager` 支持在运行时动态注册和移除监控器,无需 DI 或托管服务。每个监控器拥有独立的 `System.Threading.Timer`,按自身的 `CheckInterval` 周期触发检查。

```csharp
using var manager = new DynamicHealthMonitorManager();

// 在 manager 上订阅一次 — 对所有监控器生效,包括后续动态添加的
manager.Degraded += (_, e) => Console.WriteLine($"{e.MonitorName} 已降级");
manager.Recovered += (_, e) => Console.WriteLine($"{e.MonitorName} 已恢复");

// 随时添加监控器,每个监控器使用自己的 Timer 和 CheckInterval
var db = manager.Add("database", new HealthMonitorOptions
{
DegradedThreshold = TimeSpan.FromSeconds(10),
CheckInterval = TimeSpan.FromSeconds(2),
});

// 在业务逻辑中发送心跳
db.Signal();

// 在运行时动态添加更多监控器
var cache = manager.Add("cache", new HealthMonitorOptions
{
DegradedThreshold = TimeSpan.FromSeconds(5),
CheckInterval = TimeSpan.FromSeconds(1),
});

// 移除监控器 — 立即销毁其 Timer 并取消事件转发
manager.Remove("cache");

// 枚举所有活跃监控器
foreach (var m in manager.Monitors)
Console.WriteLine($"{m.Name}: {(m.IsHealthy ? "健康" : "降级")}");
```

`DynamicHealthMonitorManager` 实现了 `IDisposable`,Dispose 时会停止所有计时器。

## 状态机

```
Expand Down
160 changes: 133 additions & 27 deletions examples/HealthMonitor.Examples/Program.cs
Original file line number Diff line number Diff line change
@@ -1,18 +1,25 @@
using HealthMonitor.Core.Abstractions;
using HealthMonitor.Core.Extensions;
using HealthMonitor.Core.Monitors;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Serilog;

// ─────────────────────────────────────────────────────────────────
// HealthMonitor — usage example: quote feed health
// HealthMonitor — usage examples
//
// Run with: dotnet run --project examples/ActivityMonitor.Examples
// Run with: dotnet run --project examples/HealthMonitor.Examples
//
// Simulates two quote feeds. A background task sends periodic
// "quotes" for the fast feed but intentionally stalls the slow feed
// so you can observe Degraded / Recovered events.
// Demo 1 – DI-based monitors (quote feeds)
// Two monitors are registered at startup. A background task drives
// Signal() calls; the slow feed stalls intentionally so you can see
// Degraded / Recovered events.
//
// Demo 2 – DynamicHealthMonitorManager (runtime registration)
// A manager is created standalone (no DI). Monitors are added,
// signalled, and removed while the app runs — each uses its own
// Timer and per-monitor options.
//
// Press Ctrl+C to exit.
// ─────────────────────────────────────────────────────────────────
Expand All @@ -26,15 +33,15 @@
.UseSerilog()
.ConfigureServices(services =>
{
// ── Monitor 1: fast feed ───────────────────────────────────
// ── Demo 1: DI-based monitors ──────────────────────────────

// Expects a signal at least every 3 seconds.
services.AddHealthMonitor("fast-feed", opt =>
{
opt.DegradedThreshold = TimeSpan.FromSeconds(3);
opt.CheckInterval = TimeSpan.FromSeconds(1);
});

// ── Monitor 2: slow feed ───────────────────────────────────
// Expects a signal at least every 10 seconds.
services.AddHealthMonitor("slow-feed", opt =>
{
Expand All @@ -45,14 +52,127 @@
services.AddHostedService<HealthEventLogger>();
services.AddHostedService<QuoteFeedSimulator>();
services.AddHostedService<StatusPrinter>();

// ── Demo 2: DynamicHealthMonitorManager ────────────────────
services.AddHostedService<DynamicMonitorDemo>();
})
.Build();

await host.RunAsync();

// ─────────────────────────────────────────────────────────────────
// HealthEventLogger — subscribes to Degraded / Recovered events.
// Demo 1 helpers
// ─────────────────────────────────────────────────────────────────

// ─────────────────────────────────────────────────────────────────
// Demo 2 – DynamicHealthMonitorManager
//
// Subscribes Degraded / Recovered on the manager itself — events from
// all monitors (including those added later) are forwarded centrally.
//
// Timeline (approximate):
// t=0s "api-gateway" and "worker" added and start receiving signals
// t=15s "cache" added dynamically — its events flow through manager too
// t=20s "worker" stops signalling — will degrade after 8 s
// t=35s "worker" resumes — fires Recovered
// t=40s "cache" removed — its timer and event forwarding disposed
// ─────────────────────────────────────────────────────────────────
internal sealed class DynamicMonitorDemo(ILogger<DynamicMonitorDemo> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation(new('─', 60));
logger.LogInformation("Demo 2 (DynamicHealthMonitorManager) starting...");

using var manager = new DynamicHealthMonitorManager();

// Subscribe once on the manager — covers all current and future monitors
manager.Degraded += (sender, e) =>
logger.LogWarning("[Dynamic/{Name}] *** DEGRADED *** (was healthy for {Duration:mm\\:ss})",
e.MonitorName, e.HealthyDuration);
manager.Recovered += (sender, e) =>
logger.LogInformation("[Dynamic/{Name}] *** RECOVERED *** (was degraded for {Duration:mm\\:ss})",
e.MonitorName, e.DegradedDuration);

// Add two monitors at startup
var gateway = manager.Add("api-gateway", new()
{
DegradedThreshold = TimeSpan.FromSeconds(6),
CheckInterval = TimeSpan.FromSeconds(2),
});

var worker = manager.Add("worker", new()
{
DegradedThreshold = TimeSpan.FromSeconds(8),
CheckInterval = TimeSpan.FromSeconds(2),
});

_ = SignalLoop(gateway, TimeSpan.FromSeconds(3), stoppingToken);

var workerSignalling = true;
_ = Task.Run(async () =>
{
while (!stoppingToken.IsCancellationRequested)
{
if (workerSignalling)
worker.Signal();
await Task.Delay(TimeSpan.FromSeconds(3), stoppingToken).ConfigureAwait(false);
}
}, stoppingToken);

// t=15s: add "cache" dynamically — manager events fire for it automatically
await Task.Delay(TimeSpan.FromSeconds(15), stoppingToken).ConfigureAwait(false);
if (stoppingToken.IsCancellationRequested)
return;

var cache = manager.Add("cache", new()
{
DegradedThreshold = TimeSpan.FromSeconds(5),
CheckInterval = TimeSpan.FromSeconds(1),
});
_ = SignalLoop(cache, TimeSpan.FromSeconds(2), stoppingToken);
logger.LogInformation("[Dynamic] 'cache' added at runtime. Active: {Names}",
string.Join(", ", manager.Monitors.Select(m => m.Name)));

// t=20s: pause worker signals → will degrade
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken).ConfigureAwait(false);
if (stoppingToken.IsCancellationRequested)
return;

workerSignalling = false;
logger.LogInformation("[Dynamic] 'worker' signals paused — expect Degraded in ~8 s");

// t=35s: resume worker → fires Recovered
await Task.Delay(TimeSpan.FromSeconds(15), stoppingToken).ConfigureAwait(false);
if (stoppingToken.IsCancellationRequested)
return;

workerSignalling = true;
worker.Signal();
logger.LogInformation("[Dynamic] 'worker' signals resumed — expect Recovered");

// t=40s: remove cache — timer and event forwarding disposed
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken).ConfigureAwait(false);
if (stoppingToken.IsCancellationRequested)
return;

manager.Remove("cache");
logger.LogInformation("[Dynamic] 'cache' removed. Active: {Names}",
string.Join(", ", manager.Monitors.Select(m => m.Name)));

await Task.Delay(Timeout.InfiniteTimeSpan, stoppingToken).ConfigureAwait(false);
}

private static async Task SignalLoop(IHealthMonitor monitor, TimeSpan interval, CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
monitor.Signal();
await Task.Delay(interval, ct).ConfigureAwait(false);
}
}
}

internal sealed class HealthEventLogger(IEnumerable<IHealthMonitor> monitors, ILogger<HealthEventLogger> logger)
: IHostedService
{
Expand All @@ -62,11 +182,11 @@ public Task StartAsync(CancellationToken cancellationToken)
{
var name = monitor.Name;
monitor.Degraded += (_, e) =>
logger.LogWarning("[{Monitor}] *** DEGRADED *** (was healthy for {Duration:mm\\:ss})",
logger.LogWarning("[DI/{Monitor}] *** DEGRADED *** (was healthy for {Duration:mm\\:ss})",
name, e.HealthyDuration);

monitor.Recovered += (_, e) =>
logger.LogInformation("[{Monitor}] *** RECOVERED *** (was degraded for {Duration:mm\\:ss})",
logger.LogInformation("[DI/{Monitor}] *** RECOVERED *** (was degraded for {Duration:mm\\:ss})",
name, e.DegradedDuration);
}

Expand All @@ -76,14 +196,6 @@ public Task StartAsync(CancellationToken cancellationToken)
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}

// ─────────────────────────────────────────────────────────────────
// QuoteFeedSimulator — drives Signal() calls to simulate quote feeds.
//
// fast-feed: sends a signal every 1 second (healthy).
// slow-feed: sends a signal every 8 seconds, then intentionally
// stalls for 15 seconds to trigger a Degraded event,
// then resumes to trigger Recovered.
// ─────────────────────────────────────────────────────────────────
internal sealed class QuoteFeedSimulator : BackgroundService
{
private readonly IHealthMonitor _fastFeed;
Expand All @@ -97,7 +209,6 @@ public QuoteFeedSimulator(IEnumerable<IHealthMonitor> monitors)

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Run both feed simulations concurrently
await Task.WhenAll(
SimulateFastFeed(stoppingToken),
SimulateSlowFeed(stoppingToken));
Expand Down Expand Up @@ -125,31 +236,26 @@ private async Task SimulateSlowFeed(CancellationToken ct)

// Stall phase: no signal for 15 s → triggers Degraded
await Task.Delay(TimeSpan.FromSeconds(15), ct).ConfigureAwait(false);

// Resume: first signal → triggers Recovered immediately
}
}
}

// ─────────────────────────────────────────────────────────────────
// StatusPrinter — prints current health state every second.
// ─────────────────────────────────────────────────────────────────
internal sealed class StatusPrinter(IEnumerable<IHealthMonitor> monitors, ILogger<StatusPrinter> logger)
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation("HealthMonitor running. Watching quote feeds...");
logger.LogInformation("Demo 1 (DI-based) running. Watching quote feeds...");
logger.LogInformation(new('─', 60));

while (!stoppingToken.IsCancellationRequested)
{
var parts = monitors.Select(m =>
$"{m.Name}: {(m.IsHealthy ? "HEALTHY" : "DEGRADED")}");

logger.LogInformation("{Parts}", parts);
logger.LogInformation("[DI status] {Parts}", string.Join(" | ", parts));

await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken).ConfigureAwait(false);
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken).ConfigureAwait(false);
}
}
}
Loading
Loading