diff --git a/CHANGELOG.md b/CHANGELOG.md index ae3cff4..65d0987 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,14 @@ its code behaves exactly as it did. that only cared about a component going down reached into `OldState`/`NewState.LastResult?.Health` and worked it out again. The alerting and uptime packages were doing that identically; both now read `PreviousHealth` and `CurrentHealth` from the event instead of digging for them. +- **Slack, Microsoft Teams and PagerDuty alert sinks**, in `Healthie.NET.Alerting` beside the + webhook. Each of those three rejects arbitrary JSON and wants its own shape, so the generic + webhook never actually reached them without something in between to reshape it -- which its own + remarks admitted, in the phrase "Teams through a Power Automate flow". They need no dependency the + package did not already have, so they are sinks rather than a package each. Teams targets the + Workflows URL and an Adaptive Card, because the Office 365 connectors and the `MessageCard` + payload they took are retired. PagerDuty resolves the incident it opened rather than raising a + second one: `Alert.DeduplicationKey` and `Alert.IsRecovery` already existed for exactly that. - **Schedules.** `PulseSchedule` says either "every this long" or "on this cron expression", and sits alongside `PulseInterval` rather than replacing it. The enum stopped at five minutes, which is short of what a certificate-expiry or disk-space check wants. Cron is standard Unix syntax and diff --git a/README.md b/README.md index c483be8..c657e96 100644 --- a/README.md +++ b/README.md @@ -824,12 +824,11 @@ Upgrading from v1.x? See the [v1 to v2 migration guide](https://github.com/ivanv Shipped since 3.1.4: alerting on transitions, OpenTelemetry metrics and traces, arbitrary intervals and cron, PostgreSQL / SQL Server / SQLite state providers, Hangfire / Coravel / Temporal scheduling, ready-made checkers, uptime reporting, leader election, optimistic concurrency -on `IStateProvider`, and `HealthChanged` on the state-changed event. What is left: +on `IStateProvider`, `HealthChanged` on the state-changed event, and Slack / Teams / +PagerDuty alert sinks. What is left: - **A Redis state provider** -- the fastest option for state written on every tick, and a natural lease store for leader election. -- **Alert sinks beyond the webhook** -- Slack, Teams and PagerDuty as packages rather than as a - documented payload. --- diff --git a/src/Healthie.Alerting/Healthie.Alerting.csproj b/src/Healthie.Alerting/Healthie.Alerting.csproj index 21ad91c..ab88a24 100644 --- a/src/Healthie.Alerting/Healthie.Alerting.csproj +++ b/src/Healthie.Alerting/Healthie.Alerting.csproj @@ -2,8 +2,8 @@ Healthie.NET.Alerting - Alerting for Healthie.NET: turns health changes into alerts and delivers them to a webhook or your own sink, with deduplication and delivery failures isolated from the checks. - $(HealthieCommonTags);alerting;notifications;webhook;incidents + Alerting for Healthie.NET: turns health changes into alerts and delivers them to Slack, Microsoft Teams, PagerDuty, a webhook or your own sink, with deduplication and delivery failures isolated from the checks. + $(HealthieCommonTags);alerting;notifications;webhook;slack;teams;pagerduty;incidents diff --git a/src/Healthie.Alerting/MicrosoftTeamsAlertSink.cs b/src/Healthie.Alerting/MicrosoftTeamsAlertSink.cs new file mode 100644 index 0000000..d221ce7 --- /dev/null +++ b/src/Healthie.Alerting/MicrosoftTeamsAlertSink.cs @@ -0,0 +1,202 @@ +using Healthie.Abstractions.Enums; +using System.Net.Http.Json; +using System.Text.Json.Serialization; + +namespace Healthie.Alerting; + +/// +/// Posts each alert to a Microsoft Teams channel as an Adaptive Card. +/// +/// +/// +/// Targets the Workflows URL, not the old Office 365 connector. Microsoft has retired those +/// connectors and the MessageCard payload they took, so a sink written to that shape would +/// have arrived already dead. A Workflows webhook takes the same envelope the Bot Framework uses: +/// a message whose attachment is an Adaptive Card. +/// +/// +/// This is what the webhook sink meant by "Teams through a Power Automate flow" -- the flow existed +/// to reshape the payload, and shaping it correctly here removes the flow. +/// +/// +public sealed class MicrosoftTeamsAlertSink : IAlertSink +{ + /// The name this sink resolves its under. + public const string HttpClientName = "Healthie.Alerting.MicrosoftTeams"; + + private readonly IHttpClientFactory _clients; + private readonly Uri _webhookUrl; + + /// Initializes a new instance of the class. + /// The factory the request's client comes from. + /// The Workflows URL of the channel to post to. + public MicrosoftTeamsAlertSink(IHttpClientFactory clients, Uri webhookUrl) + { + _clients = clients ?? throw new ArgumentNullException(nameof(clients)); + _webhookUrl = webhookUrl ?? throw new ArgumentNullException(nameof(webhookUrl)); + } + + /// + public async Task SendAsync(Alert alert, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(alert); + + var client = _clients.CreateClient(HttpClientName); + + using var response = await client + .PostAsJsonAsync(_webhookUrl, TeamsMessage.From(alert), cancellationToken) + .ConfigureAwait(false); + + // Throwing is how a sink reports a failed delivery; the dispatcher logs it and carries on. + response.EnsureSuccessStatusCode(); + } +} + +/// +/// The envelope a Teams Workflows webhook takes. +/// +/// Always message. +/// Exactly one, holding the card. +public sealed record TeamsMessage( + [property: JsonPropertyName("type")] string Type, + [property: JsonPropertyName("attachments")] IReadOnlyList Attachments) +{ + /// Builds the message for an alert. + /// The alert to describe. + public static TeamsMessage From(Alert alert) + { + ArgumentNullException.ThrowIfNull(alert); + + return new TeamsMessage( + "message", + [new TeamsAttachment( + "application/vnd.microsoft.card.adaptive", + ContentUrl: null, + AdaptiveCard.From(alert))]); + } +} + +/// +/// One attachment on a Teams message. +/// +/// Always the Adaptive Card content type. +/// Always null; the card is inline. +/// The card. +public sealed record TeamsAttachment( + [property: JsonPropertyName("contentType")] string ContentType, + [property: JsonPropertyName("contentUrl")] string? ContentUrl, + [property: JsonPropertyName("content")] AdaptiveCard Content); + +/// +/// The Adaptive Card a Teams channel renders. +/// +/// Always AdaptiveCard. +/// The Adaptive Card schema URL, which Teams requires. +/// The card schema version. +/// The card's elements. +public sealed record AdaptiveCard( + [property: JsonPropertyName("type")] string Type, + [property: JsonPropertyName("$schema")] string Schema, + [property: JsonPropertyName("version")] string Version, + [property: JsonPropertyName("body")] IReadOnlyList Body) +{ + /// Builds the card for an alert. + /// The alert to describe. + public static AdaptiveCard From(Alert alert) + { + ArgumentNullException.ThrowIfNull(alert); + + var headline = alert.IsRecovery + ? $"{alert.DisplayName} recovered" + : $"{alert.DisplayName} is {alert.CurrentHealth}"; + + var facts = new List + { + new("Checker", alert.CheckerName), + new("Status", alert.CurrentHealth.ToString()), + new("Was", alert.PreviousHealth?.ToString() ?? "never run"), + new("Observed", $"{alert.OccurredAt:yyyy-MM-dd HH:mm:ss} UTC"), + }; + + if (alert.Group is { } group) + { + facts.Add(new AdaptiveFact("Group", group)); + } + + if (alert.Tags.Count > 0) + { + facts.Add(new AdaptiveFact("Tags", string.Join(", ", alert.Tags))); + } + + var body = new List + { + new AdaptiveTextBlock(headline, Weight: "Bolder", Size: "Medium", Color: ColourOf(alert.CurrentHealth), Wrap: true), + new AdaptiveFactSet(facts), + }; + + if (!string.IsNullOrWhiteSpace(alert.Message)) + { + body.Add(new AdaptiveTextBlock(alert.Message, Weight: null, Size: null, Color: null, Wrap: true)); + } + + return new AdaptiveCard("AdaptiveCard", "http://adaptivecards.io/schemas/adaptive-card.json", "1.4", body); + } + + /// + /// Adaptive Cards name their colours rather than taking hex. + /// + /// + /// Lower case, because the schema's Colors enum is + /// default|dark|light|accent|good|warning|attention and matching is case-sensitive. A + /// value outside it is not an error: the card renders in the default colour and the POST still + /// returns 2xx, so getting this wrong loses the colour on every alert and reports success. + /// + private static string ColourOf(PulseCheckerHealth health) => health switch + { + PulseCheckerHealth.Unhealthy => "attention", + PulseCheckerHealth.Suspicious => "warning", + PulseCheckerHealth.Healthy => "good", + _ => throw new ArgumentOutOfRangeException(nameof(health), health, "Unknown health."), + }; +} + +/// +/// A line of text on an Adaptive Card. +/// +/// The text. +/// How bold, or null for the default. +/// How large, or null for the default. +/// One of the Adaptive Card colour names, or null for the default. +/// Whether long text wraps rather than being cut off. +public sealed record AdaptiveTextBlock( + [property: JsonPropertyName("text")] string Text, + [property: JsonPropertyName("weight"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Weight, + [property: JsonPropertyName("size"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Size, + [property: JsonPropertyName("color"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Color, + [property: JsonPropertyName("wrap")] bool Wrap) +{ + /// Always TextBlock. + [JsonPropertyName("type")] + public string Type => "TextBlock"; +} + +/// +/// A table of labelled values on an Adaptive Card. +/// +/// The rows. +public sealed record AdaptiveFactSet( + [property: JsonPropertyName("facts")] IReadOnlyList Facts) +{ + /// Always FactSet. + [JsonPropertyName("type")] + public string Type => "FactSet"; +} + +/// +/// One row of an . +/// +/// The label. +/// The value. +public sealed record AdaptiveFact( + [property: JsonPropertyName("title")] string Title, + [property: JsonPropertyName("value")] string Value); diff --git a/src/Healthie.Alerting/PagerDutyAlertSink.cs b/src/Healthie.Alerting/PagerDutyAlertSink.cs new file mode 100644 index 0000000..888deea --- /dev/null +++ b/src/Healthie.Alerting/PagerDutyAlertSink.cs @@ -0,0 +1,146 @@ +using Healthie.Abstractions.Enums; +using System.Net.Http.Json; +using System.Text.Json.Serialization; + +namespace Healthie.Alerting; + +/// +/// Opens and closes PagerDuty incidents through the Events API v2. +/// +/// +/// +/// The one destination a generic webhook cannot reach usefully. PagerDuty does not want a health +/// change posted at it; it wants to be told an incident has started or ended, and which incident, +/// so that a checker flapping between suspicious and unhealthy pages somebody once rather than +/// every time it moves. +/// +/// +/// and already existed for +/// exactly this: the key becomes dedup_key, and a recovery becomes resolve rather +/// than another trigger. +/// +/// +public sealed class PagerDutyAlertSink : IAlertSink +{ + /// The name this sink resolves its under. + public const string HttpClientName = "Healthie.Alerting.PagerDuty"; + + /// Where the Events API takes events. + public static readonly Uri EventsEndpoint = new("https://events.pagerduty.com/v2/enqueue"); + + private readonly IHttpClientFactory _clients; + private readonly string _routingKey; + private readonly Uri _endpoint; + + /// Initializes a new instance of the class. + /// The factory the request's client comes from. + /// The integration key of the PagerDuty service to alert. + /// Where to send events. Defaults to . + public PagerDutyAlertSink(IHttpClientFactory clients, string routingKey, Uri? endpoint = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(routingKey); + + _clients = clients ?? throw new ArgumentNullException(nameof(clients)); + _routingKey = routingKey; + _endpoint = endpoint ?? EventsEndpoint; + } + + /// + public async Task SendAsync(Alert alert, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(alert); + + var client = _clients.CreateClient(HttpClientName); + + using var response = await client + .PostAsJsonAsync(_endpoint, PagerDutyEvent.From(alert, _routingKey), cancellationToken) + .ConfigureAwait(false); + + // Throwing is how a sink reports a failed delivery; the dispatcher logs it and carries on. + response.EnsureSuccessStatusCode(); + } +} + +/// +/// One PagerDuty Events API v2 event. +/// +/// The integration key of the service to alert. +/// Either trigger or resolve. +/// Identifies the incident, so repeats update it instead of opening another. +/// What the incident says. Omitted on a resolve, which PagerDuty allows. +public sealed record PagerDutyEvent( + [property: JsonPropertyName("routing_key")] string RoutingKey, + [property: JsonPropertyName("event_action")] string EventAction, + [property: JsonPropertyName("dedup_key")] string DedupKey, + [property: JsonPropertyName("payload"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] PagerDutyEventPayload? Payload) +{ + /// Builds the event for an alert. + /// The alert to describe. + /// The integration key of the service to alert. + public static PagerDutyEvent From(Alert alert, string routingKey) + { + ArgumentNullException.ThrowIfNull(alert); + + // A recovery closes the incident the failure opened, which is the whole reason the + // deduplication key excludes the health and the time. + return alert.IsRecovery + ? new PagerDutyEvent(routingKey, "resolve", alert.DeduplicationKey, Payload: null) + : new PagerDutyEvent( + routingKey, + "trigger", + alert.DeduplicationKey, + PagerDutyEventPayload.From(alert)); + } +} + +/// +/// What a triggered PagerDuty incident says. +/// +/// The one line shown in the incident list and the page. +/// PagerDuty's own scale: critical, warning or info. +/// Where the problem is, which PagerDuty groups and searches by. +/// The component within that source. +/// The checker's group, or null. +/// Everything else, shown on the incident. +public sealed record PagerDutyEventPayload( + [property: JsonPropertyName("summary")] string Summary, + [property: JsonPropertyName("severity")] string Severity, + [property: JsonPropertyName("source")] string Source, + [property: JsonPropertyName("component")] string Component, + [property: JsonPropertyName("group"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Group, + [property: JsonPropertyName("custom_details")] IReadOnlyDictionary CustomDetails) +{ + /// Builds the payload for an alert. + /// The alert to describe. + public static PagerDutyEventPayload From(Alert alert) + { + ArgumentNullException.ThrowIfNull(alert); + + return new PagerDutyEventPayload( + $"{alert.DisplayName} is {alert.CurrentHealth}", + SeverityOf(alert.CurrentHealth), + alert.CheckerName, + alert.DisplayName, + alert.Group, + new Dictionary(StringComparer.Ordinal) + { + ["message"] = alert.Message, + ["previous_health"] = alert.PreviousHealth?.ToString() ?? "none", + ["tags"] = string.Join(", ", alert.Tags), + ["observed_at_utc"] = alert.OccurredAt.ToString("O"), + }); + } + + /// Maps a health onto PagerDuty's severity scale. + /// + /// Suspicious is a warning rather than critical on purpose: it is the state that says "failing, + /// but not past the threshold yet", and paging on it would defeat having a threshold. + /// + private static string SeverityOf(PulseCheckerHealth health) => health switch + { + PulseCheckerHealth.Unhealthy => "critical", + PulseCheckerHealth.Suspicious => "warning", + PulseCheckerHealth.Healthy => "info", + _ => throw new ArgumentOutOfRangeException(nameof(health), health, "Unknown health."), + }; +} diff --git a/src/Healthie.Alerting/README.md b/src/Healthie.Alerting/README.md index 992953b..97a90ed 100644 --- a/src/Healthie.Alerting/README.md +++ b/src/Healthie.Alerting/README.md @@ -22,10 +22,28 @@ using Healthie.Alerting; builder.Services .AddHealthie(typeof(Program).Assembly) .AddHealthieAlerts() - .AddHealthieWebhookAlerts(new Uri("https://hooks.example.com/healthie")); + .AddHealthieSlackAlerts(new Uri("https://hooks.slack.com/services/...")) + .AddHealthiePagerDutyAlerts("your-integration-key"); ``` -Add as many webhooks as you like; each gets every alert. For anywhere a webhook cannot reach, implement `IAlertSink` and register it — the dispatcher finds every one that is registered. +Add as many sinks as you like; each gets every alert. For anywhere none of them reaches, implement `IAlertSink` and register it — the dispatcher finds every one that is registered. + +## Where alerts go + +| Sink | Registered with | Sends | +|---|---|---| +| Slack | `AddHealthieSlackAlerts(url)` | A message with a colour-coded attachment, to an incoming webhook | +| Microsoft Teams | `AddHealthieMicrosoftTeamsAlerts(url)` | An Adaptive Card, to a **Workflows** URL | +| PagerDuty | `AddHealthiePagerDutyAlerts(key)` | An Events API v2 `trigger`, resolved when the checker recovers | +| Anything else | `AddHealthieWebhookAlerts(url)` | The generic JSON payload below | + +Three of these are not the webhook wearing a hat. Slack, Teams and PagerDuty each reject arbitrary JSON, so posting the generic payload at them fails — which is why reaching them used to need something in between to reshape it. None needs a dependency this package did not already have, so they are here rather than in a package each. + +**Teams takes the Workflows URL, not an Office 365 connector.** Microsoft has retired those connectors along with the `MessageCard` payload they accepted; in Teams, add a *Workflow* to the channel and use the URL it gives you. + +**PagerDuty closes what it opens.** A failure raises an incident keyed on the checker, and the recovery resolves that same incident rather than posting a second message saying everything is fine. A checker flapping between suspicious and unhealthy stays one incident, because the deduplication key deliberately leaves out the health and the time. `Suspicious` maps to `warning` rather than `critical` — it is the state that means "failing, but not past the threshold yet", and paging on it would defeat having a threshold. + +Configure any sink's `HttpClient` — its timeout, its handler, an auth header — by naming its `HttpClientName` constant in your own `AddHttpClient` call. ## What alerts diff --git a/src/Healthie.Alerting/SlackAlertSink.cs b/src/Healthie.Alerting/SlackAlertSink.cs new file mode 100644 index 0000000..f4a6b53 --- /dev/null +++ b/src/Healthie.Alerting/SlackAlertSink.cs @@ -0,0 +1,152 @@ +using Healthie.Abstractions.Enums; +using System.Net.Http.Json; +using System.Text.Json.Serialization; + +namespace Healthie.Alerting; + +/// +/// Posts each alert to a Slack incoming webhook. +/// +/// +/// A Slack webhook does not accept arbitrary JSON: it wants text, and optionally attachments +/// or blocks. Posting the generic payload at one produces invalid_payload, which is why +/// reaching Slack used to need something in between to reshape it. +/// +public sealed class SlackAlertSink : IAlertSink +{ + /// The name this sink resolves its under. + public const string HttpClientName = "Healthie.Alerting.Slack"; + + private readonly IHttpClientFactory _clients; + private readonly Uri _webhookUrl; + + /// Initializes a new instance of the class. + /// The factory the request's client comes from. + /// The incoming-webhook URL of the channel to post to. + public SlackAlertSink(IHttpClientFactory clients, Uri webhookUrl) + { + _clients = clients ?? throw new ArgumentNullException(nameof(clients)); + _webhookUrl = webhookUrl ?? throw new ArgumentNullException(nameof(webhookUrl)); + } + + /// + public async Task SendAsync(Alert alert, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(alert); + + var client = _clients.CreateClient(HttpClientName); + + using var response = await client + .PostAsJsonAsync(_webhookUrl, SlackMessage.From(alert), cancellationToken) + .ConfigureAwait(false); + + // Throwing is how a sink reports a failed delivery; the dispatcher logs it and carries on. + response.EnsureSuccessStatusCode(); + } +} + +/// +/// The JSON a posts. +/// +/// The notification line, which is what a push notification shows. +/// The coloured detail block. +public sealed record SlackMessage( + [property: JsonPropertyName("text")] string Text, + [property: JsonPropertyName("attachments")] IReadOnlyList Attachments) +{ + /// Builds the message for an alert. + /// The alert to describe. + public static SlackMessage From(Alert alert) + { + ArgumentNullException.ThrowIfNull(alert); + + // The display name is a consumer's own string, and DisplayName is virtual -- "Auth & Session" + // is an ordinary thing to call a checker. + var name = Escape(alert.DisplayName); + + var headline = alert.IsRecovery + ? $"{name} recovered" + : $"{name} is {alert.CurrentHealth}"; + + var fields = new List + { + new("Status", alert.CurrentHealth.ToString(), Short: true), + new("Was", alert.PreviousHealth?.ToString() ?? "never run", Short: true), + }; + + if (alert.Group is { } group) + { + fields.Add(new SlackField("Group", group, Short: true)); + } + + if (alert.Tags.Count > 0) + { + fields.Add(new SlackField("Tags", string.Join(", ", alert.Tags), Short: true)); + } + + if (!string.IsNullOrWhiteSpace(alert.Message)) + { + fields.Add(new SlackField("Message", alert.Message, Short: false)); + } + + return new SlackMessage( + headline, + [new SlackAttachment(ColourOf(alert.CurrentHealth), alert.CheckerName, fields, ToUnixSeconds(alert.OccurredAt))]); + } + + /// + /// Escapes the three characters Slack reads as markup. + /// + /// + /// Only the top-level text needs this: Slack parses it as mrkdwn by default, so an + /// unescaped < starts what it takes for a link or a mention. The attachment's fields + /// are plain text unless mrkdwn_in asks otherwise, and it does not. + /// + private static string Escape(string text) => text + .Replace("&", "&", StringComparison.Ordinal) + .Replace("<", "<", StringComparison.Ordinal) + .Replace(">", ">", StringComparison.Ordinal); + + /// Slack's own status colours, which it renders as the bar down the attachment. + private static string ColourOf(PulseCheckerHealth health) => health switch + { + PulseCheckerHealth.Unhealthy => "danger", + PulseCheckerHealth.Suspicious => "warning", + PulseCheckerHealth.Healthy => "good", + _ => throw new ArgumentOutOfRangeException(nameof(health), health, "Unknown health."), + }; + + /// + /// Slack timestamps events in Unix seconds. + /// + /// + /// Read as UTC rather than local: an alert's time is recorded in UTC, and a DateTime carrying + /// Unspecified would otherwise be shifted by the server's offset on the way out. + /// + private static long ToUnixSeconds(DateTime occurredAt) => + new DateTimeOffset(DateTime.SpecifyKind(occurredAt, DateTimeKind.Utc)).ToUnixTimeSeconds(); +} + +/// +/// One coloured block under a Slack message. +/// +/// Slack's good, warning or danger. +/// Shown small under the fields; the checker's full name goes here. +/// The labelled values. +/// When it happened, in Unix seconds. +public sealed record SlackAttachment( + [property: JsonPropertyName("color")] string Color, + [property: JsonPropertyName("footer")] string Footer, + [property: JsonPropertyName("fields")] IReadOnlyList Fields, + [property: JsonPropertyName("ts")] long Timestamp); + +/// +/// One labelled value in a Slack attachment. +/// +/// The label. +/// The value. +/// Whether Slack may put it beside another rather than on its own line. +public sealed record SlackField( + [property: JsonPropertyName("title")] string Title, + [property: JsonPropertyName("value")] string Value, + [property: JsonPropertyName("short")] bool Short); diff --git a/src/Healthie.Alerting/StartupExtensions.cs b/src/Healthie.Alerting/StartupExtensions.cs index 4268796..f0e7aec 100644 --- a/src/Healthie.Alerting/StartupExtensions.cs +++ b/src/Healthie.Alerting/StartupExtensions.cs @@ -16,7 +16,9 @@ public static class StartupExtensions /// Optionally adjusts which changes alert, and how hard to try. /// The service collection for fluent chaining. /// - /// Add at least one sink -- , or your own + /// Add at least one sink -- , + /// , , + /// , or your own /// . With none, the dispatcher says so once at startup and does nothing, /// rather than queueing alerts nobody will read. /// @@ -56,4 +58,76 @@ public static IServiceCollection AddHealthieWebhookAlerts(this IServiceCollectio return services.AddSingleton(provider => new WebhookAlertSink(provider.GetRequiredService(), url)); } + + /// + /// Posts every alert to a Slack channel. + /// + /// The service collection to register with. + /// The incoming-webhook URL of the channel to post to. + /// The service collection for fluent chaining. + /// + /// One more sink rather than a replacement, so this can sit alongside a webhook and a pager and + /// each gets every alert. Configure the client by naming + /// in your own AddHttpClient call. + /// + public static IServiceCollection AddHealthieSlackAlerts(this IServiceCollection services, Uri webhookUrl) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(webhookUrl); + + services.AddHttpClient(); + + return services.AddSingleton(provider => + new SlackAlertSink(provider.GetRequiredService(), webhookUrl)); + } + + /// + /// Posts every alert to a Microsoft Teams channel as an Adaptive Card. + /// + /// The service collection to register with. + /// The Workflows URL of the channel to post to. + /// The service collection for fluent chaining. + /// + /// The URL is the one a Teams Workflow gives you, not an Office 365 connector -- those + /// are retired, along with the payload they took. Configure the client by naming + /// in your own AddHttpClient call. + /// + public static IServiceCollection AddHealthieMicrosoftTeamsAlerts(this IServiceCollection services, Uri webhookUrl) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(webhookUrl); + + services.AddHttpClient(); + + return services.AddSingleton(provider => + new MicrosoftTeamsAlertSink(provider.GetRequiredService(), webhookUrl)); + } + + /// + /// Opens and closes PagerDuty incidents as checkers fail and recover. + /// + /// The service collection to register with. + /// The integration key of the PagerDuty service to alert. + /// + /// Where to send events. Defaults to ; override it + /// for the EU service region. + /// + /// The service collection for fluent chaining. + /// + /// Unlike the chat sinks, this one closes what it opened: a recovery resolves the incident the + /// failure raised rather than posting a second message saying everything is fine. + /// + public static IServiceCollection AddHealthiePagerDutyAlerts( + this IServiceCollection services, + string routingKey, + Uri? endpoint = null) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentException.ThrowIfNullOrWhiteSpace(routingKey); + + services.AddHttpClient(); + + return services.AddSingleton(provider => + new PagerDutyAlertSink(provider.GetRequiredService(), routingKey, endpoint)); + } } diff --git a/src/Healthie.Alerting/WebhookAlertSink.cs b/src/Healthie.Alerting/WebhookAlertSink.cs index b60eceb..f53774d 100644 --- a/src/Healthie.Alerting/WebhookAlertSink.cs +++ b/src/Healthie.Alerting/WebhookAlertSink.cs @@ -6,10 +6,15 @@ namespace Healthie.Alerting; /// Posts each alert as JSON to a URL. /// /// -/// The one sink worth shipping, because it is the one that reaches everything else. Slack, Teams -/// through a Power Automate flow, Discord, PagerDuty's Events API, an internal service and a -/// no-code automation tool all accept an HTTP POST, so a webhook covers them with a payload shape -/// documented once rather than a package each. +/// The sink for anything that accepts a POST and does not care what shape it is: an internal +/// service, a no-code automation tool, a queue. It is deliberately the generic one -- the payload is +/// documented once and never reshaped per destination. +/// +/// It does not reach Slack, Teams or PagerDuty. Each of those rejects arbitrary JSON and wants its +/// own shape, which is what the sinks beside this one are: , +/// and . They need no +/// dependency this package did not already have, so they are here rather than in a package each. +/// /// public sealed class WebhookAlertSink : IAlertSink { diff --git a/src/Healthie.Scheduling.Coravel/CoravelPulseScheduler.cs b/src/Healthie.Scheduling.Coravel/CoravelPulseScheduler.cs index 020e8b3..7c234df 100644 --- a/src/Healthie.Scheduling.Coravel/CoravelPulseScheduler.cs +++ b/src/Healthie.Scheduling.Coravel/CoravelPulseScheduler.cs @@ -25,8 +25,21 @@ namespace Healthie.Scheduling.Coravel; /// same thing without the dependency. /// /// -public sealed class CoravelPulseScheduler(ILogger? logger = null) : IPulseScheduler +public sealed class CoravelPulseScheduler( + ILogger? logger = null, + TimeProvider? timeProvider = null) : IPulseScheduler { + /// + /// Where "now" comes from. + /// + /// + /// Optional, so adding it does not change how this is constructed, and + /// is the wall clock this always used. A test can supply its + /// own instead of sleeping: due times are the whole of this class's behaviour, and a test that + /// waits for real milliseconds to pass is testing the machine's load as much as the scheduler. + /// + private readonly TimeProvider _time = timeProvider ?? TimeProvider.System; + private sealed record Entry(IPulseChecker Checker, PulseSchedule Schedule, CronExpression? Cron, DateTime DueAt); private readonly ConcurrentDictionary _scheduled = new(StringComparer.Ordinal); @@ -52,7 +65,7 @@ public Task ScheduleAsync( // is already running on a good one. var cron = schedule.IsCron ? ParseCron(schedule.CronExpression!, checker.Name) : null; - var due = NextDueAt(schedule, cron, DateTime.UtcNow); + var due = NextDueAt(schedule, cron, _time.GetUtcNow().UtcDateTime); if (due is null) { @@ -89,7 +102,7 @@ public Task UnscheduleAsync(IPulseChecker checker, CancellationToken cancellatio /// internal async Task TickAsync(CancellationToken cancellationToken = default) { - var now = DateTime.UtcNow; + var now = _time.GetUtcNow().UtcDateTime; foreach (var entry in _scheduled.Values) { diff --git a/tests/Healthie.Tests.Unit/AlertSinkTests.cs b/tests/Healthie.Tests.Unit/AlertSinkTests.cs new file mode 100644 index 0000000..17b22ab --- /dev/null +++ b/tests/Healthie.Tests.Unit/AlertSinkTests.cs @@ -0,0 +1,362 @@ +using Healthie.Abstractions.Enums; +using Healthie.Alerting; +using System.Net; +using System.Text.Json; + +namespace Healthie.Tests.Unit; + +/// +/// What each sink actually puts on the wire. +/// +/// +/// Asserted against the request body rather than against the payload records, because the thing that +/// breaks is the shape a service receives: Slack, Teams and PagerDuty each reject arbitrary JSON, so +/// a field named right in C# and wrong in JSON fails only at the far end, where nothing here would +/// see it. +/// +public class AlertSinkTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private static Alert Failing( + PulseCheckerHealth current = PulseCheckerHealth.Unhealthy, + PulseCheckerHealth? previous = PulseCheckerHealth.Healthy) => + new( + "Acme.Checkers.PaymentsPulseChecker", + "Payments API", + "Tier 1", + ["payments", "external"], + previous, + current, + "502 Bad Gateway", + new DateTime(2026, 7, 29, 14, 30, 0, DateTimeKind.Utc)); + + private static Alert Recovered() => Failing(PulseCheckerHealth.Healthy, PulseCheckerHealth.Unhealthy); + + /// Captures the one request a sink makes, and answers it. + private sealed class CapturingHandler(HttpStatusCode status = HttpStatusCode.OK) : HttpMessageHandler + { + public HttpRequestMessage? Request { get; private set; } + + public string Body { get; private set; } = string.Empty; + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + Request = request; + Body = request.Content is null + ? string.Empty + : await request.Content.ReadAsStringAsync(cancellationToken); + + return new HttpResponseMessage(status); + } + } + + private sealed class SingleClientFactory(HttpMessageHandler handler) : IHttpClientFactory + { + public HttpClient CreateClient(string name) => new(handler, disposeHandler: false); + } + + private static (SingleClientFactory Clients, CapturingHandler Handler) Capture( + HttpStatusCode status = HttpStatusCode.OK) + { + var handler = new CapturingHandler(status); + return (new SingleClientFactory(handler), handler); + } + + private static JsonElement Json(string body) => JsonDocument.Parse(body).RootElement; + + + [Fact] + public async Task Slack_SendsTheShapeAnIncomingWebhookAccepts() + { + var (clients, handler) = Capture(); + var sink = new SlackAlertSink(clients, new Uri("https://hooks.slack.test/services/abc")); + + await sink.SendAsync(Failing(), Ct); + + var root = Json(handler.Body); + + // `text` is what a push notification shows, so it must carry the headline on its own. + Assert.Contains("Payments API", root.GetProperty("text").GetString()!, StringComparison.Ordinal); + + var attachment = root.GetProperty("attachments")[0]; + Assert.Equal("danger", attachment.GetProperty("color").GetString()); + Assert.Equal("Acme.Checkers.PaymentsPulseChecker", attachment.GetProperty("footer").GetString()); + + var fields = attachment.GetProperty("fields").EnumerateArray() + .ToDictionary(f => f.GetProperty("title").GetString()!, f => f.GetProperty("value").GetString()!); + + Assert.Equal("Unhealthy", fields["Status"]); + Assert.Equal("Healthy", fields["Was"]); + Assert.Equal("Tier 1", fields["Group"]); + Assert.Equal("payments, external", fields["Tags"]); + Assert.Equal("502 Bad Gateway", fields["Message"]); + } + + [Theory] + [InlineData(PulseCheckerHealth.Unhealthy, "danger")] + [InlineData(PulseCheckerHealth.Suspicious, "warning")] + [InlineData(PulseCheckerHealth.Healthy, "good")] + public async Task Slack_ColoursTheAttachmentByHealth(PulseCheckerHealth health, string expected) + { + var (clients, handler) = Capture(); + var sink = new SlackAlertSink(clients, new Uri("https://hooks.slack.test/services/abc")); + + await sink.SendAsync(Failing(health, PulseCheckerHealth.Suspicious), Ct); + + Assert.Equal(expected, Json(handler.Body).GetProperty("attachments")[0].GetProperty("color").GetString()); + } + + /// + /// An alert's time is UTC. Read as anything else it is shifted by the server's offset, and the + /// message says a component failed at a time it did not. + /// + /// + /// The time here carries , which is what makes this test + /// mean something: new DateTimeOffset(DateTime) takes its offset from the Kind, so a time + /// already marked Utc converts correctly whether or not the sink says so. Only an unmarked one + /// -- a state round-tripped through a store that does not preserve Kind -- tells the two apart. + /// + [Fact] + public async Task Slack_TimestampsInUtcEvenWhenTheTimeDoesNotSaySo() + { + var (clients, handler) = Capture(); + var sink = new SlackAlertSink(clients, new Uri("https://hooks.slack.test/services/abc")); + + var unmarked = Failing() with + { + OccurredAt = new DateTime(2026, 7, 29, 14, 30, 0, DateTimeKind.Unspecified), + }; + + await sink.SendAsync(unmarked, Ct); + + var expected = new DateTimeOffset(2026, 7, 29, 14, 30, 0, TimeSpan.Zero).ToUnixTimeSeconds(); + + Assert.Equal(expected, Json(handler.Body).GetProperty("attachments")[0].GetProperty("ts").GetInt64()); + } + + /// + /// Slack parses the top-level text as mrkdwn, so an unescaped angle bracket starts what it takes + /// for a link. DisplayName is virtual and "Auth & Session" is an ordinary name to give a checker. + /// + [Fact] + public async Task Slack_EscapesTheCharactersItWouldOtherwiseReadAsMarkup() + { + var (clients, handler) = Capture(); + var sink = new SlackAlertSink(clients, new Uri("https://hooks.slack.test/services/abc")); + + var awkward = Failing() with { DisplayName = "Auth & " }; + + await sink.SendAsync(awkward, Ct); + + var text = Json(handler.Body).GetProperty("text").GetString()!; + + Assert.Contains("Auth & <Session>", text, StringComparison.Ordinal); + Assert.DoesNotContain("", text, StringComparison.Ordinal); + } + + /// + /// A checker with no group must not send "group": null -- every other optional field here + /// is omitted when it is absent, and this one was not. + /// + [Fact] + public async Task PagerDuty_OmitsTheGroupWhenTheCheckerHasNone() + { + var (clients, handler) = Capture(HttpStatusCode.Accepted); + var sink = new PagerDutyAlertSink(clients, "routing-key-1"); + + await sink.SendAsync(Failing() with { Group = null }, Ct); + + Assert.False(Json(handler.Body).GetProperty("payload").TryGetProperty("group", out _)); + } + + + [Fact] + public async Task Teams_SendsAnAdaptiveCardInTheEnvelopeWorkflowsExpects() + { + var (clients, handler) = Capture(); + var sink = new MicrosoftTeamsAlertSink(clients, new Uri("https://prod.westeurope.logic.azure.test/workflows/abc")); + + await sink.SendAsync(Failing(), Ct); + + var root = Json(handler.Body); + Assert.Equal("message", root.GetProperty("type").GetString()); + + var attachment = root.GetProperty("attachments")[0]; + Assert.Equal("application/vnd.microsoft.card.adaptive", attachment.GetProperty("contentType").GetString()); + + var card = attachment.GetProperty("content"); + Assert.Equal("AdaptiveCard", card.GetProperty("type").GetString()); + + // Teams refuses a card with no schema. + Assert.Equal("http://adaptivecards.io/schemas/adaptive-card.json", card.GetProperty("$schema").GetString()); + + var body = card.GetProperty("body").EnumerateArray().ToList(); + Assert.Equal("TextBlock", body[0].GetProperty("type").GetString()); + Assert.Equal("attention", body[0].GetProperty("color").GetString()); + Assert.Contains("Payments API", body[0].GetProperty("text").GetString()!, StringComparison.Ordinal); + + Assert.Equal("FactSet", body[1].GetProperty("type").GetString()); + + var facts = body[1].GetProperty("facts").EnumerateArray() + .ToDictionary(f => f.GetProperty("title").GetString()!, f => f.GetProperty("value").GetString()!); + + Assert.Equal("Unhealthy", facts["Status"]); + Assert.Equal("Healthy", facts["Was"]); + Assert.Contains("UTC", facts["Observed"], StringComparison.Ordinal); + } + + /// + /// A card element carrying a null weight or colour is invalid to Teams, so the optional ones are + /// left out rather than written as null. + /// + [Fact] + public async Task Teams_OmitsOptionalCardPropertiesRatherThanWritingThemNull() + { + var (clients, handler) = Capture(); + var sink = new MicrosoftTeamsAlertSink(clients, new Uri("https://prod.westeurope.logic.azure.test/workflows/abc")); + + await sink.SendAsync(Failing(), Ct); + + var body = Json(handler.Body) + .GetProperty("attachments")[0].GetProperty("content").GetProperty("body") + .EnumerateArray().ToList(); + + // The message block is the plain one: no weight, size or colour of its own. + var message = body.Last(); + Assert.False(message.TryGetProperty("weight", out _)); + Assert.False(message.TryGetProperty("size", out _)); + Assert.False(message.TryGetProperty("color", out _)); + Assert.True(message.GetProperty("wrap").GetBoolean()); + } + + + [Fact] + public async Task PagerDuty_TriggersAnIncidentKeyedOnTheChecker() + { + var (clients, handler) = Capture(HttpStatusCode.Accepted); + var sink = new PagerDutyAlertSink(clients, "routing-key-1"); + + var alert = Failing(); + await sink.SendAsync(alert, Ct); + + var root = Json(handler.Body); + Assert.Equal("routing-key-1", root.GetProperty("routing_key").GetString()); + Assert.Equal("trigger", root.GetProperty("event_action").GetString()); + Assert.Equal(alert.DeduplicationKey, root.GetProperty("dedup_key").GetString()); + + var payload = root.GetProperty("payload"); + Assert.Equal("critical", payload.GetProperty("severity").GetString()); + Assert.Equal("Acme.Checkers.PaymentsPulseChecker", payload.GetProperty("source").GetString()); + Assert.Equal("Payments API", payload.GetProperty("component").GetString()); + Assert.Equal("Tier 1", payload.GetProperty("group").GetString()); + Assert.Equal("502 Bad Gateway", payload.GetProperty("custom_details").GetProperty("message").GetString()); + } + + /// + /// The reason the deduplication key leaves out the health and the time: a recovery has to close + /// the incident the failure opened, not open a second one saying everything is fine. + /// + [Fact] + public async Task PagerDuty_ResolvesTheSameIncidentOnRecovery() + { + var (clients, handler) = Capture(HttpStatusCode.Accepted); + var sink = new PagerDutyAlertSink(clients, "routing-key-1"); + + await sink.SendAsync(Failing(), Ct); + var opened = Json(handler.Body).GetProperty("dedup_key").GetString(); + + await sink.SendAsync(Recovered(), Ct); + var root = Json(handler.Body); + + Assert.Equal("resolve", root.GetProperty("event_action").GetString()); + Assert.Equal(opened, root.GetProperty("dedup_key").GetString()); + + // A resolve carries no payload at all -- not one written as null. + Assert.False(root.TryGetProperty("payload", out _)); + } + + /// + /// Suspicious means "failing, but not past the threshold yet". Paging on it would defeat having + /// a threshold at all. + /// + [Theory] + [InlineData(PulseCheckerHealth.Unhealthy, "critical")] + [InlineData(PulseCheckerHealth.Suspicious, "warning")] + public async Task PagerDuty_MapsHealthOntoItsOwnSeverityScale(PulseCheckerHealth health, string expected) + { + var (clients, handler) = Capture(HttpStatusCode.Accepted); + var sink = new PagerDutyAlertSink(clients, "routing-key-1"); + + await sink.SendAsync(Failing(health, PulseCheckerHealth.Healthy), Ct); + + Assert.Equal(expected, Json(handler.Body).GetProperty("payload").GetProperty("severity").GetString()); + } + + [Fact] + public async Task PagerDuty_PostsToTheEventsApiUnlessToldOtherwise() + { + var (clients, handler) = Capture(HttpStatusCode.Accepted); + + await new PagerDutyAlertSink(clients, "routing-key-1").SendAsync(Failing(), Ct); + Assert.Equal(PagerDutyAlertSink.EventsEndpoint, handler.Request!.RequestUri); + + var eu = new Uri("https://events.eu.pagerduty.com/v2/enqueue"); + await new PagerDutyAlertSink(clients, "routing-key-1", eu).SendAsync(Failing(), Ct); + Assert.Equal(eu, handler.Request!.RequestUri); + } + + [Fact] + public void PagerDuty_WithoutARoutingKey_SaysSoAtConstruction() + { + var (clients, _) = Capture(); + + Assert.Throws(() => new PagerDutyAlertSink(clients, " ")); + } + + + /// + /// Throwing is how a sink reports a failed delivery: the dispatcher catches it, logs it, and no + /// check is affected. A sink that swallowed the failure would report a delivery that never + /// happened. + /// + [Fact] + public async Task ARejectedDelivery_Throws() + { + var (clients, _) = Capture(HttpStatusCode.BadRequest); + + var sinks = new IAlertSink[] + { + new SlackAlertSink(clients, new Uri("https://hooks.slack.test/services/abc")), + new MicrosoftTeamsAlertSink(clients, new Uri("https://prod.westeurope.logic.azure.test/workflows/abc")), + new PagerDutyAlertSink(clients, "routing-key-1"), + new WebhookAlertSink(clients, new Uri("https://example.test/hook")), + }; + + foreach (var sink in sinks) + { + await Assert.ThrowsAsync(() => sink.SendAsync(Failing(), Ct)); + } + } + + [Fact] + public async Task EverySink_PostsRatherThanAnythingElse() + { + var (clients, handler) = Capture(); + + foreach (var sink in new IAlertSink[] + { + new SlackAlertSink(clients, new Uri("https://hooks.slack.test/services/abc")), + new MicrosoftTeamsAlertSink(clients, new Uri("https://prod.westeurope.logic.azure.test/workflows/abc")), + new WebhookAlertSink(clients, new Uri("https://example.test/hook")), + }) + { + await sink.SendAsync(Failing(), Ct); + + Assert.Equal(HttpMethod.Post, handler.Request!.Method); + Assert.Equal("application/json", handler.Request.Content!.Headers.ContentType!.MediaType); + } + } +} diff --git a/tests/Healthie.Tests.Unit/CoravelSchedulerTests.cs b/tests/Healthie.Tests.Unit/CoravelSchedulerTests.cs index b8501fc..1fec1be 100644 --- a/tests/Healthie.Tests.Unit/CoravelSchedulerTests.cs +++ b/tests/Healthie.Tests.Unit/CoravelSchedulerTests.cs @@ -78,21 +78,36 @@ public async Task ReschedulingAChecker_ReplacesItsScheduleRatherThanAddingOne() [Fact] public async Task ASingleDueOccurrence_TriggersOnceEvenIfTicksKeepComing() { - var scheduler = new CoravelPulseScheduler(); + // The clock is controlled, so this asks the question directly instead of sleeping and hoping. + // It used to schedule a one-millisecond period and assert a second tick would not fire -- + // but with that period the second tick is legitimately due after a millisecond, so it failed + // whenever the machine was busy enough to put one between the two calls, which says nothing + // about whether a single occurrence can fire twice. + var clock = new SettableTimeProvider(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)); + var scheduler = new CoravelPulseScheduler(timeProvider: clock); var checker = new FakePulseChecker("once-per-occurrence"); - await scheduler.ScheduleAsync(checker, PulseSchedule.Every(TimeSpan.FromHours(1)), Ct); + var period = TimeSpan.FromMinutes(1); + await scheduler.ScheduleAsync(checker, PulseSchedule.Every(period), Ct); - // Due immediately, then not again for an hour. - await scheduler.ScheduleAsync(checker, PulseSchedule.Every(TimeSpan.FromMilliseconds(1)), Ct); - await Task.Delay(20, Ct); + // Exactly one occurrence has come due. + clock.Advance(period); await scheduler.TickAsync(Ct); var afterFirst = checker.TriggerCount; + + // Ticks keep coming, and the clock has not moved: the occurrence is spent. + await scheduler.TickAsync(Ct); await scheduler.TickAsync(Ct); Assert.Equal(1, afterFirst); Assert.Equal(1, checker.TriggerCount); + + // And the next occurrence still arrives when it should. + clock.Advance(period); + await scheduler.TickAsync(Ct); + + Assert.Equal(2, checker.TriggerCount); } /// One checker throwing must not stop the rest of the tick. diff --git a/tests/Healthie.Tests.Unit/TestDoubles.cs b/tests/Healthie.Tests.Unit/TestDoubles.cs index ca58e9e..e95debc 100644 --- a/tests/Healthie.Tests.Unit/TestDoubles.cs +++ b/tests/Healthie.Tests.Unit/TestDoubles.cs @@ -182,3 +182,20 @@ public Task ScheduleAsync(IPulseChecker checker, PulseInterval interval, Cancell public Task UnscheduleAsync(IPulseChecker checker, CancellationToken cancellationToken = default) => Task.CompletedTask; } + +/// +/// A clock a test moves by hand. +/// +/// +/// Only is overridden, because that is all the schedulers read. A test that +/// waits for real time to pass is testing the machine's load as much as the code. +/// +internal sealed class SettableTimeProvider(DateTimeOffset now) : TimeProvider +{ + private DateTimeOffset _now = now; + + public override DateTimeOffset GetUtcNow() => _now; + + /// Moves the clock forward. + public void Advance(TimeSpan by) => _now = _now.Add(by); +}