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
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ dotnet test # Run all tests (multi-targets: net8.0, net9.0, net10.0)
dotnet test --filter "FullyQualifiedName~SomeTest" # Run a single test
```

Build artifacts go to `/artifacts/bin/` (configured in `Directory.Build.props`).
Build artifacts go to `/artifacts/bin/` (configured in `Directory.Build.props`). The solution also contains `examples/HealthMonitor.Examples/` with runnable usage examples.

## Architecture

Expand All @@ -37,7 +37,9 @@ Build artifacts go to `/artifacts/bin/` (configured in `Directory.Build.props`).

### DI registration

`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.
`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. The call is idempotent for the shared infrastructure (hosted service, coordinator) — call it once per monitor name.

On `netstandard2.0` (no keyed DI), resolve a specific monitor by filtering `IEnumerable<IHealthMonitor>` by `Name`.

### Dynamic registration (no DI)

Expand Down
4 changes: 1 addition & 3 deletions Directory.Build.props
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
<Project>
<PropertyGroup>
<!-- Redirect all build outputs to a single top-level artifacts directory -->
<ArtifactsPath>$(MSBuildThisFileDirectory)artifacts</ArtifactsPath>
<!-- Use latest C# regardless of target framework (needed for netstandard2.0) -->
<UseArtifactsOutput>true</UseArtifactsOutput>
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
Expand Down
2 changes: 1 addition & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.1" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.3" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.4.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.5.1" />
<PackageVersion Include="MinVer" Version="7.0.0" />
<PackageVersion Include="Serilog.Extensions.Hosting" Version="8.0.0" />
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
[![CI](https://github.com/coldhighsun/HealthMonitor/actions/workflows/ci.yml/badge.svg)](https://github.com/coldhighsun/HealthMonitor/actions/workflows/ci.yml)
[![NuGet Version](https://img.shields.io/nuget/v/HealthMonitor.Core)](https://www.nuget.org/packages/HealthMonitor.Core)
[![NuGet Downloads](https://img.shields.io/nuget/dt/HealthMonitor.Core)](https://www.nuget.org/packages/HealthMonitor.Core)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)

A lightweight .NET library for monitoring application health via periodic heartbeats. Supports dependency injection, multiple named monitors, and fires `Degraded` / `Recovered` events on state transitions.

Expand Down Expand Up @@ -120,7 +121,7 @@ monitor.Signal();
```csharp
using var manager = new DynamicHealthMonitorManager();

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

Expand Down Expand Up @@ -178,6 +179,7 @@ Build outputs land in `./artifacts/bin/`.
[![CI](https://github.com/coldhighsun/HealthMonitor/actions/workflows/ci.yml/badge.svg)](https://github.com/coldhighsun/HealthMonitor/actions/workflows/ci.yml)
[![NuGet 版本](https://img.shields.io/nuget/v/HealthMonitor.Core)](https://www.nuget.org/packages/HealthMonitor.Core)
[![NuGet 下载量](https://img.shields.io/nuget/dt/HealthMonitor.Core)](https://www.nuget.org/packages/HealthMonitor.Core)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)

轻量级 .NET 健康监控库,通过周期性心跳信号判断组件是否存活,在状态切换时触发 `Degraded` / `Recovered` 事件。支持依赖注入、多个命名监控器。

Expand Down Expand Up @@ -295,7 +297,7 @@ monitor.Signal();
```csharp
using var manager = new DynamicHealthMonitorManager();

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,27 @@ public void TryGet_NonExistentMonitor_ReturnsNull()
Assert.Null(manager.TryGet("missing"));
}

[Fact]
public async Task ManagerDegraded_FiresForHandlerSubscribedAfterAdd()
{
using var manager = new DynamicHealthMonitorManager(new FakeSystemTimeProvider());

manager.Add("svc", new HealthMonitorOptions
{
DegradedThreshold = TimeSpan.FromMilliseconds(100),
CheckInterval = TimeSpan.FromMilliseconds(50),
});

// Subscribe AFTER adding the monitor
HealthDegradedEventArgs? captured = null;
manager.Degraded += (_, e) => captured = e;

await PollUntil(() => captured is not null, timeout: TimeSpan.FromSeconds(5));

Assert.NotNull(captured);
Assert.Equal("svc", captured.MonitorName);
}

private static async Task PollUntil(Func<bool> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
Expand Down
50 changes: 50 additions & 0 deletions tests/HealthMonitor.Tests/Monitors/HealthMonitorBaseTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,56 @@ private static (
return (monitor, clock, signalWatch, checkWatch, stateWatch);
}

[Fact]
public void Recovered_Args_Timestamp_UsesWallClock()
{
var (monitor, clock, signalWatch, checkWatch, _) = CreateMonitor();

// Degrade first
signalWatch.Advance(TimeSpan.FromSeconds(31));
checkWatch.Advance(TimeSpan.FromSeconds(5));
monitor.Tick();

HealthRecoveredEventArgs? recovered = null;
((IHealthMonitor)monitor).Recovered += (_, e) => recovered = e;

clock.Advance(TimeSpan.FromMinutes(1));
var expectedTime = clock.UtcNow;
((IHealthMonitor)monitor).Signal();

Assert.NotNull(recovered);
Assert.Equal(expectedTime, recovered.Timestamp);
}

[Fact]
public void Degrade_Recover_Degrade_CycleWorks()
{
var (monitor, _, signalWatch, checkWatch, _) = CreateMonitor(degradedThreshold: TimeSpan.FromSeconds(30));

var degradedCount = 0;
var recoveredCount = 0;
((IHealthMonitor)monitor).Degraded += (_, _) => degradedCount++;
((IHealthMonitor)monitor).Recovered += (_, _) => recoveredCount++;

// First degradation
signalWatch.Advance(TimeSpan.FromSeconds(31));
checkWatch.Advance(TimeSpan.FromSeconds(5));
monitor.Tick();
Assert.Equal(1, degradedCount);

// Recovery
((IHealthMonitor)monitor).Signal();
Assert.Equal(1, recoveredCount);
Assert.True(monitor.IsHealthy);

// Second degradation
signalWatch.Advance(TimeSpan.FromSeconds(31));
checkWatch.Advance(TimeSpan.FromSeconds(5));
monitor.Tick();
Assert.Equal(2, degradedCount);
Assert.False(monitor.IsHealthy);
}

// ── Initial state ──────────────────────────────────────────────────────────
// ── Degraded ───────────────────────────────────────────────────────────────
// ── Signal / Recovered ─────────────────────────────────────────────────────
Expand Down
16 changes: 16 additions & 0 deletions tests/HealthMonitor.Tests/Services/DependencyInjectionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,22 @@ public void AddHealthMonitor_RegistersSingleMonitor()
Assert.Equal("quotes", monitors[0].Name);
}

[Fact]
public void AddHealthMonitor_IsIdempotent_ForSharedInfrastructure()
{
using var provider = BuildProvider(s =>
{
s.AddHealthMonitor("quotes");
s.AddHealthMonitor("orders");
});

// Shared infrastructure (hosted service) should be registered exactly once
var hostedServices = provider.GetRequiredService<IEnumerable<IHostedService>>()
.Where(hs => hs.GetType().Name == "HealthMonitorHostedService")
.ToList();
Assert.Single(hostedServices);
}

private static ServiceProvider BuildProvider(Action<IServiceCollection> configure)
{
var services = new ServiceCollection();
Expand Down
Loading