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 @@ -![Healthie.NET - Trust your uptime](https://raw.githubusercontent.com/ivanvyd/Healthie.NET/main/healthie.net.banner.png) +![Healthie.NET - Trust your uptime](https://raw.githubusercontent.com/ivanvyd/Healthie.NET/main/healthie.net.banner.png) **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": {