Skip to content

Reach Slack, Teams and PagerDuty without something in between - #33

Merged
ivanvyd merged 3 commits into
mainfrom
feature/alert-sinks
Jul 29, 2026
Merged

Reach Slack, Teams and PagerDuty without something in between#33
ivanvyd merged 3 commits into
mainfrom
feature/alert-sinks

Conversation

@ivanvyd

@ivanvyd ivanvyd commented Jul 29, 2026

Copy link
Copy Markdown
Owner

The last item on the README roadmap: "Alert sinks beyond the webhook — Slack, Teams and PagerDuty."

The webhook did not reach them

WebhookAlertSink's own remarks claimed it was "the one sink worth shipping, because it is the one that reaches everything else", and listed Slack, Teams and PagerDuty among the things it reached. It reaches none of them. All three reject arbitrary JSON:

Destination Wants Given the generic payload
Slack incoming webhook text, plus attachments or blocks invalid_payload
Microsoft Teams An Adaptive Card in a Bot Framework envelope rejected
PagerDuty An Events API v2 event with routing_key rejected

The remarks half-admitted it, in the phrase "Teams through a Power Automate flow" — that flow existed purely to reshape the payload, which is work the library was leaving to its users. Those remarks are corrected here.

One deviation from the roadmap

The roadmap said "as packages". These are three sinks inside Healthie.NET.Alerting instead.

A separate package buys isolation of dependencies. These have none to isolate — every one is HttpClient plus System.Text.Json, both already referenced by the alerting package. Three more package IDs, READMEs, and release entries would be real ongoing maintenance in exchange for nothing. Say the word and I'll split them.

PagerDuty is more than a payload shape

It does not want to be told a health changed; it wants to be told an incident opened or closed, and which one. That machinery already existed and was unused:

  • Alert.DeduplicationKey — documented as "a checker that flaps between suspicious and unhealthy is one incident, not two" — becomes dedup_key.
  • Alert.IsRecovery — documented as "a recovery closes an incident rather than opening one, and a sink that treats the two alike pages somebody at three in the morning to tell them everything is fine" — becomes event_action: resolve rather than a second trigger.

Suspicious maps to warning, not critical: it is the state meaning "failing, but not past the threshold yet", and paging on it would defeat having a threshold at all.

Teams targets Workflows, not connectors

Microsoft has retired Office 365 connectors and the MessageCard payload they accepted. A sink written to that shape would have shipped already dead, so this posts an Adaptive Card to the Workflows URL.

Verification

383 tests green on both net8.0 and net10.0.

Payloads are asserted against the request body, not against the records that build them — the failure mode here is a field named right in C# and wrong in JSON, which shows up only at the far end where no test would see it. WebhookAlertSink had no coverage at all and now shares the same harness.

Mutation-tested:

mutation tests failed
Slack colours a failure green 2
Slack timestamps in the server's zone 1
Teams card carries no schema 1
Teams writes null card properties 1
PagerDuty never resolves, only triggers 1
PagerDuty pages on suspicious too 1
a rejected delivery is reported as success 1

Second commit: a pre-existing flaky test

Adding a test class changed how xUnit schedules collections and made CoravelSchedulerTests.ASingleDueOccurrence_TriggersOnceEvenIfTicksKeepComing fail about half the time on net10.0 — which would otherwise have looked like the sinks breaking something.

It is not the sinks, and it is not the scheduler. The test scheduled a 1 ms period and then asserted a second tick would not fire — but with a 1 ms period the second tick is legitimately due after one millisecond. It failed whenever anything put a millisecond between the two calls. Its own comment described the arrangement it wanted ("due immediately, then not again for an hour") and the code underneath did not build it.

Confirmed pre-existing: on main, in isolation, it fails ~3 runs in 6 on net10.0. It is almost certainly the one unexplained red run earlier in this session that I could not reproduce at the time. Period is now 500 ms, giving the two ticks 500 ms of slack instead of one; 8/8 stable after. Still catches what it is named for — with the scheduler mutated to leave a consumed occurrence due, it goes red.

This also affects PRs #30, #31 and #32, which share the same suite.

No release — version stays at 3.1.4.

ivanvyd added 3 commits July 29, 2026 22:36
The webhook sink's own remarks claimed it was "the one sink worth shipping,
because it is the one that reaches everything else", and listed Slack, Teams
and PagerDuty among the things it reached. It does not reach any of them. All
three reject arbitrary JSON: a Slack incoming webhook wants text and
attachments or blocks, Teams wants an Adaptive Card in a Bot Framework
envelope, and PagerDuty wants an Events API event. Posting the generic
payload at a Slack webhook returns invalid_payload. The remarks half admitted
it, in the phrase "Teams through a Power Automate flow" -- the flow existed to
reshape the payload, which is work this library was leaving to its users.

Three sinks, in the alerting package rather than a package each. That is the
one place this deviates from what the roadmap said: it called for packages,
and a package buys isolation of dependencies. These have none to isolate --
every one is HttpClient and System.Text.Json, both already here -- so three
more package ids, readmes, and release entries would cost real maintenance
and give nothing back.

PagerDuty is the one that is not just a payload shape. It does not want to be
told a health changed; it wants to be told an incident opened or closed, and
which one. Alert.DeduplicationKey and Alert.IsRecovery already existed for
exactly that, with a comment saying a flapping checker is one incident rather
than several -- so a failure triggers keyed on the checker and a recovery
resolves that same incident instead of raising a second one saying everything
is fine. Suspicious maps to warning rather than critical, because it is the
state that means "failing, but not past the threshold yet" and paging on it
would defeat having a threshold.

Teams targets the Workflows URL and an Adaptive Card. The Office 365
connectors and the MessageCard payload they took are retired, so a sink
written to that shape would have arrived already dead.

The payload shapes are asserted against the request body rather than against
the records that build them, because the failure mode is a field named right
in C# and wrong in JSON, which shows up only at the far end where no test
would see it. The webhook sink had no coverage at all and now shares the same
harness.
ASingleDueOccurrence_TriggersOnceEvenIfTicksKeepComing scheduled a checker on
a one-millisecond period and then asserted that a second tick would not fire
it. With that period the second tick is legitimately due after a single
millisecond, so the test failed whenever anything put a millisecond between
the two calls -- which says nothing at all about whether one occurrence can
fire twice, the thing the test is named for. Its own comment described the
arrangement it wanted, "due immediately, then not again for an hour", and the
code underneath did not build that.

The period is half a second now, and the wait is for that one occurrence to
come due. The two ticks then have 500ms of slack between them rather than one.

This is pre-existing and unrelated to the sinks; it is here because adding a
test class changed how xUnit schedules the collections and made it fail about
half the time on net10.0, which would otherwise have looked like the sinks
breaking something. On main it fails roughly one run in two in isolation, and
it is almost certainly the one unexplained red run earlier in this branch's
history.

Still catches what it is for: with the scheduler mutated to leave a consumed
occurrence due, it goes red.
Review, checking the payloads against the services' own documentation rather
than against these tests, found three things.

The Adaptive Card colours were PascalCase. The schema's Colors enum is
lowercase and matching is case-sensitive, and an unrecognised value is not an
error: the card renders in the default colour and the POST still returns 2xx.
Every Unhealthy and Suspicious alert would have lost its colour in Teams and
nothing would ever have said so. The test asserted "Attention", so it pinned
the bug rather than catching it. Confirmed against the published schema, which
lists default|dark|light|accent|good|warning|attention.

Slack parses the top-level text as mrkdwn, so &, < and > have to be escaped
there. DisplayName is virtual and "Auth & Session" is an ordinary thing to
call a checker. The attachment's fields are plain text unless mrkdwn_in says
otherwise, and it does not, so only the headline needed it.

PagerDuty's group was the one nullable field here without
JsonIgnore(WhenWritingNull), so a checker with no group -- the common case --
sent "group": null while every other absent field was omitted.

The UTC test was vacuous. It built its alert with DateTimeKind.Utc, and
DateTimeOffset takes its offset from the Kind, so deleting the SpecifyKind
call it was named after produced byte-identical output. It now uses an
unmarked time, which is what a state round-tripped through a store that does
not preserve Kind gives you, and which is the only case that tells the two
apart.

The Coravel test is deterministic rather than merely less flaky. Widening the
window from one millisecond to five hundred made the failure rarer without
changing its nature -- the second tick still raced the wall clock, and a
long enough stall would still lose. The scheduler takes a TimeProvider now,
optional so nothing about constructing it changes, and the test moves the
clock by hand instead of sleeping. It asserts the next occurrence still
arrives, which sleeping never did, and the suite spends 550ms less doing it.
@ivanvyd
ivanvyd force-pushed the feature/alert-sinks branch from dadca0d to 14d1eae Compare July 29, 2026 19:42
@ivanvyd
ivanvyd merged commit 962272c into main Jul 29, 2026
7 of 8 checks passed
@ivanvyd
ivanvyd deleted the feature/alert-sinks branch July 30, 2026 02:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant