Documentation
¶
Overview ¶
Package notifications sends user-facing convenience notifications (workflow completion, ...) to configured channels. It is deliberately NOT the admin monitoring path — leaked workers, DB pool saturation, quota exhaustion etc. surface through server/metrics as Prometheus gauges/counters that Zabbix / Grafana consume. Alerting logic (silence, escalate, page on-call) lives in the monitoring tool.
This package is best-effort: a failed webhook is logged and the caller keeps going. Users who miss a workflow-done ping are mildly annoyed; that's the whole failure surface.
Backends:
- zulip: knows the Zulip incoming-webhook JSON shape natively. Just pass the webhook URL (with stream + topic + api_key as URL params, per Zulip's docs).
- webhook: generic HTTP POST with a Go-template body. Fits Slack, Discord, ntfy, or any custom endpoint that accepts JSON or text.
- log: writes the notification to the server log. Always available; useful as a smoke test or as a fallback channel.
The Dispatcher reads a route table (event -> channels) and fans each Emit() call out to every subscribed channel in parallel. No retries, no dedup, no rate limit for now — add those the day we need them.
Index ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type Dispatcher ¶
type Dispatcher struct {
// contains filtered or unexported fields
}
Dispatcher owns the channel set + routing table and dispatches events to matching channels in parallel.
func New ¶
func New(cfg config.NotificationsConfig) *Dispatcher
New builds a Dispatcher from the given config block. Unknown backend kinds and malformed URLs are logged and skipped rather than failing server startup — a bad notification channel is not a reason to refuse to boot.
func (*Dispatcher) Emit ¶
func (d *Dispatcher) Emit(ctx context.Context, msg Message)
Emit dispatches msg to every channel subscribed to msg.Event. Deliveries run in parallel; the call returns as soon as they are launched. Errors are logged inside each backend's Send — this method never returns one because callers on hot paths (workflow completion) shouldn't be blocked by a slow webhook.
The caller's ctx is INTENTIONALLY not passed down to the send goroutines. Callers on the workflow-status path build a ctx with a short timeout for their DB queries and defer-cancel it on return; since Emit doesn't wg.Wait, that cancel fires while the HTTP request is still in flight and kills it with "context canceled" (observed 2026-09-10 on the very first live workflow.terminal to Zulip). Each backend caps request duration via its own http.Client.Timeout, so context.Background here is both safe and necessary.
type EventType ¶
type EventType string
EventType is the routing key. Backends never look at this — the Dispatcher does, matching against the configured routes.
const ( // EventWorkflowTerminal fires when a workflow transitions to S or // F. Meta carries workflow_id, name, status, and counts. EventWorkflowTerminal EventType = "workflow.terminal" )
type Log ¶
type Log struct {
// contains filtered or unexported fields
}
Log is a Notifier that writes to the standard server log. Always available, no config beyond name — useful as a smoke test channel or a "notify me on the console" for a lab operator without a separate integration.
func NewLog ¶
func NewLog(cfg config.NotificationChannel) *Log
type Message ¶
type Message struct {
Event EventType
Severity Severity
Title string
Body string
// Meta is arbitrary key/value context. The Zulip backend ignores
// it (Body carries the human-readable text); the generic webhook
// backend exposes it to its body template as `.Meta.<key>`.
Meta map[string]string
}
Message is one notification. Backends turn (Title, Body, Meta) into their own on-the-wire format. Keep Body compact — most channels line-truncate at some point.
type Notifier ¶
type Notifier interface {
// Name is the channel name from config — used in log lines and
// as the routing key.
Name() string
// Send delivers the message. Errors are logged and swallowed by
// the Dispatcher — a broken webhook must never bring down the
// caller's code path.
Send(ctx context.Context, msg Message) error
}
Notifier is one delivery target.
type Severity ¶
type Severity string
Severity ranks the message. Backends can filter on it; the initial backends ignore it. Kept in the interface from day one so a future "warn+ only" channel doesn't force a signature change.
type Webhook ¶
type Webhook struct {
// contains filtered or unexported fields
}
Webhook is a generic HTTP-POST notifier with a templated body. The template runs on the Message plus a small "toJSON" helper, so most JSON-eating endpoints (Slack, Discord, ntfy, custom) fit with one config line.
Configuration:
url: required, the endpoint URL
method: optional, defaults to POST
template: optional Go text/template. Default: JSON with a
conventional shape ({event, severity, title, body, meta}).
content_type: optional, defaults to application/json
headers: optional map, added to every request (e.g. Authorization)
The template has access to:
{{.Event}} {{.Severity}} {{.Title}} {{.Body}} {{.Meta.foo}} …
{{toJSON .Body}} -- JSON-quote a string
{{toJSON .Meta}} -- JSON-serialise the meta map
func NewWebhook ¶
func NewWebhook(cfg config.NotificationChannel) (*Webhook, error)
type Zulip ¶
type Zulip struct {
// contains filtered or unexported fields
}
Zulip is the incoming-webhook Notifier. Configure with the URL Zulip's "Incoming webhook (generic)" integration hands out — it looks like:
https://<subdomain>.zulipchat.com/api/v1/external/generic?api_key=...&stream=alerts&topic=scitq
The URL carries stream + topic + api_key as query params. We only need to POST `content` (the message body) and optionally `topic` (overrides the one baked into the URL). See: https://zulip.com/integrations/doc/generic
type ZulipDM ¶
type ZulipDM struct {
// contains filtered or unexported fields
}
ZulipDM is the Notifier that sends private (direct) messages via Zulip's `messages` REST API. Distinct from the `zulip` backend (which POSTs to /api/v1/external/generic for stream messages) — incoming-webhook bots CANNOT hit /api/v1/messages, so this backend requires a Zulip bot of type "Generic bot" whose bot_email + api_key are given in the channel config.
The recipient email is not baked into the channel — it's read from each Message's `Meta["run_by_email"]` at Send time. That's what makes DM routing per-workflow-owner work without a static channel-per-user setup: one `zulip-dm` channel serves every user whose scitq account has an email address set.
Config:
channels:
- name: gmt-zulip-dm
kind: zulip-dm
url: "https://gmt.zulipchat.com" # Zulip realm, no path
options:
bot_email: "scitq-bot@gmt.zulipchat.com"
api_key: "${ZULIP_API_KEY}"
# recipient_meta_key defaults to "run_by_email"; override if
# a future event carries the recipient under a different key.
recipient_meta_key: "run_by_email"
If the message's Meta lacks the recipient key (or has it blank), Send logs a one-liner and returns nil — a user with no email set is not an error, just a "no target for this notification". No retries; the caller doesn't wait on us anyway.
func NewZulipDM ¶
func NewZulipDM(cfg config.NotificationChannel) (*ZulipDM, error)