Skip to content

Add generic webhook notifier for alerts and reports - #9643

Open
dfliess wants to merge 26 commits into
rilldata:mainfrom
dfliess:feature/webhook-notifier
Open

dfliess wants to merge 26 commits into
rilldata:mainfrom
dfliess:feature/webhook-notifier

Conversation

@dfliess

@dfliess dfliess commented Jul 2, 2026 •

Copy link
Copy Markdown
Contributor

Alerts and scheduled reports can currently notify via email and Slack only. Integrating
with anything else (PagerDuty, Opsgenie, n8n/Zapier/Make flows, or an in-house system)
requires abusing Slack incoming webhooks or polling the API.

This PR adds a generic webhook notifier driver, completing the connector-agnostic
Notifiers {connector, properties} design introduced in #4371. Because that groundwork
exists, the change is contained: no runtime proto changes and no new Go dependencies
(delivery uses hashicorp/go-retryablehttp, already a direct dependency; signing is stdlib
crypto/hmac). The only proto change is two additive webhook_urls fields on the admin
API's AlertOptions/ReportOptions for the UI dialogs.

YAML surface (alerts and reports)

notify:
  webhook:
    urls:
      - https://example.com/rill-hook
    connector: my_hook   # optional: named connector instance; defaults to "webhook"

Parity with the slack block: one entry appended to Notifiers, multiplicity inside
properties. The optional connector: key selects a named connector instance (the
reconciler already resolves notifiers by connector name), enabling per-receiver
secrets/headers without new machinery.

Connector config (connector.<name>.* variables or a connector YAML; secret values go in
variables, not in committed files): signing_secret (secret), headers (map, e.g. an
Authorization header for receivers behind an API gateway, same pattern as the https
driver) and allowed_url_prefixes. Anyone who can create an alert chooses its URLs, so
headers requires allowed_url_prefixes and credentials only go to the listed receivers.
Without an allowlist, loopback, private and link-local destinations are blocked, and
deliveries don't go through an environment proxy.

Payload

Versioned envelope, two event types mirroring the Notifier interface:

{
  "id": "unique-per-delivery",
  "type": "alert.status",            // or "report.scheduled"
  "version": 1,
  "timestamp": "2026-07-02T15:04:05Z",
  "data": {
    "display_name": "…",
    "execution_time": "…",
    "status": "PASS | FAIL | ERROR",
    "is_recover": false,
    "fail_row": { "…": "…" },
    "execution_error": "",
    "open_link": "…",
    "edit_link": "…"
  }
}

report.scheduled carries display_name, report_time, download_format, summary,
open_link and download_link. fail_row is included for parity with the email/Slack
notifiers; ToEmail/ToName are excluded (email-specific, per the #4371 review), and no
unsubscribe link is sent (webhook receivers are anonymous).

Signing: Standard Webhooks

Signatures follow the Standard Webhooks spec exactly
(webhook-id / webhook-timestamp / webhook-signature headers, HMAC-SHA256 over
{id}.{timestamp}.{body}, whsec_ secret format), so receivers can verify with the
existing libraries in 11+ languages. Implemented with ~15 lines of stdlib code and
unit-tested against the spec's official test vectors. Without a signing_secret the
request is sent unsigned (capability-URL receivers like Zapier/n8n, same spirit as Slack
incoming webhooks working without a bot token). The connector's Ping validates that the
secret is well-formed so misconfigurations surface early.

Delivery semantics

  • go-retryablehttp: 3 attempts, waiting 1s then 2s (Retry-After is honored, capped at
    4s), 10s per-attempt timeout; retries on 5xx except 501, 429 and network errors, hard-fails
    on other 4xx. Success = any 2xx. Redirects are not followed.
  • URLs are delivered in parallel, up to 8 at a time, under one 60s deadline for the whole
    notification.
  • All URLs are always attempted; errors are aggregated with errors.Join and surface as
    the execution's ERROR result. Since executions are the only delivery-visibility
    surface, each per-URL error is self-sufficient (URL + status + attempts).
  • No persistent queue / deferred redelivery: that is a delivery-platform concern, out of
    scope for a notifier driver. The stable id lets receivers deduplicate.

UI

Mirrors the Slack pattern: a "Webhook notifications" section (toggle + URL multi-input) in
the alert and report create/edit dialogs, gated on ListNotifierConnectors reporting a
configured webhook connector (with a "not configured" docs hint otherwise), and the
destinations listed on the alert/report detail pages. The connector: key stays YAML-only,
like named Slack instances today. New strings use paraglide messages in en.json, with their
Spanish translations in es.json.

Tested

Unit tests for the driver (signing vectors, retry policy, Retry-After cap, delivery
deadline, concurrency cap, final status in errors, per-URL error aggregation, dedupe,
unsigned mode, URL allowlist, private-address guard, proxy, redirects, Ping), the parser
(alerts, reports, URL validation, default and named connectors) and the admin YAML
(notify.webhook omitted when empty, unsubscribe).

Before the review, exercised end-to-end on our self-hosted deployment: alert and report
created through the dialogs, alert.status and report.scheduled (ad hoc and cron)
delivered and signature-verified against a local receiver. After the review changes, the
allowlist, the private-address guard and the headers check were exercised with rill start
against local receivers.

Open question

Docs: added as docs/docs/developers/build/connectors/services/webhook.md. Should the
payload JSON schema be published more formally?

@nishantmonu51 nishantmonu51 added Type:Feature New feature request Area:Alerts Size:L Large change: 500-1,999 lines labels Jul 6, 2026
@dfliess
dfliess force-pushed the feature/webhook-notifier branch 2 times, most recently from be85820 to 25a6448 Compare July 22, 2026 09:44

@nishantmonu51 nishantmonu51 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Four findings inline; the credential-forwarding one on runtime/drivers/webhook/notifier.go is the most significant.

The branch also no longer merges cleanly with main: besides api.pb.go, git merge-tree reports content conflicts in AlertDialogDeliveryTab.svelte, BaseScheduledReportForm.svelte, ScheduledReportDialog.svelte, en.json and es.json, because main refactored the submit options and made columns optional for canvas reports. A rebase is needed before this can be evaluated end to end.

Two smaller notes:

  • retryablehttp.NewClient() builds a fresh http.Client and pooled transport on every send, and CloseIdleConnections only runs on the failure path, so each successful delivery leaves a transport's idle connections open until IdleConnTimeout. Building the client once in newNotifier avoids this.
  • The popCurrentExecution drive-by fix described in the PR body is not in the diff; nothing under runtime/reconcilers/ is touched, and main already guards adminMeta in the non-email path at runtime/reconcilers/alert.go:931.

Comment thread runtime/drivers/webhook/notifier.go Outdated
continue
}
req.Header.Set("Content-Type", "application/json")
for k, v := range n.headers {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every header from the connector config is set on every request regardless of destination, and the documented use case for headers is an Authorization bearer token for an API gateway. CreateAlerts/CreateReports are granted to every project viewer (admin/database/postgres/migrations/0023.sql:2 sets create_alerts = read_prod) and the dialog accepts any http(s) URL, so a viewer who creates an alert with urls: [https://attacker.example] receives the project's gateway token on the next execution. Slack does not have this problem because its bot token only ever goes to slack.com. Separately, there is no private-IP or metadata guard and the default http.Client follows redirects, so the runtime will also POST (blind, with retries) to internal services reachable from its network. An allowed_url_prefixes allowlist, following the precedent of path_prefixes in runtime/drivers/https/https.go:58 and made mandatory whenever headers is set, plus a private-IP guard, closes both holes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 663a4ab. headers now requires allowed_url_prefixes. This is checked in Ping, so it shows as a connector error, and again when the notifier is built. A URL outside the list fails on its own and the other URLs are still delivered.

The match is not a plain string prefix like path_prefixes: scheme and host (with port) must be equal, and the path must start with the entry's path. So https://example.com does not admit https://example.com.evil.io.

Without an allowlist, loopback, private and link-local addresses are blocked when dialing, after DNS resolution. In that case the client also connects directly instead of through an environment proxy, because through a proxy the guard would check the proxy's address. A listed URL skips the guard, for localhost during development or an internal receiver in a self-hosted setup. Redirects are not followed anymore: a 3xx counts as a failed delivery.

Comment thread runtime/drivers/webhook/notifier.go Outdated
Comment on lines +186 to +195
client := retryablehttp.NewClient()
client.Logger = nil
client.RetryMax = n.retryMax
client.RetryWaitMin = n.retryWaitMin
client.RetryWaitMax = n.retryWaitMax
client.HTTPClient.Timeout = n.requestTimeout

var errs []error
for _, u := range dedupe(n.props.URLs) {
req, err := retryablehttp.NewRequest(http.MethodPost, u, body)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

retryablehttp's DefaultBackoff returns a server-supplied Retry-After verbatim on 429/503 without capping it at RetryWaitMax (go-retryablehttp@v0.7.8/client.go:551-566; parseRetryAfterHeader accepts any non-negative integer or future HTTP-date), and retryablehttp.NewRequest carries context.Background(), so the select on req.Context().Done() inside Do never fires. A receiver at any URL a viewer supplies can answer 429 with Retry-After: 86400 and block SendAlertStatus for a day per retry, holding the alert's reconcile goroutine, surviving an edit or delete of the alert, and delaying runtime shutdown. Even the benign worst case is roughly 33 s per URL, sent sequentially with no shared deadline. Set a custom Backoff that clamps to RetryWaitMax, and give the request a context with an overall deadline: Notifier takes no context today, so a context.WithTimeout inside send is the minimum.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7bcebf6. The backoff now caps Retry-After at RetryWaitMax, and requests carry a context with one 60s deadline for the whole send. URLs are now delivered in parallel, up to 8 at a time, under that deadline, so one slow receiver does not delay the others.

Comment thread proto/rill/admin/v1/api.proto Outdated
repeated string slack_users = 10;
repeated string slack_channels = 11;
repeated string slack_webhooks = 12;
repeated string webhook_urls = 23;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

metrics_view_filters = 23 landed in ReportOptions on main in #9770 (canvas PDF export), after this branch's 2026-07-21 merge-base. A three-way merge of this file succeeds textually but produces two fields numbered 23 in the message (verified with git merge-tree), so make proto.generate fails on the merged tree and the committed api.pb.go conflicts as well. Renumber to 24, since 19 is reserved. AlertOptions.webhook_urls = 18 is still free.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in the rebase: ReportOptions.webhook_urls is now 24, and the generated code is regenerated. AlertOptions.webhook_urls stays 18.

Comment thread admin/server/alerts.go
Channels []string `yaml:"channels"`
Webhooks []string `yaml:"webhooks"`
}
Webhook struct {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This nested struct has no omitempty, so yamlForManagedAlert, yamlForManagedReport and the unsubscribe re-marshal at admin/server/alerts.go:383 always serialize webhook: {urls: []}. The runtime parser decodes alerts and reports with KnownFields(true) (runtime/parser/parse_node.go:370), and in Rill Cloud the admin service and project runtimes deploy independently, so a runtime that has not been upgraded yet rejects every alert or report created, edited or unsubscribed after the admin deploy with an unknown-field parse error. yaml:"webhook,omitempty" here and on the matching struct at admin/server/reports.go:866 keeps the output byte-identical unless URLs are actually set.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5802ead, on both structs. A test checks the managed and committed YAML of alerts and reports: no notify.webhook key without URLs, and the key is there when URLs are set.

@dfliess
dfliess force-pushed the feature/webhook-notifier branch from 25a6448 to 4673a20 Compare September 21, 2026 19:48
@dfliess

dfliess commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks for the review. I rebased on main and resolved the conflicts you listed. Each finding has its own commit, see the replies in the threads.

About the two notes:

We also found and fixed two more things:

  • Unsubscribe did not count webhook URLs. When the last email recipient unsubscribed, an alert or report that still notified a webhook was deleted (0a5fc4b).
  • When retries ran out, the error only said "giving up after 3 attempt(s)", without the status. It now says, for example, "unexpected status 503 after 3 attempt(s)" (4673a20).

One question: notify.slack.webhooks has the same blind POST you described. The Slack driver posts with http.DefaultClient to the URL in the alert, with no private-address guard and no timeout. Do you want the same guard for Slack webhooks in this PR, in a follow-up PR, or not at all?

Co-authored with Claude.

@dfliess
dfliess force-pushed the feature/webhook-notifier branch from 4673a20 to 929ab0d Compare October 7, 2026 08:49
@dfliess

dfliess commented Oct 7, 2026

Copy link
Copy Markdown
Contributor Author

Hi @nishantmonu51, just rebased this so we keep it mergeable until it's done or closed. The only new commit is 929ab0d, which adds the ctx argument to the webhook driver's Open after #9909.

@nishantmonu51 nishantmonu51 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the thorough follow-up. All four threads are resolved well, and the allowlist matching on scheme and canonical host, the dial-time IP check, and the final status in the retry error are all improvements over what I asked for.

The branch has drifted again, though, in two ways that a textual merge won't catch. First, main has since used both of the new proto field numbers: ReportOptions.export_attach = 24 landed in #10031 and AlertOptions.export_attach = 18 landed in #10060. api.proto merges cleanly but ends up with duplicate numbers in both messages, so webhook_urls needs to move to 25 and 22 respectively. Second, #10009 added AsMCP() to drivers.Handle, so the webhook handle no longer compiles against main. I confirmed that adding the usual return nil, false stub is enough for the driver to build and its tests to pass on a merge preview. Beyond those, there are mechanical conflicts in api.pb.go, parser_test.go, AlertMetadata.svelte, ScheduledReportDialog.svelte, en.json and es.json. The AlertMetadata.svelte conflict comes only from moving the m import, so leaving that import in place avoids it.

runtime/parser/schema/project.schema.yaml also needs updating. notify_properties still requires slack or email and doesn't list webhook, and there is no Webhook connector entry. That schema generates the reference pages for alerts, reports and connectors, and it is what the developer agent receives through JSONSchemaForResourceType. As it stands, the reference docs won't mention notify.webhook, and the agent is told that a webhook-only notify block is invalid. Please add both entries and run make docs.generate. While you're in the docs, linking the new page from the services index card list and from the "Notification Configuration" section in developers/build/alerts.md would make it discoverable.

On your question about notify.slack.webhooks: I'd prefer a separate PR. The same private-address guard and a request timeout make sense there, but that changes existing Slack behavior and deserves its own review and release note.

The failing Go test and e2e jobs are caused by secrets that aren't available to fork PRs (S3, GCS, Druid DSN and GCP auth), not by this change. The webhook, parser and admin server packages pass in that run.


Developed in collaboration with Claude Code

Comment thread proto/rill/admin/v1/api.proto Outdated
repeated string slack_users = 10;
repeated string slack_channels = 11;
repeated string slack_webhooks = 12;
repeated string webhook_urls = 24;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

main now has bool export_attach = 24; in ReportOptions (#10031). The three-way merge succeeds textually and leaves two fields numbered 24 in this message. Please renumber this field to 25; 19 is reserved, and 7 is better left unused.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renumbered to 25 in the rebase. I moved it in the commit that adds the field, so no commit in the branch has a duplicate number.

Comment thread proto/rill/admin/v1/api.proto Outdated
repeated string slack_users = 9;
repeated string slack_channels = 10;
repeated string slack_webhooks = 11;
repeated string webhook_urls = 18;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue in AlertOptions. main added bool export_attach = 18; in #10060, and 19 through 21 are also taken now. The next free number is 22.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renumbered to 22, also in the commit that adds the field.

logger *zap.Logger
}

var _ drivers.Handle = &handle{}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After #10009, drivers.Handle requires AsMCP() (drivers.MCPService, bool), so this assertion and the return conn in Open fail to compile on a merge with main. The Slack driver's stub at runtime/drivers/slack/slack.go:166 on main is the pattern to copy.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added the stub in 5ca7852, following the Slack driver.

Comment thread runtime/drivers/webhook/notifier.go Outdated
// link-local (which includes cloud metadata endpoints such as 169.254.169.254) or unspecified.
func isPrivateAddress(ip netip.Addr) bool {
ip = ip.Unmap()
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IsPrivate only covers RFC 1918 and RFC 4193, so 100.64.0.0/10 (RFC 6598) and NAT64 64:ff9b::/96 pass the guard. I checked that 100.64.0.1, 100.100.100.200 (the Alibaba Cloud metadata address) and 64:ff9b::a00:1 all return false. Shared address space is a supported Pod and Service range on GKE and is common with Tailscale, so it would be good to block it too, and to unwrap NAT64 addresses to their embedded IPv4 before checking.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b955bcf. The guard now blocks 100.64.0.0/10, and an address under 64:ff9b::/96 is checked as its embedded IPv4, so 64:ff9b::a00:1 is treated as 10.0.0.1. NAT64 addresses of public IPv4s are still allowed, so receivers stay reachable from IPv6-only networks. The test covers your three addresses and the edges of the shared range.

Comment thread runtime/drivers/webhook/webhook.go Outdated
return nil, fmt.Errorf("webhook driver can't be shared")
}
conf := &configProperties{}
err := mapstructure.Decode(config, conf)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Connector variables always arrive as strings, so connector.webhook.allowed_url_prefixes=https://hooks.example.com/ makes Open fail with "source data must be an array or slice, got string", and every alert using the connector then fails in AcquireHandle. mapstructure.WeakDecode, which the https driver uses, turns a single string into a one-element slice.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3f60fd0. Open now uses mapstructure.WeakDecode, and a test opens the connector with the prefix as a single string.

Comment thread runtime/drivers/webhook/notifier.go Outdated
}
host := canonicalHost(u)
for _, p := range prefixes {
if u.Scheme == p.scheme && host == p.host && strings.HasPrefix(u.Path, p.path) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This prefix check isn't bounded at a path segment. With an entry of https://gw.example.com/hooks, both /hooks-admin/x and /hooksx are admitted and receive the static headers. Requiring the path to equal the entry's path or to continue with / would close this. Normalizing entries to a trailing slash in parseURLPrefixes would work as well.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 951ff9f. The path must now equal the entry's path or continue with /. I kept the entries as written instead of adding a trailing slash, so https://gw.example.com/hooks still admits /hooks itself. An entry that already ends with / works as before. The docs describe the rule.

Comment thread runtime/parser/parse_alert.go Outdated
if err != nil {
return fmt.Errorf(`invalid value %q for property "notify.webhook.urls": %w`, u, err)
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This accepts URLs without a host, such as https:///hook or https:hook.example.com. They parse successfully and then fail on every execution with "http: no Host in request URL". Checking parsed.Host != "" here would match the error message and surface the mistake at parse time.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b40b274. URLs without a host are now rejected at parse time, for alerts and reports.

Comment thread runtime/parser/parse_report.go Outdated
}
// Webhook settings
if len(tmp.Notify.Webhook.URLs) > 0 {
if err := validateWebhookURLs(tmp.Notify.Webhook.URLs); err != nil {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This repeats the validation that already ran at line 220, before insertResource, so it can be removed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed in a6f67eb.

import MetadataValue from "@rilldata/web-admin/features/scheduled-reports/metadata/MetadataValue.svelte";
import { extractNotifier } from "@rilldata/web-admin/features/scheduled-reports/metadata/notifiers-utils";
import { formatRefreshSchedule } from "@rilldata/web-admin/features/scheduled-reports/metadata/utils.ts";
import { m } from "@rilldata/web-common/lib/i18n/gen/messages";

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moving this import isn't needed for the feature, and it is the only reason this file conflicts with main, which changed the formatRefreshSchedule import above it. The same move happens in ReportMetadata.svelte.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in the rebase. The m import stays where it was in both AlertMetadata.svelte and ReportMetadata.svelte.

slackChannels: array().of(string()),
slackUsers: array().of(string().email(m.report_form_invalid_email())),
enableWebhookNotification: boolean(),
webhookUrls: array().of(string().url("Invalid URL")),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The slackUsers validator next to this one uses m.report_form_invalid_email(). Could this use a new report_form_invalid_url message, with its Spanish translation, so the Spanish locale doesn't show English?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added report_form_invalid_url with its Spanish translation in dbfb552. I left the alert form as it is, since its other validators in form-utils.ts (Invalid email, Required) are also plain English strings.

(cherry picked from commit 87406d7854fd20684392e467c8f2af021763c8d1)
Inert until the Spanish locale is registered in the inlang project settings
(pending in rilldata#9635); included here so the two PRs compose
cleanly in either merge order.
…scribes

Unsubscribe deleted the resource once no email recipient or Slack target was
left, without counting webhook URLs. Unsubscribing the last email recipient
therefore deleted an alert or report that still delivered to a webhook.
Runtimes that predate the webhook notifier parse alerts and reports with
KnownFields(true), and the admin service and runtimes deploy independently.
Always emitting `webhook: {urls: []}` made those runtimes reject every alert
or report created, edited or unsubscribed after the admin deploy.
A receiver could stall delivery indefinitely: the default backoff honors a
Retry-After header verbatim, and requests carried context.Background(), so
`429` with `Retry-After: 86400` held the alert's reconcile for a day. URLs
were also sent one after another with no shared deadline.

- Cap the backoff, including Retry-After, at RetryWaitMax.
- Deliver to up to 8 URLs at a time under one 60s deadline for the whole send.
- Close idle connections at the end of each send. The reconcilers build a
  notifier per execution, so a client built once per notifier would not reuse
  connections either, and successful deliveries left idle connections open
  until the transport's idle timeout.
Anyone who can create an alert or report (every project viewer by default)
chooses its webhook URLs, so the connector's static headers, typically an
API gateway token, could be sent to any server, and the runtime would POST
to internal services reachable from its network.

- Add `allowed_url_prefixes` to the webhook connector, required whenever
  `headers` is set. The scheme and host must match exactly and the path must
  start with the entry's path, unlike a plain string prefix, which would let
  https://example.com admit https://example.com.evil.io.
- Without an allowlist, block loopback, private and link-local destinations
  (including cloud metadata endpoints). The check runs on the dialed address,
  after DNS resolution, and the client connects directly instead of through
  an environment proxy, whose address it would check instead. Listing a
  receiver admits it, e.g. localhost during development or an internal
  receiver in a self-hosted deployment.
- Don't follow redirects; a 3xx counts as a failed delivery.
- Ping validates the connector config, so a missing allowlist surfaces on the
  connector instead of on the first delivery.
With logging disabled, retryablehttp's default error on exhausted retries
only says how many attempts were made: a receiver answering 503 three times
surfaced as "giving up after 3 attempt(s)". The execution's error is the only
place a failed delivery shows up, so keep the final status or error in it.
@dfliess
dfliess force-pushed the feature/webhook-notifier branch from 929ab0d to 279efe2 Compare November 2, 2026 19:15
@dfliess

dfliess commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for checking this against main. I rebased on ac865b6 and addressed everything:

  • Proto fields renumbered to 25 (ReportOptions) and 22 (AlertOptions), and the AsMCP stub added. The mechanical conflicts are resolved.
  • project.schema.yaml now has notify.webhook (with urls and connector) in notify_properties, which also accepts a webhook-only notify, and a Webhook connector entry. make docs.generate updated the alerts, reports and connectors reference pages. A test validates a webhook-only notify block against the schema and checks the connector entry is there (ff323bc).
  • The new page is linked from the services index and from "Notification Configuration" in alerts.md (279efe2).

One thing the rebase brought up that a textual merge would miss: #9972 added reports created from an embed, which yamlForManagedReport restricts to email and rejects for Slack. Without a guard, an embed user could have sent a report to any webhook URL, so 1fd1903 rejects webhook URLs there too, and the report form doesn't show the webhook section in an embed.

Co-authored with Claude.

Conflict resolutions:
- admin/server/reports.go: keep main's embed-owner unsubscribe guard, then use the PR's reportYAML.unsubscribe helper.
- runtime/parser/parser_test.go: keep both TestValidateWebhookURLs and main's TestTemplatesRejectProjectVariables.
- BaseScheduledReportForm.svelte: keep main's embed owner email input; keep the PR's Slack-or-webhooks comment.
- ScheduledReportDialog.svelte: count main's embed owner email as a recipient alongside the PR's webhook URLs.
- api.proto: renumber ReportOptions.webhook_urls to 26, since main took 25 for embed_owner_email; regenerate.

Fixes for main's changes:
- Pass the new embedOwnerEmail argument to yamlForManagedReport in the PR's tests.
- Add Korean translations for the webhook strings and update report_email_validation in ko.json.
- Restore main's key order for report_email_validation in en.json and es.json.
@dfliess

dfliess commented Nov 9, 2026

Copy link
Copy Markdown
Contributor Author

Ready for another look when you have time. Everything from your review is addressed in the round from November 2, and the branch is current after your merge. The Slack hardening will go in its own PR, as you asked.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Area:Alerts Size:L Large change: 500-1,999 lines Type:Feature New feature request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants