Notifications
The controller (halo-server --controller, or halo controller run) acts on verdicts and tells people. This guide wires that to four sinks:
| Sink | What it is | Default events |
|---|---|---|
| Slack | Incoming webhook, {text, blocks} |
every event |
| Microsoft Teams | Workflows (Power Automate) webhook, Adaptive Card | every event |
| PagerDuty | Events API v2: trigger and resolve per incident |
only what needs a human now, plus the all-clear |
| Webhook | POST of the JSON event, HMAC-signed |
every event (all-clears are opt-in) |
A notification informs; it never approves. Nothing here merges, promotes or advances anything: a human still merges the PR. Each sink is optional and independent: a sink that is down does not hold back the others, the kill switch or the PR.
Events and routing
Section titled “Events and routing”Every sink has an event filter (--notify-<sink>-events, a comma-separated list of kinds; all means every kind). The kinds, what raises them, and how PagerDuty treats them:
| Kind | Raised when | PagerDuty severity | Default sinks |
|---|---|---|---|
experiment.rollback |
A running experiment’s verdict is rollback. The kill switch is tripped first when the evidence is gateway-sourced; the message says what happened to it | error if the kill is enforced, else critical (exposure continues until the pause PR merges) |
Slack, Teams, webhook, PagerDuty |
experiment.killed |
A running experiment is found killed (an admin kill, or one left from an earlier run). It is not evaluated until someone unkills it | warning |
Slack, Teams, webhook, PagerDuty |
experiment.promote |
The verdict is promote; the controller opened a PR concluding the experiment | info (not paged) |
Slack, Teams, webhook |
experiment.expired |
maxDays or maxSpendUSD reached without a decision; PR opened |
info (not paged) |
Slack, Teams, webhook |
rollout.rollback |
A rollout gate failed and onFailure is rollback |
as experiment.rollback |
Slack, Teams, webhook, PagerDuty |
rollout.pause |
A rollout gate failed and onFailure is pause |
warning |
Slack, Teams, webhook, PagerDuty |
rollout.killed |
A rollout’s backing experiment is killed; the rollout is held | warning |
Slack, Teams, webhook, PagerDuty |
rollout.advance |
Every gate of the step passed (or the rollout can start); PR opened to move on | info (not paged) |
Slack, Teams, webhook |
rollout.complete |
The last step passed; PR opened for general availability | info (not paged) |
Slack, Teams, webhook |
rollout.frozen |
A change freeze or incident freeze is what holds the rollout | info (not paged) |
Slack, Teams, webhook |
rollout.awaiting_approval |
A step an approval rule covers is waiting for approvals | info (not paged) |
Slack, Teams, webhook |
resolved |
The condition behind an earlier rollback, pause or killed is over (see below) |
closes the incident | PagerDuty; webhook if listed |
Each event is delivered once per channel. Informational events (a PR was opened, a freeze holds a rollout) never page: PagerDuty’s default filter is experiment.rollback,experiment.killed,rollout.rollback,rollout.pause,rollout.killed,resolved. To see one anyway, list it, for example --notify-pagerduty-events=all,resolved; it then arrives with severity info.
Slack and Teams cannot carry an all-clear, so they reject resolved. Slack’s payload is byte-for-byte what it was before these sinks existed, and its default (every event) is unchanged.
Incidents open and resolve themselves
Section titled “Incidents open and resolve themselves”A PagerDuty trigger carries a stable dedup_key per condition: halos/experiment/<name>/<verdict> or halos/rollout/<name>/<verdict>. A repeat of the same condition folds into one incident. The matching resolve is sent, once, when:
- an experiment that rolled back leaves
running(a human merged the pause PR); - an experiment or rollout hold clears because the kill was lifted (unkill);
- an experiment or rollout is deleted from policy while an incident is open (the controller notices a tracked name missing from the loaded policy, sends the
resolveto each sink that opened one, once, then drops its state); - a rollout’s
rollbackorpauseclears because the rollout leftactivein policy (a human merged the pause or rollback) or a human moved it to another step.
A resolve is sent only for an incident that sink actually opened. If the controller restarts or another replica takes over, no incident is duplicated: the trigger and the resolve are each recorded per sink, and the event id (event_id in custom_details) is the same on every attempt.
Limits to know: an all-clear is best effort. If it still fails after the retries below, the controller does not hold its own state for it (a restarted experiment must be evaluated regardless), so it is logged and counted (a deletion’s all-clear follows the same rule: state is dropped even if it failed) in halo_controller_notifications_total{result="error"}, and that incident has to be resolved in PagerDuty by hand. A kill that is lifted and then re-applied within the same experiment run is not announced a second time (the hold is told once per run).
Reliability
Section titled “Reliability”- Timeouts and redirects. Each attempt has a 10 second timeout and does not follow redirects.
- Bounded retries. A delivery makes up to 3 attempts, waiting 1s then 2s, or the receiver’s
Retry-Afterwhen it sends one (up to 15s; a longer one ends the retrying), within 30s in all. Transport errors, timeouts, 429 and 5xx are retried. Any other 4xx (a revoked URL, a bad routing key, a rejected payload) is not: it will not heal. - A failed delivery is not lost. The controller records a notification only once a sink accepted it, so a sink that still fails is tried again on the next tick, alone, without re-posting to the others.
- Never in the way. Failures are returned as tick errors (
halo_controller_errors_total), logged, and counted per sink; they never stop the loop, the kill switch or PR opening. - Secrets stay secret. Webhook URLs (they embed a token) and routing keys are read from files, never flags, never ConfigMaps; no error, log line or metric contains them.
- Metrics and alert.
halo_controller_notifications_total{sink,result}withresultofok,errororfiltered. TheHaloControllerNotifyFailingalert (ticket) fires when a sink keeps failing after retries.
Create an incoming webhook for the channel and put the URL in a file or Secret.
halo-server --controller ... --notify-slack-url-file /secrets/slack-urlMicrosoft Teams
Section titled “Microsoft Teams”Halos targets a Teams Workflows (Power Automate) webhook and posts an Adaptive Card, not the legacy Office 365 connector (“Incoming Webhook”, MessageCard). Microsoft has retired Office 365 connectors in Teams in favour of Workflows, so a connector URL (*.webhook.office.com, outlook.office.com/webhook) no longer delivers. Halos refuses those URLs at startup with an explicit error instead of failing quietly later.
- In Teams, open the channel, then ⋯ > Workflows.
- Pick the Post to a channel when a webhook request is received template (also listed as “Send webhook alerts to a channel”), name it, choose the team and channel, and finish.
- Copy the HTTP POST URL it shows. It contains a signature, so treat it like a password: put it in a file or Secret.
- Make sure the workflow’s owner account is allowed to post to the channel; the workflow runs as that account, and it stops delivering if the owner leaves or the flow is turned off.
halo-server --controller ... --notify-teams-url-file /secrets/teams-urlThe card shows the title (colored red for a rollback, green for a promotion or advance, amber otherwise), the summary sentence, a fact table (verdict, experiment, axis, kill-switch outcome, time) and, when a PR was opened, an Open pull request button (https links only). Free text such as the reason has its Markdown escaped, so it cannot inject formatting or links.
PagerDuty
Section titled “PagerDuty”Use an Events API v2 integration on the service that should be paged.
- In PagerDuty: Services > (your service) > Integrations > Add an integration, choose Events API v2.
- Copy the Integration Key; that is the routing key. Put it in a file or Secret (key
routingKeyin the Helm Secret). - Start the controller with the key file. The default endpoint is
https://events.pagerduty.com/v2/enqueue; for the EU service region pass--notify-pagerduty-url https://events.eu.pagerduty.com/v2/enqueue.
halo-server --controller ... --notify-pagerduty-routing-key-file /secrets/pagerduty-keyPayload details: summary is the same sentence Slack shows (truncated to PagerDuty’s 1024 characters), source is halos-controller, component is controller, group is the rollout or experiment name, class is the event kind, and custom_details holds the verdict, reason, kill-switch outcome, PR URL and the idempotency event_id. A PR URL is also attached as a link. The routing key travels only in the request body.
Severity mapping:
| Severity | When |
|---|---|
critical |
A rollback whose exposure continues: the kill failed, is not configured, is recorded but not enforced, or the evidence was not gateway-sourced (a human must merge the pause PR) |
error |
A rollback whose kill switch is enforced: mitigated, needs follow-up |
warning |
A rollout paused on a gate breach; a killed experiment or rollout held until someone unkills it |
info |
Anything else, only if you list it in --notify-pagerduty-events |
Route by severity or by class with PagerDuty event rules or the service’s urgency settings.
Generic webhook
Section titled “Generic webhook”--notify-webhook-url-file posts the controller’s JSON event (id, rollout, experiment, axis, verdict, reason, killed, killOutcome, prURL, report, at, and resolved on an all-clear). With --notify-webhook-secret-file it is signed with the same scheme as the SIEM audit export, so a receiver verifies both the same way:
X-Halo-Timestamp: <unix seconds>X-Halo-Signature: sha256=<hex HMAC-SHA256(secret, timestamp + "." + body)>X-Halo-Event-Id: <idempotency key, also "id" in the body>Verify the HMAC over the raw body, compare in constant time, and reject timestamps more than 5 minutes off. Go and Python snippets: reference. Add resolved to --notify-webhook-events to receive all-clears ("resolved": true). Use X-Halo-Event-Id to drop a duplicate sent across a controller failover.
Create one Secret per sink (or reuse one) and reference it. Never put a URL or key in values.
kubectl create secret generic halos-pagerduty --from-file=routingKey=./routing-keykubectl create secret generic halos-teams --from-file=url=./teams-urlserver: controller: enabled: true notify: slack: {existingSecret: halos-slack} # key: url teams: {existingSecret: halos-teams, events: "experiment.rollback,rollout.rollback,rollout.pause"} webhook: {existingSecret: halos-webhook, events: "all,resolved"} # keys: url, secret pagerduty: {existingSecret: halos-pagerduty, key: routingKey} # url: EU endpoint if neededThe chart mounts each Secret as a file and passes the matching --notify-*-file flags; values.schema.json rejects a malformed events list. If you use the chart’s NetworkPolicy, allow the controller’s egress to the sinks with networkPolicy.controllerEgress.webhook (port 443): hooks.slack.com, *.logic.azure.com, events.pagerduty.com and your webhook host.
| Flag | Purpose |
|---|---|
--notify-slack-url-file, --notify-slack-events |
Slack webhook URL; event filter |
--notify-teams-url-file, --notify-teams-events |
Teams Workflows webhook URL; event filter |
--notify-webhook-url-file, --notify-webhook-secret-file, --notify-webhook-events |
Generic webhook URL, HMAC secret; event filter (may include resolved) |
--notify-pagerduty-routing-key-file, --notify-pagerduty-url, --notify-pagerduty-events |
Routing key; endpoint override; event filter (may include resolved) |
An events flag without its sink’s URL or key file is an error, as is an unknown kind. Valid kinds: experiment.rollback, experiment.promote, experiment.expired, experiment.killed, rollout.rollback, rollout.pause, rollout.advance, rollout.complete, rollout.killed, rollout.frozen, rollout.awaiting_approval, resolved, and all.