diff --git a/.gitignore b/.gitignore
index e638876..6eb04d2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,4 @@
-## Ignore Visual Studio temporary files, build results, and
+## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
@@ -405,4 +405,16 @@ FodyWeavers.xsd
.claude
fabric
CLAUDE.md
-.mcp.json
\ No newline at end of file
+.mcp.json
+
+# Throwaway screenshots and browser-automation output, which land in the working directory
+# during local verification. They are often of somebody's admin UI or inbox, and one stray
+# `git add -A` is all it takes to publish one.
+#
+# Anchored to the repository root, which is where they land. The images the docs and the
+# samples genuinely ship live in subdirectories and are deliberately untouched by this -- a
+# bare `*.png` would also swallow docs/assets and the sample's favicon.
+/*.png
+!/healthie.net.png
+!/healthie.net.banner.png
+.playwright-mcp/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 45b95eb..fd88140 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,4 +1,4 @@
-# Changelog
+# Changelog
All notable changes to this project will be documented in this file.
@@ -7,6 +7,51 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
## [Unreleased]
+## [4.1.0] - 2026-07-30
+
+Twenty packages was too many, and two of them had no business being separate. This release fixes
+that without breaking anything: **an application on 4.0.0 upgrades by changing nothing.**
+
+### Added
+
+- **`Healthie.NET`**, the package to install first. It carries no code of its own, only a dependency
+ on `Healthie.NET.DependencyInjection`, so it is one install and one dependency rather than a
+ bundle -- no provider, scheduler or UI framework arrives with it. The name people guess did not
+ exist before, and the actual entry point was called `DependencyInjection`, which reads like
+ plumbing rather than the way in.
+- **Uptime reporting and leader election are in the core package.** `AddHealthieUptime()` and
+ `AddHealthieLeaderElection()` need nothing else installed. Both stay opt-in: nothing runs until
+ you call them.
+
+### Deprecated
+
+- **`Healthie.NET.Uptime`** and **`Healthie.NET.LeaderElection`**. Neither carried a third-party
+ dependency, so keeping them separate cost two installs and saved nothing -- the provider packages
+ exist to keep Npgsql or Temporalio off machines that do not use them, and these two were not
+ providers.
+
+ **Nothing breaks.** Both packages are still published, now as assemblies of type forwards, so an
+ application that references either keeps compiling *and* keeps running untouched. That is what
+ makes this a minor release: moving a type between assemblies is a binary break the compiler cannot
+ see, because source keeps building and only the runtime finds out. Remove the reference whenever it
+ suits you; neither package will gain features.
+
+### Fixed
+
+- **The sample Dockerfiles took their .NET base images by mutable tag.** A tag is a different image
+ next week, so the build that passed review is not the build that ships. Both are pinned by digest,
+ which Dependabot updates the way it updates a package version. Closes four OpenSSF Scorecard
+ *Pinned-Dependencies* findings.
+
+### Security
+
+- **An Azure CosmosDB key committed in May 2025 was confirmed dead and the alert closed.** It had
+ already been removed from the working tree; the account it belonged to no longer exists in the
+ owning tenant, verified by enumerating every Cosmos account across that tenant's subscriptions, so
+ the key authenticates to nothing. It remains in history at `ee0c78e`, inert.
+- **Stray screenshots can no longer be committed.** Root-level PNGs are ignored, scoped to the root
+ so the images the docs and samples genuinely ship are untouched.
+
## [4.0.0] - 2026-07-30
Twelve new packages, the schedule model several of them needed, and optimistic concurrency on the
@@ -520,7 +565,8 @@ Blazor dashboard, and the CosmosDB and Quartz.NET providers.
- Dashboard UI improvements and additional sample pulse checkers.
-[Unreleased]: https://github.com/ivanvyd/Healthie.NET/compare/v4.0.0...HEAD
+[Unreleased]: https://github.com/ivanvyd/Healthie.NET/compare/v4.1.0...HEAD
+[4.1.0]: https://github.com/ivanvyd/Healthie.NET/compare/v4.0.0...v4.1.0
[4.0.0]: https://github.com/ivanvyd/Healthie.NET/compare/v3.1.4...v4.0.0
[3.0.0]: https://github.com/ivanvyd/Healthie.NET/compare/v2.3.0...v3.0.0
[2.3.0]: https://github.com/ivanvyd/Healthie.NET/releases/tag/v2.3.0
diff --git a/Healthie.NET.sln b/Healthie.NET.sln
index d61c039..01ead33 100644
--- a/Healthie.NET.sln
+++ b/Healthie.NET.sln
@@ -68,6 +68,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Healthie.LeaderElection", "
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Healthie.StateProviding.Redis", "src\Healthie.StateProviding.Redis\Healthie.StateProviding.Redis.csproj", "{AE94818D-ABCF-4B84-AF49-525A16D7890C}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Healthie.NET.Package", "src\Healthie.NET.Package\Healthie.NET.Package.csproj", "{A5B438EB-322F-4467-962E-190E22061BDB}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -390,6 +392,18 @@ Global
{AE94818D-ABCF-4B84-AF49-525A16D7890C}.Release|x64.Build.0 = Release|Any CPU
{AE94818D-ABCF-4B84-AF49-525A16D7890C}.Release|x86.ActiveCfg = Release|Any CPU
{AE94818D-ABCF-4B84-AF49-525A16D7890C}.Release|x86.Build.0 = Release|Any CPU
+ {A5B438EB-322F-4467-962E-190E22061BDB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A5B438EB-322F-4467-962E-190E22061BDB}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A5B438EB-322F-4467-962E-190E22061BDB}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {A5B438EB-322F-4467-962E-190E22061BDB}.Debug|x64.Build.0 = Debug|Any CPU
+ {A5B438EB-322F-4467-962E-190E22061BDB}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {A5B438EB-322F-4467-962E-190E22061BDB}.Debug|x86.Build.0 = Debug|Any CPU
+ {A5B438EB-322F-4467-962E-190E22061BDB}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A5B438EB-322F-4467-962E-190E22061BDB}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A5B438EB-322F-4467-962E-190E22061BDB}.Release|x64.ActiveCfg = Release|Any CPU
+ {A5B438EB-322F-4467-962E-190E22061BDB}.Release|x64.Build.0 = Release|Any CPU
+ {A5B438EB-322F-4467-962E-190E22061BDB}.Release|x86.ActiveCfg = Release|Any CPU
+ {A5B438EB-322F-4467-962E-190E22061BDB}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -421,6 +435,7 @@ Global
{4E2A9262-B7AC-4E51-9CB1-EDD8D81D2CBE} = {9BAF98D2-CC85-4721-9C67-88576218F61C}
{0EF6CF0A-9019-4A1D-A668-6C4982832BC6} = {9BAF98D2-CC85-4721-9C67-88576218F61C}
{AE94818D-ABCF-4B84-AF49-525A16D7890C} = {9BAF98D2-CC85-4721-9C67-88576218F61C}
+ {A5B438EB-322F-4467-962E-190E22061BDB} = {9BAF98D2-CC85-4721-9C67-88576218F61C}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {0EE98644-4A9C-4D31-8145-997C8B5A8119}
diff --git a/README.md b/README.md
index d495598..5c719c4 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-
+
**Trust your uptime.** A lightweight, extensible health monitoring framework for .NET applications.
@@ -39,37 +39,105 @@ Watch it all through a live dashboard, a REST API, Kubernetes probes, or an AI a
- **A dashboard with no dependencies.** Pure HTML and CSS, no UI framework, no web fonts -- two lines to add, and it runs air-gapped.
- **Built for agents.** An [MCP server](#ai-agents-mcp) that is read-only until you say otherwise, plus [optional diagnostics](#ai-diagnostics) through any `IChatClient`.
- **It tells someone.** [Alerting](https://www.nuget.org/packages/Healthie.NET.Alerting) fires on a health change rather than on every check, with recovery notices and flap suppression -- and a webhook that is down cannot delay a check or make a healthy component look unhealthy.
-- **It reports on itself.** A `Meter` and an `ActivitySource` OpenTelemetry finds by name, with no package to install. Plus [uptime over any window](https://www.nuget.org/packages/Healthie.NET.Uptime), which the hundred-entry history cannot answer.
-- **It scales out.** [Leader election](https://www.nuget.org/packages/Healthie.NET.LeaderElection) runs the checks on one replica at a time, so three replicas do not check everything three times.
+- **It reports on itself.** A `Meter` and an `ActivitySource` OpenTelemetry finds by name, with no package to install. Plus uptime over any window, which the hundred-entry history cannot answer -- `AddHealthieUptime()`, in the core package.
+- **It scales out.** `AddHealthieLeaderElection()` runs the checks on one replica at a time, so three replicas do not check everything three times.
-Everything heavy is opt-in: `Healthie.NET.Abstractions` carries exactly one dependency, and every provider, scheduler, sink and the dashboard is a separate package you add only if you want it.
+Everything heavy is opt-in. One package gets you running; a provider, a scheduler, the dashboard or the REST API is a separate install, and each of those exists to keep its driver off machines that do not need it.
---
## NuGet Packages
-| Package | Description | Install |
+**You need one.** `Healthie.NET` gets you checkers on a schedule, the three-state health model,
+in-memory storage and the `IHealthCheck` bridge — everything below is something you add only when
+you want it.
+
+```shell
+dotnet add package Healthie.NET
+```
+
+That is one package and one dependency — `Healthie.NET` → `Healthie.NET.DependencyInjection` →
+`Healthie.NET.Abstractions`. It is **not** a bundle: nothing below is pulled in with it, so no
+database driver, scheduler or UI framework arrives on your machine unless you name it.
+
+
+Swap the storage — state survives a restart
+
+Checker state is written on every tick. The in-memory default loses it when the process ends; pick
+one of these and it does not. Each is separate because each carries a database driver, and nobody
+wanting PostgreSQL should be made to download Cosmos.
+
+| Package | For |
+|---|---|
+| **[Healthie.NET.Postgres](https://www.nuget.org/packages/Healthie.NET.Postgres)** | PostgreSQL, including Databricks Lakebase |
+| **[Healthie.NET.SqlServer](https://www.nuget.org/packages/Healthie.NET.SqlServer)** | SQL Server and Azure SQL |
+| **[Healthie.NET.Sqlite](https://www.nuget.org/packages/Healthie.NET.Sqlite)** | SQLite — durable, with no server to stand up |
+| **[Healthie.NET.CosmosDb](https://www.nuget.org/packages/Healthie.NET.CosmosDb)** | Azure CosmosDB |
+| **[Healthie.NET.Redis](https://www.nuget.org/packages/Healthie.NET.Redis)** | Redis — the fastest, for state written every tick |
+| **[Healthie.NET.Relational](https://www.nuget.org/packages/Healthie.NET.Relational)** | The engine behind the three SQL providers. Use it directly for any other ADO.NET database |
+
+
+
+
+Swap the scheduler — where the schedule lives
+
+The built-in timer schedules in-process and forgets on restart, which is right for most
+applications. These put the schedule somewhere that outlives it.
+
+| Package | For |
+|---|---|
+| **[Healthie.NET.Quartz](https://www.nuget.org/packages/Healthie.NET.Quartz)** | Quartz.NET, if you already run it |
+| **[Healthie.NET.Hangfire](https://www.nuget.org/packages/Healthie.NET.Hangfire)** | Hangfire — schedules survive a restart, each occurrence runs on one replica |
+| **[Healthie.NET.Coravel](https://www.nuget.org/packages/Healthie.NET.Coravel)** | Coravel, if you already run it |
+| **[Healthie.NET.Temporal](https://www.nuget.org/packages/Healthie.NET.Temporal)** | Temporal — schedules live in the cluster |
+
+
+
+
+Add a surface — somewhere to look, or something to call
+
+| Package | For |
+|---|---|
+| **[Healthie.NET.Dashboard](https://www.nuget.org/packages/Healthie.NET.Dashboard)** | The Blazor dashboard. Zero third-party dependencies, no web fonts |
+| **[Healthie.NET.Api](https://www.nuget.org/packages/Healthie.NET.Api)** | REST endpoints for managing checkers, plus liveness and readiness probes |
+| **[Healthie.NET.Mcp](https://www.nuget.org/packages/Healthie.NET.Mcp)** | A Model Context Protocol server, so an agent can read and act on health |
+
+
+
+
+Add a capability — alerting, ready-made checkers, AI
+
+| Package | For |
+|---|---|
+| **[Healthie.NET.Alerting](https://www.nuget.org/packages/Healthie.NET.Alerting)** | Health changes become alerts, delivered to Slack, Teams, PagerDuty, a webhook, or your own sink |
+| **[Healthie.NET.Checkers](https://www.nuget.org/packages/Healthie.NET.Checkers)** | HTTP endpoints, TCP ports, TLS expiry, DNS and disk space, with no checker code to write |
+| **[Healthie.NET.AI](https://www.nuget.org/packages/Healthie.NET.AI)** | Explains a checker's recent failures through any `IChatClient` |
+
+Uptime reporting and leader election used to be packages of their own. They are in the core package
+now — call `AddHealthieUptime()` or `AddHealthieLeaderElection()` and nothing else to install. See
+[deprecated packages](#deprecated-packages).
+
+
+
+
+Reference it directly — for library authors
+
+| Package | For |
+|---|---|
+| **[Healthie.NET.Abstractions](https://www.nuget.org/packages/Healthie.NET.Abstractions)** | The contracts and `PulseChecker`, with exactly one dependency. Reference this if you are shipping a provider of your own |
+| **[Healthie.NET.DependencyInjection](https://www.nuget.org/packages/Healthie.NET.DependencyInjection)** | What `Healthie.NET` resolves to. Reference it directly if you would rather be explicit |
+
+
+
+#### Deprecated packages
+
+| Package | Replaced by | Still works? |
|---|---|---|
-| **[Healthie.NET.Abstractions](https://www.nuget.org/packages/Healthie.NET.Abstractions)** | Core interfaces, models, enums, and the `PulseChecker` abstract base class. | `dotnet add package Healthie.NET.Abstractions` |
-| **[Healthie.NET.DependencyInjection](https://www.nuget.org/packages/Healthie.NET.DependencyInjection)** | DI registration (`AddHealthie`), assembly scanning, the built-in `TimerPulseScheduler`, the in-memory state provider, and the `IHealthCheck` bridge. | `dotnet add package Healthie.NET.DependencyInjection` |
-| **[Healthie.NET.Api](https://www.nuget.org/packages/Healthie.NET.Api)** | ASP.NET Core API controller for managing pulse checkers via REST, plus liveness and readiness probe endpoints. | `dotnet add package Healthie.NET.Api` |
-| **[Healthie.NET.CosmosDb](https://www.nuget.org/packages/Healthie.NET.CosmosDb)** | Azure CosmosDB `IStateProvider` implementation for persisting pulse checker state. | `dotnet add package Healthie.NET.CosmosDb` |
-| **[Healthie.NET.Postgres](https://www.nuget.org/packages/Healthie.NET.Postgres)** | PostgreSQL `IStateProvider` implementation. Also covers Databricks Lakebase, which is managed PostgreSQL. | `dotnet add package Healthie.NET.Postgres` |
-| **[Healthie.NET.SqlServer](https://www.nuget.org/packages/Healthie.NET.SqlServer)** | SQL Server and Azure SQL `IStateProvider` implementation. | `dotnet add package Healthie.NET.SqlServer` |
-| **[Healthie.NET.Sqlite](https://www.nuget.org/packages/Healthie.NET.Sqlite)** | SQLite `IStateProvider` implementation -- durable state with no server to stand up. | `dotnet add package Healthie.NET.Sqlite` |
-| **[Healthie.NET.Redis](https://www.nuget.org/packages/Healthie.NET.Redis)** | Redis `IStateProvider` implementation -- the fastest option for state written on every tick. | `dotnet add package Healthie.NET.Redis` |
-| **[Healthie.NET.Relational](https://www.nuget.org/packages/Healthie.NET.Relational)** | The engine behind the three above. Use it directly for any other database with an ADO.NET driver. | `dotnet add package Healthie.NET.Relational` |
-| **[Healthie.NET.Dashboard](https://www.nuget.org/packages/Healthie.NET.Dashboard)** | Blazor health monitoring dashboard (Razor Class Library, zero third-party dependencies). | `dotnet add package Healthie.NET.Dashboard` |
-| **[Healthie.NET.Quartz](https://www.nuget.org/packages/Healthie.NET.Quartz)** | Quartz.NET `IPulseScheduler` implementation for CRON-based scheduling. | `dotnet add package Healthie.NET.Quartz` |
-| **[Healthie.NET.Hangfire](https://www.nuget.org/packages/Healthie.NET.Hangfire)** | Hangfire `IPulseScheduler` implementation -- schedules survive a restart, and each occurrence runs on exactly one replica. | `dotnet add package Healthie.NET.Hangfire` |
-| **[Healthie.NET.Coravel](https://www.nuget.org/packages/Healthie.NET.Coravel)** | Coravel `IPulseScheduler` implementation, for applications already running Coravel. | `dotnet add package Healthie.NET.Coravel` |
-| **[Healthie.NET.Temporal](https://www.nuget.org/packages/Healthie.NET.Temporal)** | Temporal `IPulseScheduler` implementation -- schedules live in the cluster and survive a restart. | `dotnet add package Healthie.NET.Temporal` |
-| **[Healthie.NET.Mcp](https://www.nuget.org/packages/Healthie.NET.Mcp)** | Model Context Protocol server, so an AI agent can read and act on service health. | `dotnet add package Healthie.NET.Mcp` |
-| **[Healthie.NET.AI](https://www.nuget.org/packages/Healthie.NET.AI)** | Optional AI diagnostics that explain a checker's recent failures. Bring any `IChatClient`. | `dotnet add package Healthie.NET.AI` |
-| **[Healthie.NET.Checkers](https://www.nuget.org/packages/Healthie.NET.Checkers)** | Ready-made checkers for HTTP endpoints, TCP ports, TLS certificate expiry, DNS and disk space. | `dotnet add package Healthie.NET.Checkers` |
-| **[Healthie.NET.Alerting](https://www.nuget.org/packages/Healthie.NET.Alerting)** | Turns health changes into alerts and delivers them to a webhook or your own sink. | `dotnet add package Healthie.NET.Alerting` |
-| **[Healthie.NET.Uptime](https://www.nuget.org/packages/Healthie.NET.Uptime)** | Uptime and SLA reporting over any window, by recording transitions rather than every check. | `dotnet add package Healthie.NET.Uptime` |
-| **[Healthie.NET.LeaderElection](https://www.nuget.org/packages/Healthie.NET.LeaderElection)** | Runs the checks on one replica at a time, so a scaled-out app checks each component once. | `dotnet add package Healthie.NET.LeaderElection` |
+| **Healthie.NET.Uptime** | `Healthie.NET` — call `AddHealthieUptime()` | Yes. It ships type forwards, so an existing application keeps compiling and running untouched |
+| **Healthie.NET.LeaderElection** | `Healthie.NET` — call `AddHealthieLeaderElection()` | Yes, the same way |
+
+Neither carried a third-party dependency, so keeping them separate cost you two installs and saved
+you nothing. They will not gain features; remove the reference whenever it suits you.
All packages target **.NET 8** and **.NET 10**.
@@ -77,11 +145,10 @@ All packages target **.NET 8** and **.NET 10**.
## Quick Start
-### 1. Install Packages
+### 1. Install
```shell
-dotnet add package Healthie.NET.Abstractions
-dotnet add package Healthie.NET.DependencyInjection
+dotnet add package Healthie.NET
```
### 2. Create a Pulse Checker
diff --git a/samples/Healthie.Sample.BlazorUI/Dockerfile b/samples/Healthie.Sample.BlazorUI/Dockerfile
index e9ce9f7..3eea706 100644
--- a/samples/Healthie.Sample.BlazorUI/Dockerfile
+++ b/samples/Healthie.Sample.BlazorUI/Dockerfile
@@ -1,7 +1,10 @@
-# Build from the repository root so the shared build configuration and the src/ projects the sample
+# Build from the repository root so the shared build configuration and the src/ projects the sample
# references are in context:
# docker build -f samples/Healthie.Sample.BlazorUI/Dockerfile .
-FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
+# Pinned by digest, not by tag. A tag is mutable, so "sdk:10.0" is a different image next
+# week and a build that passed review is not the build that ships. Dependabot updates the
+# digest the same way it updates a package version.
+FROM mcr.microsoft.com/dotnet/sdk:10.0@sha256:ed034a8bf0b24ded0cbbac07e17825d8e9ebfe21e308191d0f7421eaf5ad4664 AS build
WORKDIR /src
# Restore against the manifests alone first, so this layer is only rebuilt when dependencies change.
@@ -17,7 +20,7 @@ RUN dotnet publish samples/Healthie.Sample.BlazorUI/Healthie.Sample.BlazorUI.csp
# The samples target net8.0, so they run on the .NET 8 runtime even though the .NET 10 SDK builds
# them: the libraries they reference target both, and the newer SDK is needed to build net10.0.
# This also exercises the library from a .NET 8 consumer.
-FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
+FROM mcr.microsoft.com/dotnet/aspnet:8.0@sha256:8f6a307ae32fb393a4b4bcde0a81f0ce3f0a715de0e5575df71f3030448f2dde AS runtime
WORKDIR /app
COPY --from=build /app ./
diff --git a/samples/Healthie.Sample.BlazorUI/packages.lock.json b/samples/Healthie.Sample.BlazorUI/packages.lock.json
index c55b3da..37bdefb 100644
--- a/samples/Healthie.Sample.BlazorUI/packages.lock.json
+++ b/samples/Healthie.Sample.BlazorUI/packages.lock.json
@@ -300,7 +300,7 @@
"Healthie.NET.LeaderElection": {
"type": "Project",
"dependencies": {
- "Healthie.NET.Abstractions": "[1.0.0, )"
+ "Healthie.NET.DependencyInjection": "[1.0.0, )"
}
},
"Healthie.NET.Quartz": {
@@ -315,7 +315,7 @@
"Healthie.NET.Uptime": {
"type": "Project",
"dependencies": {
- "Healthie.NET.Abstractions": "[1.0.0, )"
+ "Healthie.NET.DependencyInjection": "[1.0.0, )"
}
},
"Cronos": {
diff --git a/samples/Healthie.Sample.WebApi/Dockerfile b/samples/Healthie.Sample.WebApi/Dockerfile
index 11082dd..3ceaec4 100644
--- a/samples/Healthie.Sample.WebApi/Dockerfile
+++ b/samples/Healthie.Sample.WebApi/Dockerfile
@@ -1,7 +1,10 @@
-# Build from the repository root so the shared build configuration and the src/ projects the sample
+# Build from the repository root so the shared build configuration and the src/ projects the sample
# references are in context:
# docker build -f samples/Healthie.Sample.WebApi/Dockerfile .
-FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
+# Pinned by digest, not by tag. A tag is mutable, so "sdk:10.0" is a different image next
+# week and a build that passed review is not the build that ships. Dependabot updates the
+# digest the same way it updates a package version.
+FROM mcr.microsoft.com/dotnet/sdk:10.0@sha256:ed034a8bf0b24ded0cbbac07e17825d8e9ebfe21e308191d0f7421eaf5ad4664 AS build
WORKDIR /src
# Restore against the manifests alone first, so this layer is only rebuilt when dependencies change.
@@ -17,7 +20,7 @@ RUN dotnet publish samples/Healthie.Sample.WebApi/Healthie.Sample.WebApi.csproj
# The samples target net8.0, so they run on the .NET 8 runtime even though the .NET 10 SDK builds
# them: the libraries they reference target both, and the newer SDK is needed to build net10.0.
# This also exercises the library from a .NET 8 consumer.
-FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
+FROM mcr.microsoft.com/dotnet/aspnet:8.0@sha256:8f6a307ae32fb393a4b4bcde0a81f0ce3f0a715de0e5575df71f3030448f2dde AS runtime
WORKDIR /app
COPY --from=build /app ./
diff --git a/src/Healthie.Alerting/AlertHistory.cs b/src/Healthie.Alerting/AlertHistory.cs
index 61ace8b..3b846b8 100644
--- a/src/Healthie.Alerting/AlertHistory.cs
+++ b/src/Healthie.Alerting/AlertHistory.cs
@@ -46,6 +46,9 @@ public sealed class AlertHistory(
// A plain object, not System.Threading.Lock: this package targets net8.0 as well.
private readonly object _gate = new();
+ /// Lets one write reach the state provider at a time. See .
+ private readonly SemaphoreSlim _writeGate = new(1, 1);
+
private bool _loaded;
private readonly Dictionary _sinks = [];
@@ -134,8 +137,6 @@ public void Record(Alert alert, bool delivered)
alert.OccurredAt,
delivered);
- List toPersist;
-
lock (_gate)
{
// Trims after enqueuing rather than before. Dropping the oldest first has to special-case
@@ -146,13 +147,11 @@ public void Record(Alert alert, bool delivered)
{
_recent.Dequeue();
}
-
- toPersist = [.. _recent];
}
- // Outside the lock: this is a round trip to the state store, and holding a lock across it
- // would stall every reader of the board for the duration of a database write.
- _ = PersistAsync(toPersist);
+ // Outside the lock: a round trip to the state store held under it would stall every reader of
+ // the board for the duration of a database write.
+ _ = PersistAsync();
}
/// Records that an alert never reached the queue.
@@ -231,15 +230,34 @@ private async Task EnsureLoadedAsync(CancellationToken cancellationToken)
}
}
- private async Task PersistAsync(List log)
+ ///
+ /// Writes the whole log, one writer at a time.
+ ///
+ ///
+ /// The snapshot is taken inside the gate, not passed in. Two alerts raised back to back
+ /// start two writes, and nothing orders them: with the snapshot captured at call time the older
+ /// one could land last and silently drop the newer alert. Taking it here means whichever write
+ /// goes last writes the newest state, so the store converges on what is actually held. A real
+ /// PostgreSQL under CI timing found this; two writes in a row on a fast local disk did not.
+ ///
+ private async Task PersistAsync()
{
if (stateProvider is null)
{
return;
}
+ await _writeGate.WaitAsync().ConfigureAwait(false);
+
try
{
+ List log;
+
+ lock (_gate)
+ {
+ log = [.. _recent];
+ }
+
await stateProvider.SetStateAsync(StorageKey, log).ConfigureAwait(false);
}
catch (Exception ex)
@@ -248,5 +266,9 @@ private async Task PersistAsync(List log)
// must not take alerting down with it -- the alert has already reached its sinks.
logger?.LogWarning(ex, "Could not persist the alert history to the state provider.");
}
+ finally
+ {
+ _writeGate.Release();
+ }
}
}
diff --git a/src/Healthie.LeaderElection/ILeaseProvider.cs b/src/Healthie.DependencyInjection/LeaderElection/ILeaseProvider.cs
similarity index 100%
rename from src/Healthie.LeaderElection/ILeaseProvider.cs
rename to src/Healthie.DependencyInjection/LeaderElection/ILeaseProvider.cs
diff --git a/src/Healthie.LeaderElection/InMemoryLeaseProvider.cs b/src/Healthie.DependencyInjection/LeaderElection/InMemoryLeaseProvider.cs
similarity index 100%
rename from src/Healthie.LeaderElection/InMemoryLeaseProvider.cs
rename to src/Healthie.DependencyInjection/LeaderElection/InMemoryLeaseProvider.cs
diff --git a/src/Healthie.LeaderElection/LeaderElectedPulseScheduler.cs b/src/Healthie.DependencyInjection/LeaderElection/LeaderElectedPulseScheduler.cs
similarity index 100%
rename from src/Healthie.LeaderElection/LeaderElectedPulseScheduler.cs
rename to src/Healthie.DependencyInjection/LeaderElection/LeaderElectedPulseScheduler.cs
diff --git a/src/Healthie.LeaderElection/LeaderElectionOptions.cs b/src/Healthie.DependencyInjection/LeaderElection/LeaderElectionOptions.cs
similarity index 100%
rename from src/Healthie.LeaderElection/LeaderElectionOptions.cs
rename to src/Healthie.DependencyInjection/LeaderElection/LeaderElectionOptions.cs
diff --git a/src/Healthie.LeaderElection/LeaderElectionService.cs b/src/Healthie.DependencyInjection/LeaderElection/LeaderElectionService.cs
similarity index 100%
rename from src/Healthie.LeaderElection/LeaderElectionService.cs
rename to src/Healthie.DependencyInjection/LeaderElection/LeaderElectionService.cs
diff --git a/src/Healthie.LeaderElection/LeadershipInsights.cs b/src/Healthie.DependencyInjection/LeaderElection/LeadershipInsights.cs
similarity index 100%
rename from src/Healthie.LeaderElection/LeadershipInsights.cs
rename to src/Healthie.DependencyInjection/LeaderElection/LeadershipInsights.cs
diff --git a/src/Healthie.LeaderElection/StartupExtensions.cs b/src/Healthie.DependencyInjection/LeaderElection/StartupExtensions.cs
similarity index 100%
rename from src/Healthie.LeaderElection/StartupExtensions.cs
rename to src/Healthie.DependencyInjection/LeaderElection/StartupExtensions.cs
diff --git a/src/Healthie.Uptime/IUptimeStore.cs b/src/Healthie.DependencyInjection/Uptime/IUptimeStore.cs
similarity index 100%
rename from src/Healthie.Uptime/IUptimeStore.cs
rename to src/Healthie.DependencyInjection/Uptime/IUptimeStore.cs
diff --git a/src/Healthie.Uptime/InMemoryUptimeStore.cs b/src/Healthie.DependencyInjection/Uptime/InMemoryUptimeStore.cs
similarity index 100%
rename from src/Healthie.Uptime/InMemoryUptimeStore.cs
rename to src/Healthie.DependencyInjection/Uptime/InMemoryUptimeStore.cs
diff --git a/src/Healthie.Uptime/StartupExtensions.cs b/src/Healthie.DependencyInjection/Uptime/StartupExtensions.cs
similarity index 100%
rename from src/Healthie.Uptime/StartupExtensions.cs
rename to src/Healthie.DependencyInjection/Uptime/StartupExtensions.cs
diff --git a/src/Healthie.Uptime/UptimeCalculator.cs b/src/Healthie.DependencyInjection/Uptime/UptimeCalculator.cs
similarity index 100%
rename from src/Healthie.Uptime/UptimeCalculator.cs
rename to src/Healthie.DependencyInjection/Uptime/UptimeCalculator.cs
diff --git a/src/Healthie.Uptime/UptimeInsights.cs b/src/Healthie.DependencyInjection/Uptime/UptimeInsights.cs
similarity index 100%
rename from src/Healthie.Uptime/UptimeInsights.cs
rename to src/Healthie.DependencyInjection/Uptime/UptimeInsights.cs
diff --git a/src/Healthie.Uptime/UptimeRecorder.cs b/src/Healthie.DependencyInjection/Uptime/UptimeRecorder.cs
similarity index 100%
rename from src/Healthie.Uptime/UptimeRecorder.cs
rename to src/Healthie.DependencyInjection/Uptime/UptimeRecorder.cs
diff --git a/src/Healthie.Uptime/UptimeReport.cs b/src/Healthie.DependencyInjection/Uptime/UptimeReport.cs
similarity index 100%
rename from src/Healthie.Uptime/UptimeReport.cs
rename to src/Healthie.DependencyInjection/Uptime/UptimeReport.cs
diff --git a/src/Healthie.Uptime/UptimeSegment.cs b/src/Healthie.DependencyInjection/Uptime/UptimeSegment.cs
similarity index 100%
rename from src/Healthie.Uptime/UptimeSegment.cs
rename to src/Healthie.DependencyInjection/Uptime/UptimeSegment.cs
diff --git a/src/Healthie.LeaderElection/Healthie.LeaderElection.csproj b/src/Healthie.LeaderElection/Healthie.LeaderElection.csproj
index bfe994f..172f659 100644
--- a/src/Healthie.LeaderElection/Healthie.LeaderElection.csproj
+++ b/src/Healthie.LeaderElection/Healthie.LeaderElection.csproj
@@ -1,17 +1,18 @@
+
Healthie.NET.LeaderElection
- Leader election for Healthie.NET: runs pulse checks on one replica at a time, so a scaled-out application checks each component once rather than once per replica.
- $(HealthieCommonTags);leader-election;distributed;lease;ha
+ DEPRECATED -- use Healthie.NET.DependencyInjection, which now contains leader election. This package remains as type forwards so existing applications keep working; it will not gain new features.
+ $(HealthieCommonTags);leader-election;ha;replicas;deprecated
-
-
-
-
-
+
diff --git a/src/Healthie.LeaderElection/TypeForwards.cs b/src/Healthie.LeaderElection/TypeForwards.cs
new file mode 100644
index 0000000..5b52492
--- /dev/null
+++ b/src/Healthie.LeaderElection/TypeForwards.cs
@@ -0,0 +1,16 @@
+using System.Runtime.CompilerServices;
+
+// Every public type this package used to define now lives in Healthie.NET.DependencyInjection.
+// See the note in Healthie.Uptime's TypeForwards.cs for why these are forwarded rather than simply
+// moved: without the forward, an assembly compiled against this one fails at runtime rather than at
+// build time, which is the worst way to find out.
+//
+// This package is deprecated on nuget.org and exists only so applications that reference it keep
+// working. Nothing new should be added here.
+
+[assembly: TypeForwardedTo(typeof(Healthie.LeaderElection.ILeaseProvider))]
+[assembly: TypeForwardedTo(typeof(Healthie.LeaderElection.InMemoryLeaseProvider))]
+[assembly: TypeForwardedTo(typeof(Healthie.LeaderElection.LeaderElectedPulseScheduler))]
+[assembly: TypeForwardedTo(typeof(Healthie.LeaderElection.LeaderElectionOptions))]
+[assembly: TypeForwardedTo(typeof(Healthie.LeaderElection.LeaderElectionService))]
+[assembly: TypeForwardedTo(typeof(Healthie.LeaderElection.StartupExtensions))]
diff --git a/src/Healthie.LeaderElection/packages.lock.json b/src/Healthie.LeaderElection/packages.lock.json
index 6ce5436..9a85b39 100644
--- a/src/Healthie.LeaderElection/packages.lock.json
+++ b/src/Healthie.LeaderElection/packages.lock.json
@@ -55,12 +55,44 @@
"Microsoft.Extensions.Hosting.Abstractions": "[10.0.10, )"
}
},
+ "Healthie.NET.DependencyInjection": {
+ "type": "Project",
+ "dependencies": {
+ "Cronos": "[0.13.0, )",
+ "Healthie.NET.Abstractions": "[1.0.0, )",
+ "Microsoft.Extensions.Diagnostics.HealthChecks": "[10.0.10, )"
+ }
+ },
+ "Cronos": {
+ "type": "CentralTransitive",
+ "requested": "[0.13.0, )",
+ "resolved": "0.13.0",
+ "contentHash": "Nkve+Yi0+ol3ntSBRHeW0ka9cJQ8vndSUvvkXqF9LVqwpoqWueyxu/HS275r98RDOmF7/mzRnZW5XXUBXob+TA=="
+ },
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "CentralTransitive",
"requested": "[10.0.10, )",
"resolved": "10.0.10",
"contentHash": "z/2xXlFw2aLGjHyEm6E0tQ+In6VfzQzTrtArbQ2c0TQE16ZbyDCMGPvaUT9I0s8rgy9sRWlU2P9waW37qV04qA=="
},
+ "Microsoft.Extensions.Diagnostics.HealthChecks": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.10, )",
+ "resolved": "10.0.10",
+ "contentHash": "R0O5oG+zAJeBSM8nNTa+Ycj2Zobyr/v6Ilo7Dha0sNB2Vq/XXoLdoecj9DAWGbN8YrPaW6u8+osTQ5Ypj7ZF0w==",
+ "dependencies": {
+ "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": "10.0.10",
+ "Microsoft.Extensions.Hosting.Abstractions": "10.0.10",
+ "Microsoft.Extensions.Logging.Abstractions": "10.0.10",
+ "Microsoft.Extensions.Options": "10.0.10"
+ }
+ },
+ "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.10, )",
+ "resolved": "10.0.10",
+ "contentHash": "6euxgVR7NS83y0a2wLRAxfYXusLQJ2e1ah0MpQgYTYMs5lYrmdNP79C6T8uvRZdP87n5mcCcp6+w0EyWAidKZw=="
+ },
"Microsoft.Extensions.Hosting.Abstractions": {
"type": "CentralTransitive",
"requested": "[10.0.10, )",
@@ -103,8 +135,8 @@
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "Transitive",
- "resolved": "8.0.2",
- "contentHash": "nroMDjS7hNBPtkZqVBbSiQaQjWRDxITI8Y7XnDs97rqG3EbzVTNLZQf7bIeUJcaHOV8bca47s1Uxq94+2oGdxA==",
+ "resolved": "8.0.3",
+ "contentHash": "dL0QGToTxggRLMYY4ZYX5AMwBb+byQBd/5dMiZE07Nv73o6I5Are3C7eQTh7K2+A4ct0PVISSr7TZANbiNb2yQ==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2"
}
@@ -129,12 +161,44 @@
"Microsoft.Extensions.Hosting.Abstractions": "[8.0.1, )"
}
},
+ "Healthie.NET.DependencyInjection": {
+ "type": "Project",
+ "dependencies": {
+ "Cronos": "[0.13.0, )",
+ "Healthie.NET.Abstractions": "[1.0.0, )",
+ "Microsoft.Extensions.Diagnostics.HealthChecks": "[8.0.29, )"
+ }
+ },
+ "Cronos": {
+ "type": "CentralTransitive",
+ "requested": "[0.13.0, )",
+ "resolved": "0.13.0",
+ "contentHash": "Nkve+Yi0+ol3ntSBRHeW0ka9cJQ8vndSUvvkXqF9LVqwpoqWueyxu/HS275r98RDOmF7/mzRnZW5XXUBXob+TA=="
+ },
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "CentralTransitive",
"requested": "[8.0.2, )",
"resolved": "8.0.2",
"contentHash": "3iE7UF7MQkCv1cxzCahz+Y/guQbTqieyxyaWKhrRO91itI9cOKO76OHeQDahqG4MmW5umr3CcCvGmK92lWNlbg=="
},
+ "Microsoft.Extensions.Diagnostics.HealthChecks": {
+ "type": "CentralTransitive",
+ "requested": "[8.0.29, )",
+ "resolved": "8.0.29",
+ "contentHash": "CKaXBomiwjwo+HQv8WhCPVW8LwNP8pVu6nKzRxh23qgOMVNJeerPgxfHaBniFJsFnBzsq9ntu6nXk77CbYvkHA==",
+ "dependencies": {
+ "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": "8.0.29",
+ "Microsoft.Extensions.Hosting.Abstractions": "8.0.1",
+ "Microsoft.Extensions.Logging.Abstractions": "8.0.3",
+ "Microsoft.Extensions.Options": "8.0.2"
+ }
+ },
+ "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": {
+ "type": "CentralTransitive",
+ "requested": "[8.0.29, )",
+ "resolved": "8.0.29",
+ "contentHash": "HSabrcvae+5j1CYW4MJa8CC9Wd4ZX1l9ImldPjiDeTmcdgfi6XvEuWFLncj/xB2FiVuOVlMAssMWiICPxZnEJA=="
+ },
"Microsoft.Extensions.Hosting.Abstractions": {
"type": "CentralTransitive",
"requested": "[8.0.1, )",
diff --git a/src/Healthie.NET.Package/Healthie.NET.Package.csproj b/src/Healthie.NET.Package/Healthie.NET.Package.csproj
new file mode 100644
index 0000000..eb82af1
--- /dev/null
+++ b/src/Healthie.NET.Package/Healthie.NET.Package.csproj
@@ -0,0 +1,30 @@
+
+
+
+
+ Healthie.NET
+ Health monitoring for .NET. Install this to get started: pulse checkers on a schedule, a three-state health model, and pluggable storage. Providers, the dashboard and the REST API are separate packages you add only if you want them.
+ $(HealthieCommonTags);health;monitoring;healthcheck
+
+
+ false
+ false
+ false
+ $(NoWarn);NU5128
+
+
+
+
+
+
+
diff --git a/src/Healthie.NET.Package/packages.lock.json b/src/Healthie.NET.Package/packages.lock.json
new file mode 100644
index 0000000..9a85b39
--- /dev/null
+++ b/src/Healthie.NET.Package/packages.lock.json
@@ -0,0 +1,217 @@
+{
+ "version": 2,
+ "dependencies": {
+ "net10.0": {
+ "Microsoft.Extensions.Configuration.Abstractions": {
+ "type": "Transitive",
+ "resolved": "10.0.10",
+ "contentHash": "5Vnd2I75DmZCVEjSynIdJ/0EGafgnLQwgR3t2C2/fkjx/nRG+cLwxLLdInoHeCEpkD5K4Ov/g9ZCRYrl4TRsaA==",
+ "dependencies": {
+ "Microsoft.Extensions.Primitives": "10.0.10"
+ }
+ },
+ "Microsoft.Extensions.Diagnostics.Abstractions": {
+ "type": "Transitive",
+ "resolved": "10.0.10",
+ "contentHash": "9uWiKpeOVac355STyChWR/pliFX/5CeLqChW9kKsaxyDH4EUTZxMkT4Jwp/J/peLm0GBFmSX5c0WCse3yCnq1Q==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10",
+ "Microsoft.Extensions.Options": "10.0.10"
+ }
+ },
+ "Microsoft.Extensions.FileProviders.Abstractions": {
+ "type": "Transitive",
+ "resolved": "10.0.10",
+ "contentHash": "c5zqFCY9DiIpMovLd7/d/CTiEtrMOuQ639dhv3PABtKQIKNQikSHwQt8+N679uii9q+B55lgK28Uv64FOwEu8w==",
+ "dependencies": {
+ "Microsoft.Extensions.Primitives": "10.0.10"
+ }
+ },
+ "Microsoft.Extensions.Logging.Abstractions": {
+ "type": "Transitive",
+ "resolved": "10.0.10",
+ "contentHash": "zkFxGYUvdxAvIKTyXHrmW+Sux53D4SezD9dMyZ6hrwwzPQJNuwCRy1f5W7AvYTqacEGhWF2XderRQG1OvbV8og==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10"
+ }
+ },
+ "Microsoft.Extensions.Options": {
+ "type": "Transitive",
+ "resolved": "10.0.10",
+ "contentHash": "srnhnk7nE8krBiIXp71LvBmKBtraBONWSRzdjJgRv1Ko9Mp8IVNqv4vIS9hGeVteBig8aQkva9ZG+sC+o5sVcA==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10",
+ "Microsoft.Extensions.Primitives": "10.0.10"
+ }
+ },
+ "Microsoft.Extensions.Primitives": {
+ "type": "Transitive",
+ "resolved": "10.0.10",
+ "contentHash": "5wu/GrYVd8mG2DVUw3vFJzF+O336TyTGg/Kmcgw9bfwYhCoFiV5lR5QeEmKecJyrW4W54nMfD3p3589E8a7czQ=="
+ },
+ "Healthie.NET.Abstractions": {
+ "type": "Project",
+ "dependencies": {
+ "Microsoft.Extensions.Hosting.Abstractions": "[10.0.10, )"
+ }
+ },
+ "Healthie.NET.DependencyInjection": {
+ "type": "Project",
+ "dependencies": {
+ "Cronos": "[0.13.0, )",
+ "Healthie.NET.Abstractions": "[1.0.0, )",
+ "Microsoft.Extensions.Diagnostics.HealthChecks": "[10.0.10, )"
+ }
+ },
+ "Cronos": {
+ "type": "CentralTransitive",
+ "requested": "[0.13.0, )",
+ "resolved": "0.13.0",
+ "contentHash": "Nkve+Yi0+ol3ntSBRHeW0ka9cJQ8vndSUvvkXqF9LVqwpoqWueyxu/HS275r98RDOmF7/mzRnZW5XXUBXob+TA=="
+ },
+ "Microsoft.Extensions.DependencyInjection.Abstractions": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.10, )",
+ "resolved": "10.0.10",
+ "contentHash": "z/2xXlFw2aLGjHyEm6E0tQ+In6VfzQzTrtArbQ2c0TQE16ZbyDCMGPvaUT9I0s8rgy9sRWlU2P9waW37qV04qA=="
+ },
+ "Microsoft.Extensions.Diagnostics.HealthChecks": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.10, )",
+ "resolved": "10.0.10",
+ "contentHash": "R0O5oG+zAJeBSM8nNTa+Ycj2Zobyr/v6Ilo7Dha0sNB2Vq/XXoLdoecj9DAWGbN8YrPaW6u8+osTQ5Ypj7ZF0w==",
+ "dependencies": {
+ "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": "10.0.10",
+ "Microsoft.Extensions.Hosting.Abstractions": "10.0.10",
+ "Microsoft.Extensions.Logging.Abstractions": "10.0.10",
+ "Microsoft.Extensions.Options": "10.0.10"
+ }
+ },
+ "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.10, )",
+ "resolved": "10.0.10",
+ "contentHash": "6euxgVR7NS83y0a2wLRAxfYXusLQJ2e1ah0MpQgYTYMs5lYrmdNP79C6T8uvRZdP87n5mcCcp6+w0EyWAidKZw=="
+ },
+ "Microsoft.Extensions.Hosting.Abstractions": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.10, )",
+ "resolved": "10.0.10",
+ "contentHash": "5LugpYGHk+mkn0a8IZgcyfBca8PCTAU9RQFoMrTdtOOidq88M2SI5f3px6ugnzgxC+eTkvYYJi8pzlUnG5xdAQ==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration.Abstractions": "10.0.10",
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10",
+ "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.10",
+ "Microsoft.Extensions.FileProviders.Abstractions": "10.0.10",
+ "Microsoft.Extensions.Logging.Abstractions": "10.0.10"
+ }
+ }
+ },
+ "net8.0": {
+ "Microsoft.Extensions.Configuration.Abstractions": {
+ "type": "Transitive",
+ "resolved": "8.0.0",
+ "contentHash": "3lE/iLSutpgX1CC0NOW70FJoGARRHbyKmG7dc0klnUZ9Dd9hS6N/POPWhKhMLCEuNN5nXEY5agmlFtH562vqhQ==",
+ "dependencies": {
+ "Microsoft.Extensions.Primitives": "8.0.0"
+ }
+ },
+ "Microsoft.Extensions.Diagnostics.Abstractions": {
+ "type": "Transitive",
+ "resolved": "8.0.1",
+ "contentHash": "elH2vmwNmsXuKmUeMQ4YW9ldXiF+gSGDgg1vORksob5POnpaI6caj1Hu8zaYbEuibhqCoWg0YRWDazBY3zjBfg==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2",
+ "Microsoft.Extensions.Options": "8.0.2"
+ }
+ },
+ "Microsoft.Extensions.FileProviders.Abstractions": {
+ "type": "Transitive",
+ "resolved": "8.0.0",
+ "contentHash": "ZbaMlhJlpisjuWbvXr4LdAst/1XxH3vZ6A0BsgTphZ2L4PGuxRLz7Jr/S7mkAAnOn78Vu0fKhEgNF5JO3zfjqQ==",
+ "dependencies": {
+ "Microsoft.Extensions.Primitives": "8.0.0"
+ }
+ },
+ "Microsoft.Extensions.Logging.Abstractions": {
+ "type": "Transitive",
+ "resolved": "8.0.3",
+ "contentHash": "dL0QGToTxggRLMYY4ZYX5AMwBb+byQBd/5dMiZE07Nv73o6I5Are3C7eQTh7K2+A4ct0PVISSr7TZANbiNb2yQ==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2"
+ }
+ },
+ "Microsoft.Extensions.Options": {
+ "type": "Transitive",
+ "resolved": "8.0.2",
+ "contentHash": "dWGKvhFybsaZpGmzkGCbNNwBD1rVlWzrZKANLW/CcbFJpCEceMCGzT7zZwHOGBCbwM0SzBuceMj5HN1LKV1QqA==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0",
+ "Microsoft.Extensions.Primitives": "8.0.0"
+ }
+ },
+ "Microsoft.Extensions.Primitives": {
+ "type": "Transitive",
+ "resolved": "8.0.0",
+ "contentHash": "bXJEZrW9ny8vjMF1JV253WeLhpEVzFo1lyaZu1vQ4ZxWUlVvknZ/+ftFgVheLubb4eZPSwwxBeqS1JkCOjxd8g=="
+ },
+ "Healthie.NET.Abstractions": {
+ "type": "Project",
+ "dependencies": {
+ "Microsoft.Extensions.Hosting.Abstractions": "[8.0.1, )"
+ }
+ },
+ "Healthie.NET.DependencyInjection": {
+ "type": "Project",
+ "dependencies": {
+ "Cronos": "[0.13.0, )",
+ "Healthie.NET.Abstractions": "[1.0.0, )",
+ "Microsoft.Extensions.Diagnostics.HealthChecks": "[8.0.29, )"
+ }
+ },
+ "Cronos": {
+ "type": "CentralTransitive",
+ "requested": "[0.13.0, )",
+ "resolved": "0.13.0",
+ "contentHash": "Nkve+Yi0+ol3ntSBRHeW0ka9cJQ8vndSUvvkXqF9LVqwpoqWueyxu/HS275r98RDOmF7/mzRnZW5XXUBXob+TA=="
+ },
+ "Microsoft.Extensions.DependencyInjection.Abstractions": {
+ "type": "CentralTransitive",
+ "requested": "[8.0.2, )",
+ "resolved": "8.0.2",
+ "contentHash": "3iE7UF7MQkCv1cxzCahz+Y/guQbTqieyxyaWKhrRO91itI9cOKO76OHeQDahqG4MmW5umr3CcCvGmK92lWNlbg=="
+ },
+ "Microsoft.Extensions.Diagnostics.HealthChecks": {
+ "type": "CentralTransitive",
+ "requested": "[8.0.29, )",
+ "resolved": "8.0.29",
+ "contentHash": "CKaXBomiwjwo+HQv8WhCPVW8LwNP8pVu6nKzRxh23qgOMVNJeerPgxfHaBniFJsFnBzsq9ntu6nXk77CbYvkHA==",
+ "dependencies": {
+ "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": "8.0.29",
+ "Microsoft.Extensions.Hosting.Abstractions": "8.0.1",
+ "Microsoft.Extensions.Logging.Abstractions": "8.0.3",
+ "Microsoft.Extensions.Options": "8.0.2"
+ }
+ },
+ "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": {
+ "type": "CentralTransitive",
+ "requested": "[8.0.29, )",
+ "resolved": "8.0.29",
+ "contentHash": "HSabrcvae+5j1CYW4MJa8CC9Wd4ZX1l9ImldPjiDeTmcdgfi6XvEuWFLncj/xB2FiVuOVlMAssMWiICPxZnEJA=="
+ },
+ "Microsoft.Extensions.Hosting.Abstractions": {
+ "type": "CentralTransitive",
+ "requested": "[8.0.1, )",
+ "resolved": "8.0.1",
+ "contentHash": "nHwq9aPBdBPYXPti6wYEEfgXddfBrYC+CQLn+qISiwQq5tpfaqDZSKOJNxoe9rfQxGf1c+2wC/qWFe1QYJPYqw==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration.Abstractions": "8.0.0",
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2",
+ "Microsoft.Extensions.Diagnostics.Abstractions": "8.0.1",
+ "Microsoft.Extensions.FileProviders.Abstractions": "8.0.0",
+ "Microsoft.Extensions.Logging.Abstractions": "8.0.2"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Healthie.Uptime/Healthie.Uptime.csproj b/src/Healthie.Uptime/Healthie.Uptime.csproj
index 0ad647a..5415dd0 100644
--- a/src/Healthie.Uptime/Healthie.Uptime.csproj
+++ b/src/Healthie.Uptime/Healthie.Uptime.csproj
@@ -1,13 +1,18 @@
+
Healthie.NET.Uptime
- Uptime and SLA reporting for Healthie.NET: records health transitions so you can answer what your uptime was over any window, not just the last hundred checks.
- $(HealthieCommonTags);uptime;sla;reporting;availability
+ DEPRECATED -- use Healthie.NET.DependencyInjection, which now contains uptime reporting. This package remains as type forwards so existing applications keep working; it will not gain new features.
+ $(HealthieCommonTags);uptime;sla;reporting;availability;deprecated
-
+
diff --git a/src/Healthie.Uptime/TypeForwards.cs b/src/Healthie.Uptime/TypeForwards.cs
new file mode 100644
index 0000000..4a79dea
--- /dev/null
+++ b/src/Healthie.Uptime/TypeForwards.cs
@@ -0,0 +1,20 @@
+using System.Runtime.CompilerServices;
+
+// Every public type this package used to define now lives in Healthie.NET.DependencyInjection.
+//
+// Forwarded rather than moved outright, because moving a type between assemblies is a binary break
+// even when the source is identical: an assembly compiled against Healthie.Uptime asks the runtime
+// for Healthie.Uptime!Healthie.Uptime.IUptimeStore by name, and without a forward it gets a
+// TypeLoadException at the first call. With one, the runtime follows the pointer and the caller
+// never notices. That is what makes folding these into core a minor release rather than a major.
+//
+// This package is deprecated on nuget.org and exists only so applications that reference it keep
+// working. Nothing new should be added here.
+
+[assembly: TypeForwardedTo(typeof(Healthie.Uptime.IUptimeStore))]
+[assembly: TypeForwardedTo(typeof(Healthie.Uptime.InMemoryUptimeStore))]
+[assembly: TypeForwardedTo(typeof(Healthie.Uptime.StartupExtensions))]
+[assembly: TypeForwardedTo(typeof(Healthie.Uptime.UptimeCalculator))]
+[assembly: TypeForwardedTo(typeof(Healthie.Uptime.UptimeRecorder))]
+[assembly: TypeForwardedTo(typeof(Healthie.Uptime.UptimeReport))]
+[assembly: TypeForwardedTo(typeof(Healthie.Uptime.UptimeSegment))]
diff --git a/src/Healthie.Uptime/packages.lock.json b/src/Healthie.Uptime/packages.lock.json
index 6ce5436..9a85b39 100644
--- a/src/Healthie.Uptime/packages.lock.json
+++ b/src/Healthie.Uptime/packages.lock.json
@@ -55,12 +55,44 @@
"Microsoft.Extensions.Hosting.Abstractions": "[10.0.10, )"
}
},
+ "Healthie.NET.DependencyInjection": {
+ "type": "Project",
+ "dependencies": {
+ "Cronos": "[0.13.0, )",
+ "Healthie.NET.Abstractions": "[1.0.0, )",
+ "Microsoft.Extensions.Diagnostics.HealthChecks": "[10.0.10, )"
+ }
+ },
+ "Cronos": {
+ "type": "CentralTransitive",
+ "requested": "[0.13.0, )",
+ "resolved": "0.13.0",
+ "contentHash": "Nkve+Yi0+ol3ntSBRHeW0ka9cJQ8vndSUvvkXqF9LVqwpoqWueyxu/HS275r98RDOmF7/mzRnZW5XXUBXob+TA=="
+ },
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "CentralTransitive",
"requested": "[10.0.10, )",
"resolved": "10.0.10",
"contentHash": "z/2xXlFw2aLGjHyEm6E0tQ+In6VfzQzTrtArbQ2c0TQE16ZbyDCMGPvaUT9I0s8rgy9sRWlU2P9waW37qV04qA=="
},
+ "Microsoft.Extensions.Diagnostics.HealthChecks": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.10, )",
+ "resolved": "10.0.10",
+ "contentHash": "R0O5oG+zAJeBSM8nNTa+Ycj2Zobyr/v6Ilo7Dha0sNB2Vq/XXoLdoecj9DAWGbN8YrPaW6u8+osTQ5Ypj7ZF0w==",
+ "dependencies": {
+ "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": "10.0.10",
+ "Microsoft.Extensions.Hosting.Abstractions": "10.0.10",
+ "Microsoft.Extensions.Logging.Abstractions": "10.0.10",
+ "Microsoft.Extensions.Options": "10.0.10"
+ }
+ },
+ "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.10, )",
+ "resolved": "10.0.10",
+ "contentHash": "6euxgVR7NS83y0a2wLRAxfYXusLQJ2e1ah0MpQgYTYMs5lYrmdNP79C6T8uvRZdP87n5mcCcp6+w0EyWAidKZw=="
+ },
"Microsoft.Extensions.Hosting.Abstractions": {
"type": "CentralTransitive",
"requested": "[10.0.10, )",
@@ -103,8 +135,8 @@
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "Transitive",
- "resolved": "8.0.2",
- "contentHash": "nroMDjS7hNBPtkZqVBbSiQaQjWRDxITI8Y7XnDs97rqG3EbzVTNLZQf7bIeUJcaHOV8bca47s1Uxq94+2oGdxA==",
+ "resolved": "8.0.3",
+ "contentHash": "dL0QGToTxggRLMYY4ZYX5AMwBb+byQBd/5dMiZE07Nv73o6I5Are3C7eQTh7K2+A4ct0PVISSr7TZANbiNb2yQ==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2"
}
@@ -129,12 +161,44 @@
"Microsoft.Extensions.Hosting.Abstractions": "[8.0.1, )"
}
},
+ "Healthie.NET.DependencyInjection": {
+ "type": "Project",
+ "dependencies": {
+ "Cronos": "[0.13.0, )",
+ "Healthie.NET.Abstractions": "[1.0.0, )",
+ "Microsoft.Extensions.Diagnostics.HealthChecks": "[8.0.29, )"
+ }
+ },
+ "Cronos": {
+ "type": "CentralTransitive",
+ "requested": "[0.13.0, )",
+ "resolved": "0.13.0",
+ "contentHash": "Nkve+Yi0+ol3ntSBRHeW0ka9cJQ8vndSUvvkXqF9LVqwpoqWueyxu/HS275r98RDOmF7/mzRnZW5XXUBXob+TA=="
+ },
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "CentralTransitive",
"requested": "[8.0.2, )",
"resolved": "8.0.2",
"contentHash": "3iE7UF7MQkCv1cxzCahz+Y/guQbTqieyxyaWKhrRO91itI9cOKO76OHeQDahqG4MmW5umr3CcCvGmK92lWNlbg=="
},
+ "Microsoft.Extensions.Diagnostics.HealthChecks": {
+ "type": "CentralTransitive",
+ "requested": "[8.0.29, )",
+ "resolved": "8.0.29",
+ "contentHash": "CKaXBomiwjwo+HQv8WhCPVW8LwNP8pVu6nKzRxh23qgOMVNJeerPgxfHaBniFJsFnBzsq9ntu6nXk77CbYvkHA==",
+ "dependencies": {
+ "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": "8.0.29",
+ "Microsoft.Extensions.Hosting.Abstractions": "8.0.1",
+ "Microsoft.Extensions.Logging.Abstractions": "8.0.3",
+ "Microsoft.Extensions.Options": "8.0.2"
+ }
+ },
+ "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": {
+ "type": "CentralTransitive",
+ "requested": "[8.0.29, )",
+ "resolved": "8.0.29",
+ "contentHash": "HSabrcvae+5j1CYW4MJa8CC9Wd4ZX1l9ImldPjiDeTmcdgfi6XvEuWFLncj/xB2FiVuOVlMAssMWiICPxZnEJA=="
+ },
"Microsoft.Extensions.Hosting.Abstractions": {
"type": "CentralTransitive",
"requested": "[8.0.1, )",
diff --git a/tests/Healthie.Tests.Unit/AlertHistoryDurabilityTests.cs b/tests/Healthie.Tests.Unit/AlertHistoryDurabilityTests.cs
new file mode 100644
index 0000000..981e278
--- /dev/null
+++ b/tests/Healthie.Tests.Unit/AlertHistoryDurabilityTests.cs
@@ -0,0 +1,153 @@
+using Healthie.Abstractions.Enums;
+using Healthie.Abstractions.StateProviding;
+using Healthie.Alerting;
+using Healthie.StateProviding.Redis;
+using Healthie.StateProviding.Relational;
+using Npgsql;
+using System.Data.Common;
+using StackExchange.Redis;
+
+namespace Healthie.Tests.Unit;
+
+///
+/// The alert history against real durable providers.
+///
+///
+///
+/// The claim being tested is the one on the box: a deployment on a durable state provider keeps its
+/// alerts across a restart. Everything else about the history is covered against the in-memory
+/// provider, which proves the logic and nothing about serialization -- and this is the one entry a
+/// state provider holds that is not a PulseCheckerState, stored under a key that is not a
+/// checker's name. Either could have been an assumption a provider quietly relied on.
+///
+///
+/// Skipped rather than failed where there is no container runtime, like every other real-provider
+/// test here.
+///
+///
+public abstract class AlertHistoryDurabilityTests
+{
+ private static CancellationToken Ct => TestContext.Current.CancellationToken;
+
+ protected abstract string? FixtureUnavailable { get; }
+
+ /// A provider over the real store, keyed so each test method gets its own space.
+ protected abstract Task ProviderAsync();
+
+ ///
+ /// Two histories over one provider: the second is the same application after a redeploy.
+ ///
+ [Fact]
+ public async Task AnAlertLog_SurvivesTheProcessThatWroteIt()
+ {
+ Assert.SkipWhen(FixtureUnavailable is not null, $"No container runtime. {FixtureUnavailable}");
+
+ var store = await ProviderAsync();
+
+ var before = new AlertHistory(capacity: 10, store);
+ before.Record(AlertFor("checker-1", PulseCheckerHealth.Unhealthy), delivered: true);
+ before.Record(AlertFor("checker-2", PulseCheckerHealth.Suspicious), delivered: false);
+
+ // The write is deliberately off the delivery path, so it lands shortly after Record returns.
+ var deadline = DateTime.UtcNow.AddSeconds(15);
+ var after = await new AlertHistory(10, store).GetAlertsAsync(0, 10, Ct);
+
+ while (after.Total < 2 && DateTime.UtcNow < deadline)
+ {
+ await Task.Delay(100, Ct);
+ after = await new AlertHistory(10, store).GetAlertsAsync(0, 10, Ct);
+ }
+
+ Assert.Equal(2, after.Total);
+
+ // Newest first, and every field survived the round trip -- including the nullable enum and
+ // the delivery flag, which are the two most likely to come back wrong through a serializer.
+ Assert.Equal("checker-2", after.Alerts[0].CheckerName);
+ Assert.Equal(PulseCheckerHealth.Suspicious, after.Alerts[0].CurrentHealth);
+ Assert.Equal(PulseCheckerHealth.Healthy, after.Alerts[0].PreviousHealth);
+ Assert.False(after.Alerts[0].Delivered);
+
+ Assert.Equal("checker-1", after.Alerts[1].CheckerName);
+ Assert.Equal(PulseCheckerHealth.Unhealthy, after.Alerts[1].CurrentHealth);
+ Assert.True(after.Alerts[1].Delivered);
+ }
+
+ ///
+ /// The log lives beside checker state in the same store, so it must not be mistaken for a
+ /// checker: a dashboard reading every state would otherwise list it as one.
+ ///
+ [Fact]
+ public async Task TheAlertLog_DoesNotReadBackAsACheckerState()
+ {
+ Assert.SkipWhen(FixtureUnavailable is not null, $"No container runtime. {FixtureUnavailable}");
+
+ var store = await ProviderAsync();
+ var history = new AlertHistory(capacity: 4, store);
+
+ history.Record(AlertFor("checker-1", PulseCheckerHealth.Unhealthy), delivered: true);
+
+ var deadline = DateTime.UtcNow.AddSeconds(15);
+
+ while ((await new AlertHistory(4, store).GetAlertsAsync(0, 10, Ct)).Total == 0
+ && DateTime.UtcNow < deadline)
+ {
+ await Task.Delay(100, Ct);
+ }
+
+ // Reading it as the wrong type must fail or come back empty -- never as a checker with a
+ // plausible-looking default state.
+ try
+ {
+ var asChecker = await store.GetStateAsync(
+ "healthie.alerts.log", Ct);
+
+ Assert.Null(asChecker?.LastResult);
+ }
+ catch (Exception ex) when (ex is not Xunit.Sdk.XunitException)
+ {
+ // Refusing outright is the better answer of the two, and some providers do.
+ }
+ }
+
+ private static Alert AlertFor(string name, PulseCheckerHealth health) => new(
+ name,
+ name,
+ Group: "durability",
+ Tags: ["persisted"],
+ PulseCheckerHealth.Healthy,
+ health,
+ "stored and read back",
+ DateTime.UtcNow);
+}
+
+/// The alert log against a real Redis.
+public sealed class RedisAlertHistoryTests(RedisFixture fixture)
+ : AlertHistoryDurabilityTests, IClassFixture
+{
+ protected override string? FixtureUnavailable => fixture.Unavailable;
+
+ protected override async Task ProviderAsync()
+ {
+ var connection = await ConnectionMultiplexer.ConnectAsync(fixture.ConnectionString);
+
+ return new RedisStateProvider(connection, $"{TestContext.Current.TestMethod?.MethodName}:");
+ }
+}
+
+/// The alert log against a real PostgreSQL.
+public sealed class PostgresAlertHistoryTests(PostgresFixture fixture)
+ : AlertHistoryDurabilityTests, IClassFixture
+{
+ protected override string? FixtureUnavailable => fixture.Unavailable;
+
+ protected override async Task ProviderAsync()
+ {
+ var table = $"alerts_{Guid.NewGuid():N}";
+ DbConnection Connect() => new NpgsqlConnection(fixture.ConnectionString);
+
+ await new RelationalStateProviderInitializer(Connect, RelationalDialect.PostgreSql, table)
+ .InitializeAsync(TestContext.Current.CancellationToken);
+
+ return new RelationalStateProvider(Connect, RelationalDialect.PostgreSql, table);
+ }
+}
diff --git a/tests/Healthie.Tests.Unit/InsightsTests.cs b/tests/Healthie.Tests.Unit/InsightsTests.cs
index b7f3676..321d460 100644
--- a/tests/Healthie.Tests.Unit/InsightsTests.cs
+++ b/tests/Healthie.Tests.Unit/InsightsTests.cs
@@ -213,6 +213,60 @@ public async Task AlertHistory_OnADurableProvider_SurvivesTheProcessThatWroteIt(
Assert.False(after.Alerts[0].Delivered);
}
+ ///
+ /// A store slow enough to make the ordering of concurrent writes visible.
+ ///
+ ///
+ /// The first write is held up longer than the second, which is what a real database does under
+ /// load and what a fast local disk does not. Without serialised persistence the older, shorter
+ /// snapshot lands last and the newer alert is silently lost.
+ ///
+ private sealed class SlowFirstWriteProvider : IStateProvider
+ {
+ private const int SlowWriteMs = 250;
+
+ /// Comfortably past both writes, so the assert sees the state they settled on.
+ public static readonly TimeSpan SettleFor = TimeSpan.FromMilliseconds(SlowWriteMs * 4);
+
+ private readonly InMemoryStateProvider _inner = new();
+ private int _writes;
+
+ public async Task SetStateAsync(string name, T state, CancellationToken cancellationToken = default)
+ {
+ var delay = Interlocked.Increment(ref _writes) == 1 ? SlowWriteMs : 10;
+ await Task.Delay(delay, cancellationToken);
+
+ await _inner.SetStateAsync(name, state, cancellationToken);
+ }
+
+ public Task GetStateAsync(string name, CancellationToken cancellationToken = default) =>
+ _inner.GetStateAsync(name, cancellationToken);
+ }
+
+ ///
+ /// Two alerts raised back to back must both survive. Nothing orders the writes they trigger, so
+ /// a snapshot captured at call time could be written after a newer one and undo it.
+ ///
+ [Fact]
+ public async Task AlertHistory_WhenTwoAlertsRaceToPersist_NeitherIsLost()
+ {
+ var store = new SlowFirstWriteProvider();
+ var history = new AlertHistory(capacity: 10, store);
+
+ history.Record(Alert("first"), delivered: true);
+ history.Record(Alert("second"), delivered: true);
+
+ // A fixed settle, deliberately not a poll for the answer. Polling until the store holds two
+ // passes the moment the fast write lands and asserts before the slow one overwrites it --
+ // which is the very failure under test, observed and then looked away from.
+ await Task.Delay(SlowFirstWriteProvider.SettleFor, Ct);
+
+ var reloaded = await new AlertHistory(capacity: 10, store).GetAlertsAsync(0, 10, Ct);
+
+ Assert.Equal(2, reloaded.Total);
+ Assert.Equal(["second", "first"], reloaded.Alerts.Select(alert => alert.CheckerName));
+ }
+
///
/// A page is a window on the whole history, and the total it reports is what a pager counts
/// against -- not the size of the page it happens to be looking at.
diff --git a/tests/Healthie.Tests.Unit/MetricsInsightsTests.cs b/tests/Healthie.Tests.Unit/MetricsInsightsTests.cs
new file mode 100644
index 0000000..20ede61
--- /dev/null
+++ b/tests/Healthie.Tests.Unit/MetricsInsightsTests.cs
@@ -0,0 +1,178 @@
+using Healthie.Abstractions;
+using Healthie.Abstractions.Enums;
+using Healthie.Abstractions.Insights;
+using Healthie.Abstractions.Models;
+using Healthie.Abstractions.StateProviding;
+using Healthie.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Healthie.Tests.Unit;
+
+///
+/// The in-process metrics collector.
+///
+///
+/// It reads instruments that are internal to another assembly, by meter name, through a
+/// . Nothing about that is checked by the
+/// compiler: rename an instrument or change a tag and this keeps building and silently counts
+/// nothing. These run real checks and assert the numbers moved.
+///
+public class MetricsInsightsTests
+{
+ private static CancellationToken Ct => TestContext.Current.CancellationToken;
+
+ ///
+ /// A real checker, so the instruments it reports through are the real ones.
+ ///
+ ///
+ /// Takes only a state provider, and carries what it reports as settable properties. Assembly
+ /// scanning registers every concrete in this assembly, so a
+ /// constructor the container cannot satisfy fails every registration, MCP and AI test in the
+ /// suite rather than only this file's -- which is exactly what it did.
+ ///
+ internal sealed class MeteredChecker(IStateProvider stateProvider)
+ : PulseChecker(stateProvider, PulseInterval.EveryMinute)
+ {
+ public PulseCheckerHealth Reports { get; set; } = PulseCheckerHealth.Healthy;
+
+ public string CheckerName { get; set; } = "metered";
+
+ public override string Name => CheckerName;
+
+ public override Task CheckAsync(CancellationToken cancellationToken = default) =>
+ Task.FromResult(new PulseCheckerResult(Reports, "measured"));
+ }
+
+ private static MeteredChecker Checker(IStateProvider provider, PulseCheckerHealth reports, string name) =>
+ new(provider) { Reports = reports, CheckerName = name };
+
+ [Fact]
+ public void AddHealthieMetrics_RegistersTheCollector_AndNothingElseDoes()
+ {
+ var services = new ServiceCollection();
+ services.AddHealthie(typeof(MetricsInsightsTests).Assembly);
+
+ using (var without = services.BuildServiceProvider())
+ {
+ Assert.Null(without.GetService());
+ }
+
+ services.AddHealthieMetrics();
+
+ using var with = services.BuildServiceProvider();
+
+ Assert.NotNull(with.GetService());
+ }
+
+ ///
+ /// The listener is the whole feature: if it is not attached to the right meter, or the
+ /// instrument names drift, everything below reads zero and the board shows an empty panel.
+ ///
+ ///
+ /// Asserted as deltas with a floor, not as exact totals. A MeterListener hears every
+ /// measurement in the process -- that is the feature -- so any other test running a check at the
+ /// same time lands in these counters too. Exact numbers passed alone and failed in the full
+ /// suite, which is the worst way for a test to be wrong.
+ ///
+ [Fact]
+ public async Task RunningChecks_MovesTheCounters()
+ {
+ using var metrics = new MeterMetricsInsights();
+ var before = metrics.Snapshot(Ct);
+
+ var provider = new InMemoryStateProvider();
+ using var healthy = Checker(provider, PulseCheckerHealth.Healthy, "metrics-healthy");
+ using var failing = Checker(provider, PulseCheckerHealth.Unhealthy, "metrics-failing");
+
+ await healthy.TriggerAsync(Ct);
+ await healthy.TriggerAsync(Ct);
+ await failing.TriggerAsync(Ct);
+
+ var after = metrics.Snapshot(Ct);
+
+ Assert.True(after.Checks - before.Checks >= 3, $"checks moved by {after.Checks - before.Checks}");
+ Assert.True(Counted(after, PulseCheckerHealth.Healthy) - Counted(before, PulseCheckerHealth.Healthy) >= 2);
+ Assert.True(Counted(after, PulseCheckerHealth.Unhealthy) - Counted(before, PulseCheckerHealth.Unhealthy) >= 1);
+
+ // Two checkers went from nothing to a health, so both transitioned.
+ Assert.True(after.Transitions - before.Transitions >= 2);
+
+ Assert.NotNull(after.MeanDuration);
+ Assert.NotNull(after.SlowestDuration);
+ Assert.True(after.SlowestDuration >= after.MeanDuration);
+ }
+
+ private static long Counted(MetricsSnapshot snapshot, PulseCheckerHealth health) =>
+ snapshot.ResultsByHealth.GetValueOrDefault(health);
+
+ ///
+ /// The healthy share is of the checks run, and is nothing at all before any have.
+ ///
+ ///
+ /// Against a snapshot built directly rather than one collected from the meter: the arithmetic is
+ /// what is under test, and reading it off a live collector would only re-measure whatever else
+ /// the suite happened to be running.
+ ///
+ [Theory]
+ [InlineData(0, 0, null)]
+ [InlineData(3, 2, 66.67)]
+ [InlineData(4, 4, 100.0)]
+ [InlineData(5, 0, 0.0)]
+ public void TheHealthyShare_IsOfChecksRun_AndIsNothingBeforeAnyHaveRun(long checks, long healthy, double? expected)
+ {
+ var snapshot = new MetricsSnapshot(
+ checks,
+ new Dictionary { [PulseCheckerHealth.Healthy] = healthy },
+ Transitions: 0,
+ OverlappedTriggers: 0,
+ MeanDuration: null,
+ SlowestDuration: null,
+ Since: DateTime.UtcNow);
+
+ if (expected is null)
+ {
+ Assert.Null(snapshot.HealthyShare);
+ }
+ else
+ {
+ Assert.Equal(expected.Value, snapshot.HealthyShare!.Value, 1);
+ }
+ }
+
+ ///
+ /// Overlaps are the figure that means something is wrong rather than something happened, and
+ /// nothing else on the board reports them.
+ ///
+ [Fact]
+ public void WithNothingOverlapping_HasOverlapsIsFalse()
+ {
+ using var metrics = new MeterMetricsInsights();
+ var snapshot = metrics.Snapshot(Ct);
+
+ Assert.Equal(0, snapshot.OverlappedTriggers);
+ Assert.False(snapshot.HasOverlaps);
+ }
+
+ ///
+ /// Disposal has to detach the listener. One left attached goes on receiving measurements from
+ /// every checker in the process for the rest of its life.
+ ///
+ [Fact]
+ public async Task OnceDisposed_ItStopsCounting()
+ {
+ var metrics = new MeterMetricsInsights();
+
+ var provider = new InMemoryStateProvider();
+ using var checker = Checker(provider, PulseCheckerHealth.Healthy, "disposal-target");
+
+ await checker.TriggerAsync(Ct);
+ var before = metrics.Snapshot(Ct).Checks;
+ Assert.True(before > 0);
+
+ metrics.Dispose();
+
+ await checker.TriggerAsync(Ct);
+
+ Assert.Equal(before, metrics.Snapshot(Ct).Checks);
+ }
+}
diff --git a/tests/Healthie.Tests.Unit/PackageLayoutTests.cs b/tests/Healthie.Tests.Unit/PackageLayoutTests.cs
new file mode 100644
index 0000000..21620ba
--- /dev/null
+++ b/tests/Healthie.Tests.Unit/PackageLayoutTests.cs
@@ -0,0 +1,133 @@
+using Healthie.Abstractions.Insights;
+using Healthie.DependencyInjection;
+using Healthie.LeaderElection;
+using Healthie.Uptime;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Healthie.Tests.Unit;
+
+///
+/// Uptime and leader election moved into the core package, and the packages they used to live in
+/// became type forwards.
+///
+///
+///
+/// The reason this is a test and not a note: moving a type between assemblies is a binary break that
+/// the compiler cannot see. Source that says using Healthie.Uptime; keeps building either
+/// way, and an application compiled against the old assembly only discovers the difference when it
+/// throws TypeLoadException at the first call. Nothing else in the suite would catch a
+/// forward that was dropped or misspelled.
+///
+///
+/// So these assert against assembly-qualified names, the way the runtime resolves them, rather than
+/// against the C# types the compiler has already bound for us.
+///
+///
+public class PackageLayoutTests
+{
+ /// Every public type the two deprecated packages ever exposed.
+ public static TheoryData ForwardedTypes() => new()
+ {
+ { "Healthie.Uptime.IUptimeStore", "Healthie.Uptime" },
+ { "Healthie.Uptime.InMemoryUptimeStore", "Healthie.Uptime" },
+ { "Healthie.Uptime.StartupExtensions", "Healthie.Uptime" },
+ { "Healthie.Uptime.UptimeCalculator", "Healthie.Uptime" },
+ { "Healthie.Uptime.UptimeRecorder", "Healthie.Uptime" },
+ { "Healthie.Uptime.UptimeReport", "Healthie.Uptime" },
+ { "Healthie.Uptime.UptimeSegment", "Healthie.Uptime" },
+ { "Healthie.LeaderElection.ILeaseProvider", "Healthie.LeaderElection" },
+ { "Healthie.LeaderElection.InMemoryLeaseProvider", "Healthie.LeaderElection" },
+ { "Healthie.LeaderElection.LeaderElectedPulseScheduler", "Healthie.LeaderElection" },
+ { "Healthie.LeaderElection.LeaderElectionOptions", "Healthie.LeaderElection" },
+ { "Healthie.LeaderElection.LeaderElectionService", "Healthie.LeaderElection" },
+ { "Healthie.LeaderElection.StartupExtensions", "Healthie.LeaderElection" },
+ };
+
+ ///
+ /// Asking the old assembly for a type it no longer defines must still hand back the type, now
+ /// living in the core assembly. This is what an application compiled against 4.0.0 does.
+ ///
+ [Theory]
+ [MemberData(nameof(ForwardedTypes))]
+ public void ATypeAskedForByItsOldAssembly_ResolvesToTheCoreAssembly(string typeName, string oldAssembly)
+ {
+ var resolved = Type.GetType($"{typeName}, {oldAssembly}", throwOnError: false);
+
+ Assert.True(resolved is not null, $"'{typeName}, {oldAssembly}' did not resolve -- the type forward is missing.");
+ Assert.Equal("Healthie.DependencyInjection", resolved!.Assembly.GetName().Name);
+ }
+
+ ///
+ /// The point of the move: the core package registers both without a second package installed.
+ ///
+ [Fact]
+ public void TheCorePackage_RegistersUptimeAndLeaderElection()
+ {
+ var services = new ServiceCollection();
+ services.AddHealthie(typeof(PackageLayoutTests).Assembly);
+ services.AddHealthieUptime();
+ services.AddHealthieLeaderElection();
+
+ using var provider = services.BuildServiceProvider();
+
+ Assert.NotNull(provider.GetService());
+ Assert.NotNull(provider.GetService());
+ Assert.NotNull(provider.GetService());
+ Assert.NotNull(provider.GetService());
+ }
+
+ ///
+ /// The meta-package must stay a pointer, not a bundle.
+ ///
+ ///
+ /// One stray ProjectReference in Healthie.NET.Package.csproj turns dotnet add package
+ /// Healthie.NET into an install that drags a database driver, a scheduler and a UI framework
+ /// onto machines that asked for none of them. Nothing else would catch that: it builds, it packs,
+ /// and the damage is only visible in the restore graph of whoever installed it.
+ ///
+ [Fact]
+ public void TheMetaPackage_DependsOnTheCorePackageAndNothingElse()
+ {
+ var csproj = FindRepositoryFile("src/Healthie.NET.Package/Healthie.NET.Package.csproj");
+
+ var referenced = System.Text.RegularExpressions.Regex
+ .Matches(File.ReadAllText(csproj), @" match.Groups[1].Value)
+ .ToList();
+
+ Assert.Equal(["Healthie.DependencyInjection"], referenced);
+ }
+
+ /// Walks up from the test binaries to the repository root.
+ private static string FindRepositoryFile(string relativePath)
+ {
+ var directory = new DirectoryInfo(AppContext.BaseDirectory);
+
+ while (directory is not null && !File.Exists(Path.Combine(directory.FullName, relativePath)))
+ {
+ directory = directory.Parent;
+ }
+
+ Assert.True(directory is not null, $"Could not find '{relativePath}' above {AppContext.BaseDirectory}.");
+
+ return Path.Combine(directory!.FullName, relativePath);
+ }
+
+ ///
+ /// Still opt-in. Folding them into core was about how many packages you install, not about
+ /// starting services nobody asked for.
+ ///
+ [Fact]
+ public void TheCorePackage_StartsNeitherOfThemUnlessAsked()
+ {
+ var services = new ServiceCollection();
+ services.AddHealthie(typeof(PackageLayoutTests).Assembly);
+
+ using var provider = services.BuildServiceProvider();
+
+ Assert.Null(provider.GetService());
+ Assert.Null(provider.GetService());
+ Assert.Null(provider.GetService());
+ Assert.Null(provider.GetService());
+ }
+}
diff --git a/tests/Healthie.Tests.Unit/packages.lock.json b/tests/Healthie.Tests.Unit/packages.lock.json
index a353a56..e8fad9b 100644
--- a/tests/Healthie.Tests.Unit/packages.lock.json
+++ b/tests/Healthie.Tests.Unit/packages.lock.json
@@ -597,7 +597,7 @@
"Healthie.NET.LeaderElection": {
"type": "Project",
"dependencies": {
- "Healthie.NET.Abstractions": "[1.0.0, )"
+ "Healthie.NET.DependencyInjection": "[1.0.0, )"
}
},
"Healthie.NET.Mcp": {
@@ -662,7 +662,7 @@
"Healthie.NET.Uptime": {
"type": "Project",
"dependencies": {
- "Healthie.NET.Abstractions": "[1.0.0, )"
+ "Healthie.NET.DependencyInjection": "[1.0.0, )"
}
},
"Coravel": {
@@ -1538,7 +1538,7 @@
"Healthie.NET.LeaderElection": {
"type": "Project",
"dependencies": {
- "Healthie.NET.Abstractions": "[1.0.0, )"
+ "Healthie.NET.DependencyInjection": "[1.0.0, )"
}
},
"Healthie.NET.Mcp": {
@@ -1603,7 +1603,7 @@
"Healthie.NET.Uptime": {
"type": "Project",
"dependencies": {
- "Healthie.NET.Abstractions": "[1.0.0, )"
+ "Healthie.NET.DependencyInjection": "[1.0.0, )"
}
},
"Coravel": {