grafana-matrix-alert-relay¶
Modules
A small, single-file NixOS module that forwards Grafana alerts into a Matrix room, with no bridge, no bot framework, and no third-party dependency — just Python stdlib on loopback.
The problem¶
Grafana's webhook contact point speaks one dialect: it POSTs a JSON payload to a URL. The Matrix send API speaks another:
It requires PUT (not POST) and a caller-supplied transaction id in the
path. Grafana can produce neither. So you can't point Grafana straight at
Matrix — the verb is wrong and the txn id is missing.
The fix¶
Run a minimal relay on 127.0.0.1. Grafana POSTs alerts to it; the relay
re-shapes each alert into a well-formed Matrix PUT and sends it with a
bearer token.
The insight worth stealing: deterministic transaction ids¶
Matrix's txn id exists specifically for idempotency — resend the same
PUT .../send/m.room.message/{txn} and the server treats it as the same
message rather than a new one. Most relays throw a random UUID at it, which
defeats the mechanism: every Grafana retry or duplicate delivery becomes
another line in the room.
This relay derives the txn id instead:
The message body is hashed, and the current time is bucketed into 5-minute windows. Identical alerts delivered or retried inside the same window reuse the same txn id, so the Matrix server dedupes them for you — no local state, no seen-cache, no cron cleanup. The 5-minute bucket bounds how long a duplicate is suppressed; a genuinely re-firing alert in a later window posts again, as it should.
Secret handling¶
The bearer token is delivered through systemd LoadCredential, materialised
into the per-unit credentials directory (%d/matrix-token) at 0400, owned by
the DynamicUser. It is never placed in the unit environment or on the
command line, so it doesn't leak into systemctl show, /proc/<pid>/environ,
or the journal. You supply the token file with whatever secret manager you use
(agenix, sops-nix, a deploy step); the module only needs a path.
Usage¶
{
imports = [ ./grafana-matrix-alert-relay ];
services.grafana-matrix-alert-relay = {
enable = true;
matrixBase = "https://matrix.example.com";
room = "!aBcDeFgHiJkLmNoPqR:example.com"; # internal room id, not the #alias
tokenFile = "/run/secrets/matrix-alert-token";
# port = 9099; # default
};
}
Then add a Grafana webhook contact point pointing at
http://127.0.0.1:9099/alert (any path works) and a notification policy that
routes the alerts you care about to it. Example provisioning:
# grafana contact point
apiVersion: 1
contactPoints:
- orgId: 1
name: matrix
receivers:
- uid: matrix-relay
type: webhook
settings:
url: http://127.0.0.1:9099/alert
httpMethod: POST
Options¶
| Option | Type | Default | Notes |
|---|---|---|---|
enable |
bool | false |
|
port |
port | 9099 |
Loopback port the relay listens on. |
matrixBase |
str | — | Homeserver client-server API base URL. |
room |
str | — | Internal room id (!...), not the #alias. The bot must be joined. |
tokenFile |
path | — | File with the raw bearer token; loaded via LoadCredential. |
Getting the token and room id¶
- Token: log in as the bot account once and grab its access token (e.g.
via
POST /_matrix/client/v3/login, or from an Element session's Help & About). Treat it like a password. - Room id: the internal id starts with
!and is stable; the#name:serveralias is just a pointer. In Element it's under Room settings → Advanced → Internal room ID. The bot account must be a member of the room before the relay can post.
Message format¶
Alerts render as one line each:
Status maps to a [FIRING] / [RESOLVED] / [PENDING] prefix; the summary is
taken from annotations.summary, falling back to annotations.description
then the alert name, and truncated at 400 chars. Adjust format_alert in
default.nix if you want HTML formatting (m.room.message also accepts
format: org.matrix.custom.html with a formatted_body).
Caveats¶
- Loopback only. The relay binds
127.0.0.1. Run it on the same host as Grafana. It does no auth on the inbound side — anything that can reach the port can post to your room, so don't expose it. - Egress is open. The unit hardening (
DynamicUser,ProtectSystem,RestrictNamespaces, ...) is tight, butIPAddressAllowpermits all outbound because the relay needs DNS plus the homeserver. The token-gated room is the real boundary. Front it with an egress proxy if you need per-hostname control. - Fire-and-forget. A failed Matrix POST is logged to stderr and dropped; there's no retry queue. For alerting this is usually fine (the next evaluation re-notifies), but it is not a guaranteed-delivery pipe.
- One room. Multi-room / severity-based routing would mean running more
than one instance (they can share a port only if you change it), or teaching
format_alert/post_matrixto pick a room from labels.
Source¶
modules/grafana-matrix-alert-relay/default.nix
# grafana-matrix-alert-relay
#
# A tiny NixOS module that bridges Grafana's webhook contact point to a Matrix
# room. Grafana can only POST JSON to a URL; the Matrix send API is
# `PUT /_matrix/client/v3/rooms/{room}/send/m.room.message/{txn}` and needs a
# caller-supplied transaction id Grafana cannot generate. This relay listens on
# loopback, re-shapes each alert into a well-formed Matrix PUT, and uses a
# deterministic (sha1-of-body, 5-minute bucket) transaction id so retries and
# duplicate deliveries collapse server-side instead of spamming the room.
#
# Import it, set the options, and point a Grafana webhook contact point at
# http://127.0.0.1:<port>/alert (any path works — the relay accepts every POST).
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.grafana-matrix-alert-relay;
relayScript = pkgs.writeText "grafana-matrix-relay.py" ''
"""Webhook -> Matrix relay for Grafana alerts."""
import datetime as dt
import hashlib
import http.server
import json
import os
import sys
import urllib.parse
import urllib.request
PORT = int(os.environ.get("RELAY_PORT", "9099"))
MATRIX_BASE = os.environ["MATRIX_BASE"].rstrip("/")
ROOM = os.environ["MATRIX_ROOM"]
with open(os.environ["MATRIX_TOKEN_FILE"]) as _f:
TOKEN = _f.read().strip()
STATUS_PREFIX = {
"firing": "[FIRING]",
"resolved": "[RESOLVED]",
"pending": "[PENDING]",
}
def format_alert(a: dict) -> str:
labels = a.get("labels", {}) or {}
annot = a.get("annotations", {}) or {}
status = a.get("status", "?")
name = labels.get("alertname") or annot.get("summary") or "alert"
prefix = STATUS_PREFIX.get(status, f"[{status}]")
summary = annot.get("summary") or annot.get("description") or name
if len(summary) > 400:
summary = summary[:400] + "..."
host = labels.get("host") or labels.get("instance") or ""
host_str = f" host={host}" if host else ""
return f"{prefix} {name}{host_str}: {summary}"
def deterministic_txn(body: str) -> str:
# sha1 of the body, bucketed into 5-minute windows. Identical alerts
# delivered/retried within the same window reuse the same txn id, so the
# Matrix server dedupes them instead of posting the message twice.
h = hashlib.sha1(body.encode()).hexdigest()
bucket = int(dt.datetime.now().timestamp() // 300)
return f"grafana-{bucket}-{h[:16]}"
def post_matrix(body: str) -> int:
txn = deterministic_txn(body)
encoded_room = urllib.parse.quote(ROOM, safe="")
url = f"{MATRIX_BASE}/_matrix/client/v3/rooms/{encoded_room}/send/m.room.message/{txn}"
payload = json.dumps({"msgtype": "m.text", "body": body}).encode()
req = urllib.request.Request(
url, data=payload, method="PUT",
headers={
"Authorization": f"Bearer {TOKEN}",
"Content-Type": "application/json",
},
)
with urllib.request.urlopen(req, timeout=15) as resp:
return resp.status
class Handler(http.server.BaseHTTPRequestHandler):
def do_POST(self):
length = int(self.headers.get("Content-Length", "0") or 0)
raw = self.rfile.read(length).decode("utf-8", "replace")
try:
payload = json.loads(raw)
except Exception:
self.send_response(400)
self.end_headers()
return
alerts = payload.get("alerts") or []
if not alerts:
self.send_response(204)
self.end_headers()
return
for a in alerts:
try:
body = format_alert(a)
post_matrix(body)
except Exception as exc:
sys.stderr.write(f"matrix post failed: {exc}\n")
self.send_response(200)
self.end_headers()
def log_message(self, fmt, *args):
sys.stderr.write("relay " + (fmt % args) + "\n")
def main():
srv = http.server.HTTPServer(("127.0.0.1", PORT), Handler)
sys.stderr.write(f"relay listening on 127.0.0.1:{PORT} -> room {ROOM}\n")
srv.serve_forever()
if __name__ == "__main__":
main()
'';
in
{
options.services.grafana-matrix-alert-relay = {
enable = lib.mkEnableOption "webhook -> Matrix relay for Grafana alerts";
port = lib.mkOption {
type = lib.types.port;
default = 9099;
description = "Loopback port the relay listens on. Point Grafana's webhook contact point at http://127.0.0.1:<port>/alert.";
};
matrixBase = lib.mkOption {
type = lib.types.str;
example = "https://matrix.example.com";
description = "Base URL of the Matrix homeserver's client-server API.";
};
room = lib.mkOption {
type = lib.types.str;
example = "!aBcDeFgHiJkLmNoPqR:example.com";
description = ''
Internal Matrix room ID (starts with `!`, not the human `#alias`) that
alerts are posted to. Not a secret — room IDs are public identifiers;
the bearer token is what gates posting. The relay's bot account must
already be joined to this room.
'';
};
tokenFile = lib.mkOption {
type = lib.types.path;
example = "/run/secrets/matrix-alert-token";
description = ''
Path to a file containing the Matrix bearer access token as its raw
value (no `KEY=` env-file prefix, no trailing newline required). It is
handed to the unit via systemd `LoadCredential`, so it never appears in
the environment or on the command line. Provide it with your secret
manager of choice (agenix, sops-nix, a deploy step, ...).
'';
};
};
config = lib.mkIf cfg.enable {
systemd.services.grafana-matrix-alert-relay = {
description = "Webhook -> Matrix relay for Grafana alerts";
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
environment = {
RELAY_PORT = toString cfg.port;
MATRIX_BASE = cfg.matrixBase;
MATRIX_ROOM = cfg.room;
# %d is the systemd credentials directory; see LoadCredential below.
MATRIX_TOKEN_FILE = "%d/matrix-token";
};
serviceConfig = {
Type = "simple";
DynamicUser = true;
# Token is materialised into the per-unit credentials dir (0400, owned
# by the DynamicUser), readable at %d/matrix-token — never in env/argv.
LoadCredential = "matrix-token:${cfg.tokenFile}";
ExecStart = "${pkgs.python3}/bin/python3 ${relayScript}";
Restart = "on-failure";
RestartSec = "10s";
NoNewPrivileges = true;
ProtectHome = true;
ProtectSystem = "strict";
PrivateTmp = true;
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
LockPersonality = true;
SystemCallArchitectures = "native";
# Egress is left open (0.0.0.0/0) because the relay needs DNS plus the
# Matrix homeserver, and the token-gated room is the real security
# boundary — a tighter allowlist buys little here. If you want
# per-hostname egress control, front it with an egress proxy instead.
IPAddressDeny = "any";
IPAddressAllow = [
"127.0.0.0/8"
"::1/128"
"0.0.0.0/0"
"::/0"
];
};
};
};
}