Alertmanager routing to Telegram and Mattermost
Published: 2026-03-04
Alertmanager routes cluster alerts to two places: Telegram for mobile visibility, Mattermost for team threads. Both use the same custom Go template embedded directly in the kube-prom-stack HelmRelease values. The key architecture decision: Alertmanager doesn't talk to Telegram or Mattermost directly — it sends all alerts to abot, a small fan-out proxy.
The Telegram template
The template lives in alertmanager.templateFiles inside the HelmRelease values:
{{- define "telegram.default.message" -}}
{{- $alert := index .Alerts 0 -}}
{{- $desc := reReplaceAll ",?\\s*timestamp:.*$" "" $alert.Annotations.description -}}
{{- if eq .Status "resolved" }}✅
{{- else }}
{{- with .CommonLabels.severity }}
{{- if eq . "critical" }}🔴
{{- else if eq . "warning" }}🟡
{{- else if eq . "info" }}🔵
{{- else }}🟠{{ end }}
{{- else }}🔴{{ end }}
{{- end }} · {{ .CommonLabels.severity | toUpper }} · <a href="{{ .CommonLabels.grafana_url }}">dashboard</a>
🖥 {{ .CommonLabels.cluster }}
📦 {{ .CommonLabels.namespace }}{{ if .CommonLabels.pod }} · {{ .CommonLabels.pod }}{{ end }}
💬 {{ $desc }}
⏰ {{ $alert.StartsAt.Local.Format "2006-01-02 15:04:05" }}
{{- end -}}
Key details:
$descstripstimestamp: ...suffixes from descriptions — Alertmanager sometimes appends them from label values.- Severity maps to a color emoji: 🔴 critical, 🟡 warning, 🔵 info, 🟠 anything else.
- The
clusterlabel is injected byexternalLabelsin the Prometheus spec — every alert carries which cluster it came from. - Resolved alerts always get ✅ regardless of severity.
The Mattermost template
Identical logic, Markdown instead of HTML:
{{- define "mattermost.default.message" -}}
{{- $alert := index .Alerts 0 -}}
{{- $desc := reReplaceAll ",?\\s*timestamp:.*$" "" $alert.Annotations.description -}}
{{- if eq .Status "resolved" }}✅{{ else }}
{{- with .CommonLabels.severity }}
{{- if eq . "critical" }}🔴{{ else if eq . "warning" }}🟡{{ else }}🔵{{ end }}
{{- end }}
{{- end }} **{{ .CommonLabels.severity | toUpper }}** · [dashboard]({{ .CommonLabels.grafana_url }})
🖥 `{{ .CommonLabels.cluster }}`
📦 `{{ .CommonLabels.namespace }}`
💬 {{ $desc }}
⏰ {{ $alert.StartsAt.Local.Format "2006-01-02 15:04:05" }}
{{- end -}}
Mattermost renders Markdown in webhook payloads. Telegram uses HTML with parse_mode=HTML.
Receiver configuration
yamlalertmanager:
enabled: true
config:
global:
resolve_timeout: 5m
route:
group_by: [cluster, namespace, alertname]
group_wait: 30s
group_interval: 5m
repeat_interval: 4h
receiver: telegram-mattermost
routes:
- matchers:
- severity = "critical"
repeat_interval: 1h
receiver: telegram-mattermost
- matchers:
- alertname = "Watchdog"
receiver: "null" # suppress the heartbeat alert
receivers:
- name: "null"
- name: telegram-mattermost
webhook_configs:
- url: "http://abot.observability.svc.cluster.local:9444/alert"
send_resolved: true
Why abot instead of direct webhook
Alertmanager's native Telegram receiver has limitations and Mattermost support requires a specific webhook format. abot provides:
- A single Alertmanager webhook endpoint — no N receivers for N backends.
- Fan-out to Telegram, Mattermost, and anything else.
- Template rendering in Go with full control over the message format.
- Built-in retry with exponential backoff.
- HTTP proxy support — on-prem clusters can't reach
api.telegram.orgdirectly.
Telegram proxy
On-prem clusters route Telegram requests through an internal HTTP proxy:
yamlconfig:
telegram:
botToken: "..."
chatId: "-100..."
proxy: "http://10.16.91.109:3128"
mattermost:
webhookUrl: "https://mattermost.internal/hooks/..."
The proxy is a tinyproxy instance on the internal network that forwards to the public internet.
group_by and repeat_interval
group_by: [cluster, namespace, alertname] ensures alerts from different clusters fire as separate groups. Without cluster in group_by, a flapping alert on dev could suppress the same alert on prod.
| Setting | Value | Rationale |
|---|---|---|
group_wait |
30s | Collect related alerts before sending |
group_interval |
5m | Minimum time between group updates |
repeat_interval (warning) |
4h | Don't spam overnight |
repeat_interval (critical) |
1h | Remind every hour if unresolved |
Routing to different channels by severity
Route critical alerts to a separate on-call channel:
yamlroutes:
- matchers:
- severity = "critical"
receiver: telegram-oncall
- matchers:
- severity = "warning"
receiver: telegram-mattermost
Add a second receiver with a different chat ID for critical-only alerts.
Testing alert routing
bash# Send a test alert to Alertmanager
curl -X POST http://alertmanager.observability.svc.cluster.local:9093/api/v2/alerts \
-H 'Content-Type: application/json' \
-d '[{
"labels": {
"alertname": "TestAlert",
"severity": "warning",
"cluster": "dev",
"namespace": "app"
},
"annotations": {
"description": "This is a test"
}
}]'
# Check abot logs
kubectl logs -n observability deployment/abot --context=dev-k8s -f
# Check active alerts
kubectl port-forward -n observability svc/alertmanager 9093:9093
curl -s http://localhost:9093/api/v2/alerts | jq '[.[] | {name: .labels.alertname, status: .status.state}]'
Common issues
Alerts not firing:
- Check
group_wait— first alert in a group waits 30s before being sent. - Check
repeat_interval— if alert fired recently, it's silenced until the interval passes. - Check inhibition rules — a
criticalalert might be inhibitingwarningalerts.
Resolved alerts not sent:
send_resolved: truemust be set in the webhook config.
Template errors:
alertmanager.yamlhas an unusual template syntax — usealertmanager --check-configto validate.