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
13 changes: 7 additions & 6 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ its code behaves exactly as it did.
reports `SupportsOptimisticConcurrency == false` rather than pretending. CosmosDB uses ETags,
the relational providers a version column added to existing tables on startup, and the in-memory
provider a compare-and-swap.
- **`HealthChanged` on `PulseCheckerStateChangedEventArgs`**, with `PreviousHealth` and
`CurrentHealth` beside it. `StateChanged` fires on every check -- a stored result always moves the
execution time -- which was listed as a known issue and is not one: the dashboard redraws from
exactly those events. What was missing was a way to ask the narrower question, so every handler
that only cared about a component going down reached into `OldState`/`NewState.LastResult?.Health`
and worked it out again. The alerting and uptime packages were doing that identically; both now
read `PreviousHealth` and `CurrentHealth` from the event instead of digging for them.
- **Schedules.** `PulseSchedule` says either "every this long" or "on this cron expression", and
sits alongside `PulseInterval` rather than replacing it. The enum stopped at five minutes, which
is short of what a certificate-expiry or disk-space check wants. Cron is standard Unix syntax and
Expand Down Expand Up @@ -89,12 +96,6 @@ its code behaves exactly as it did.
`Newtonsoft.Json` reached through Hangfire (GHSA-5crp-9r3c-p9vr), and `System.Text.Json` in the
console sample (CVE-2024-30105 and CVE-2024-43485). The solution reports no vulnerable packages.

### Known issues

- `StateChanged` is raised on **every** check rather than only when state changes, because
`PulseCheckerState` equality includes the last execution time. Anything reacting to it should
compare the health itself, which the alerting and uptime packages do.

## [3.1.4] - 2026-07-19

### Changed
Expand Down
18 changes: 10 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,9 +252,14 @@ Subscribe to state transitions on any pulse checker:
```csharp
checker.StateChanged += (sender, args) =>
{
Console.WriteLine(
$"Health changed from {args.OldState.LastResult?.Health} " +
$"to {args.NewState.LastResult?.Health}");
// Fires on every check, because a stored result always moves the execution time.
// HealthChanged is the narrower question: did the component itself move?
if (!args.HealthChanged)
{
return;
}

Console.WriteLine($"Health changed from {args.PreviousHealth} to {args.CurrentHealth}");
};
```

Expand Down Expand Up @@ -818,12 +823,9 @@ Upgrading from v1.x? See the [v1 to v2 migration guide](https://github.com/ivanv

Shipped since 3.1.4: alerting on transitions, OpenTelemetry metrics and traces, arbitrary intervals
and cron, PostgreSQL / SQL Server / SQLite state providers, Hangfire / Coravel / Temporal
scheduling, ready-made checkers, uptime reporting, leader election, and optimistic concurrency on
`IStateProvider`. What is left:
scheduling, ready-made checkers, uptime reporting, leader election, optimistic concurrency
on `IStateProvider`, and `HealthChanged` on the state-changed event. What is left:

- **`StateChanged` fires on every check** rather than only when state changes, because state
equality includes the last execution time. Anything reacting to it should compare the health
itself, which the alerting and uptime packages do.
- **A Redis state provider** -- the fastest option for state written on every tick, and a natural
lease store for leader election.
- **Alert sinks beyond the webhook** -- Slack, Teams and PagerDuty as packages rather than as a
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using Healthie.Abstractions.Enums;

namespace Healthie.Abstractions.Models;

/// <summary>
Expand All @@ -18,4 +20,33 @@ public class PulseCheckerStateChangedEventArgs(
/// Gets the state after the change.
/// </summary>
public PulseCheckerState NewState { get; } = newState;

/// <summary>
/// Gets the health before this change, or <c>null</c> if no check had produced one yet.
/// </summary>
public PulseCheckerHealth? PreviousHealth => OldState.LastResult?.Health;

/// <summary>
/// Gets the health after this change, or <c>null</c> if there is still no result -- which is
/// what a setting changed before the first check ever ran looks like.
/// </summary>
public PulseCheckerHealth? CurrentHealth => NewState.LastResult?.Health;

/// <summary>
/// Gets whether this change was a change of health, rather than only of settings or of when the
/// check last ran.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="IPulseChecker.StateChanged"/> fires whenever the stored state differs, and a check
/// storing its result always changes it -- the execution time alone is enough. So a handler that
/// cares about a component going down, rather than about it having been looked at, wants this
/// and not the event itself.
/// </para>
/// <para>
/// A first result counts: going from nothing known to <c>Healthy</c> is a change. Losing a
/// result does not, because a state with no result says nothing about health.
/// </para>
/// </remarks>
public bool HealthChanged => CurrentHealth is not null && PreviousHealth != CurrentHealth;
}
6 changes: 3 additions & 3 deletions src/Healthie.Alerting/AlertDispatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -141,10 +141,10 @@ private void Unsubscribe()
/// </summary>
private void OnStateChanged(IPulseChecker checker, PulseCheckerStateChangedEventArgs args)
{
var previous = args.OldState.LastResult?.Health;
var current = args.NewState.LastResult?.Health;
// StateChanged fires on every check; only a change of health is worth waking somebody for.
var previous = args.PreviousHealth;

if (current is not { } health || previous == health || !ShouldAlert(previous, health))
if (args.CurrentHealth is not { } health || previous == health || !ShouldAlert(previous, health))
{
return;
}
Expand Down
7 changes: 2 additions & 5 deletions src/Healthie.Uptime/UptimeRecorder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -122,12 +122,9 @@ private void Unsubscribe()
private void OnStateChanged(IPulseChecker checker, PulseCheckerStateChangedEventArgs args)
{
// Only a change of health starts a new segment. StateChanged fires on every check, because
// state equality includes the last execution time, so recording every one of them would
// a stored result always moves the execution time, so recording every one of them would
// turn a day into 86,400 segments that all say the same thing.
var previous = args.OldState.LastResult?.Health;
var current = args.NewState.LastResult?.Health;

if (current is not { } health || previous == health)
if (args.CurrentHealth is not { } health || args.PreviousHealth == health)
{
return;
}
Expand Down
28 changes: 28 additions & 0 deletions tests/Healthie.Tests.Unit/AlertingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,34 @@ public async Task AFailure_ReachesTheSink()
}
}

/// <summary>
/// A change carrying no result says nothing about health, so it must not alert. This is the one
/// branch of the health-change test that the other tests here cannot reach: every other way of
/// raising the event puts a result on both sides.
/// </summary>
[Fact]
public async Task ASettingChangedBeforeTheFirstCheck_ReachesNoSink()
{
var sink = new RecordingSink();
var (dispatcher, checker) = await StartAsync(sink);

try
{
Assert.Equal(1, checker.SubscriberCount);
checker.RaiseSettingChanged("Tier 1");

// A failure raised straight after must be the first thing the sink sees.
checker.RaiseStateChanged(PulseCheckerHealth.Unhealthy);

Assert.True(await WaitUntilAsync(() => sink.Received.Count == 1, TimeSpan.FromSeconds(5)));
Assert.Equal(PulseCheckerHealth.Unhealthy, sink.Received[0].CurrentHealth);
}
finally
{
await ((IHostedService)dispatcher).StopAsync(CancellationToken.None);
}
}

/// <summary>
/// Suspicious is the state a checker passes through on its way to unhealthy, so alerting on it
/// by default would page somebody for every blip the threshold exists to absorb.
Expand Down
80 changes: 80 additions & 0 deletions tests/Healthie.Tests.Unit/HealthChangedTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
using Healthie.Abstractions.Enums;
using Healthie.Abstractions.Models;

namespace Healthie.Tests.Unit;

/// <summary>
/// <c>StateChanged</c> fires on every check, because a stored result always moves the execution
/// time. That is not a defect -- the dashboard redraws from it -- but it means every handler that
/// only cares about a component going down has to work out for itself whether the health moved.
/// Two packages were doing exactly that, identically, so the question now has one answer.
/// </summary>
public class HealthChangedTests
{
private static PulseCheckerState With(PulseCheckerHealth? health) =>
health is { } value
? new PulseCheckerState { LastResult = new PulseCheckerResult(value, string.Empty) }
: new PulseCheckerState();

private static PulseCheckerStateChangedEventArgs Change(
PulseCheckerHealth? from,
PulseCheckerHealth? to) => new(With(from), With(to));

[Fact]
public void ACheckRepeatingItsResult_IsNotAHealthChange()
{
var args = Change(PulseCheckerHealth.Healthy, PulseCheckerHealth.Healthy);

Assert.False(args.HealthChanged);
Assert.Equal(PulseCheckerHealth.Healthy, args.PreviousHealth);
Assert.Equal(PulseCheckerHealth.Healthy, args.CurrentHealth);
}

[Fact]
public void GoingUnhealthy_IsAHealthChange()
{
var args = Change(PulseCheckerHealth.Healthy, PulseCheckerHealth.Unhealthy);

Assert.True(args.HealthChanged);
Assert.Equal(PulseCheckerHealth.Healthy, args.PreviousHealth);
Assert.Equal(PulseCheckerHealth.Unhealthy, args.CurrentHealth);
}

/// <summary>
/// Nothing known to something known is a change: it is the first thing anyone learns about the
/// component, and an alerting rule that ignored it would stay silent through a cold start into
/// an outage.
/// </summary>
[Fact]
public void TheFirstResult_IsAHealthChange()
{
var args = Change(null, PulseCheckerHealth.Unhealthy);

Assert.True(args.HealthChanged);
Assert.Null(args.PreviousHealth);
}

/// <summary>
/// A state with no result is not a report of good health, so losing one is not a transition to
/// anything. Treating it as one would fire an alert whose "current health" is nothing at all.
/// </summary>
[Fact]
public void LosingAResult_IsNotAHealthChange()
{
var args = Change(PulseCheckerHealth.Unhealthy, null);

Assert.False(args.HealthChanged);
}

[Fact]
public void ASettingChangeThatKeepsTheResult_IsNotAHealthChange()
{
var result = new PulseCheckerResult(PulseCheckerHealth.Suspicious, "flapping");

var args = new PulseCheckerStateChangedEventArgs(
new PulseCheckerState { LastResult = result, Group = "before" },
new PulseCheckerState { LastResult = result, Group = "after" });

Assert.False(args.HealthChanged);
}
}
11 changes: 11 additions & 0 deletions tests/Healthie.Tests.Unit/TestDoubles.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,17 @@ public void RaiseStateChanged(PulseCheckerHealth health)
StateChanged?.Invoke(this, new PulseCheckerStateChangedEventArgs(oldState, _state));
}

/// <summary>
/// Raises <see cref="StateChanged"/> for a change that carries no result at all, which is what a
/// setting changed before the checker has ever run looks like.
/// </summary>
public void RaiseSettingChanged(string group)
{
var oldState = _state;
_state = new PulseCheckerState(PulseInterval.EveryMinute, 0) { Group = group };
StateChanged?.Invoke(this, new PulseCheckerStateChangedEventArgs(oldState, _state));
}

public Task TriggerAsync(CancellationToken cancellationToken = default)
{
TriggerCount++;
Expand Down
Loading