feat(youko): export transmission metrics and add a Grafana dashboard

nixpkgs has no transmission exporter, so add a small stdlib Python exporter that queries the authenticated netns RPC on loopback, scrape it from VictoriaMetrics, and ship a Transmission dashboard.
This commit is contained in:
Ryan Yin committed 2026-09-26 22:29:06 +08:00
1 parent 94213373cd
commit 9df7a26526
4 files changed
+499

No files matched your search

@@ -0,0 +1,257 @@
{
"annotations": {
"list": []
},
"editable": false,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": null,
"links": [],
"panels": [
{
"datasource": "prometheus-homelab",
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"decimals": 1,
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "red",
"value": null
},
{
"color": "yellow",
"value": 10240
},
{
"color": "green",
"value": 1048576
}
]
},
"unit": "Bps"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 4,
"x": 0,
"y": 0
},
"id": 1,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"textMode": "auto"
},
"targets": [
{
"datasource": "prometheus-homelab",
"expr": "transmission_upload_rate_bytes_per_second",
"legendFormat": "upload",
"refId": "A"
}
],
"title": "Upload rate",
"type": "stat"
},
{
"datasource": "prometheus-homelab",
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"decimals": 1,
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "text",
"value": null
}
]
},
"unit": "Bps"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 4,
"x": 4,
"y": 0
},
"id": 2,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"textMode": "auto"
},
"targets": [
{
"datasource": "prometheus-homelab",
"expr": "transmission_download_rate_bytes_per_second",
"legendFormat": "download",
"refId": "A"
}
],
"title": "Download rate",
"type": "stat"
},
{
"datasource": "prometheus-homelab",
"gridPos": {
"h": 8,
"w": 16,
"x": 8,
"y": 0
},
"id": 3,
"options": {
"legend": {
"displayMode": "list",
"placement": "right",
"showLegend": true
},
"pieType": "donut",
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": "prometheus-homelab",
"expr": "transmission_torrents",
"legendFormat": "{{state}}",
"refId": "A"
}
],
"title": "Torrents by state",
"type": "piechart"
},
{
"datasource": "prometheus-homelab",
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "line",
"fillOpacity": 10,
"lineWidth": 2
},
"unit": "Bps"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 24,
"x": 0,
"y": 8
},
"id": 4,
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
},
"targets": [
{
"datasource": "prometheus-homelab",
"expr": "transmission_upload_rate_bytes_per_second",
"legendFormat": "upload",
"refId": "A"
},
{
"datasource": "prometheus-homelab",
"expr": "transmission_download_rate_bytes_per_second",
"legendFormat": "download",
"refId": "B"
}
],
"title": "Session rates",
"type": "timeseries"
},
{
"datasource": "prometheus-homelab",
"fieldConfig": {
"defaults": {
"unit": "Bps"
},
"overrides": []
},
"gridPos": {
"h": 10,
"w": 24,
"x": 0,
"y": 16
},
"id": 5,
"options": {
"displayMode": "basic",
"orientation": "horizontal",
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"showUnfilled": true
},
"targets": [
{
"datasource": "prometheus-homelab",
"expr": "transmission_torrent_upload_rate_bytes_per_second",
"legendFormat": "{{name}}",
"refId": "A"
}
],
"title": "Per-torrent upload rate",
"type": "bargauge"
}
],
"refresh": "30s",
"schemaVersion": 39,
"tags": ["homelab", "transmission"],
"templating": {
"list": []
},
"time": {
"from": "now-6h",
"to": "now"
},
"timezone": "browser",
"title": "Transmission",
"uid": "transmission",
"version": 1
}
@@ -204,6 +204,22 @@ in
}
];
}
{
job_name = "transmission-exporter";
scrape_interval = "30s";
metrics_path = "/metrics";
static_configs = [
{
# same-host exporter that proxies the transmission netns RPC
targets = [ "127.0.0.1:9555" ];
labels.type = "app";
labels.app = "transmission";
labels.host = "youko";
labels.env = "homelab";
labels.cluster = "homelab";
}
];
}
]
# --- Hosts --- #
++ (lib.attrsets.foldlAttrs
@@ -0,0 +1,46 @@
{
config,
pkgs,
myvars,
...
}:
let
script = pkgs.writeText "transmission-exporter.py" (builtins.readFile ./transmission-exporter.py);
in
{
# There is no transmission exporter in nixpkgs, so expose a small set of
# gauges from the authenticated RPC on loopback for VictoriaMetrics.
systemd.services.transmission-exporter = {
description = "Prometheus exporter for the transmission RPC";
wantedBy = [ "multi-user.target" ];
after = [ "transmission.service" ];
serviceConfig = {
ExecStart = "${pkgs.python3}/bin/python3 ${script}";
Restart = "on-failure";
RestartSec = "10s";
DynamicUser = true;
# The RPC password lives only in the agenix credentials file; hand it to
# the dynamic user through a systemd credential instead of widening the
# file's permissions.
LoadCredential = "transmission-rpc:${config.age.secrets."transmission-credentials.json".path}";
Environment = [
"TRANSMISSION_RPC_URL=http://192.168.5.118:9091/transmission/rpc"
"TRANSMISSION_RPC_USER=${myvars.username}"
"TRANSMISSION_EXPORTER_LISTEN=127.0.0.1:9555"
];
NoNewPrivileges = true;
PrivateTmp = true;
ProtectSystem = "strict";
ProtectHome = true;
RestrictAddressFamilies = [
"AF_INET"
"AF_INET6"
];
IPAddressDeny = [ "any" ];
IPAddressAllow = [
"localhost"
"192.168.5.118"
];
};
};
}
@@ -0,0 +1,180 @@
#!/usr/bin/env python3
"""Minimal Prometheus exporter for Transmission's RPC (standard library only).
Transmission runs inside its own network namespace and its RPC needs
authentication, so we query it here and re-expose a small set of gauges on
loopback for VictoriaMetrics to scrape.
"""
import base64
import json
import os
import urllib.error
import urllib.request
from http.server import BaseHTTPRequestHandler, HTTPServer
RPC_URL = os.environ.get(
"TRANSMISSION_RPC_URL", "http://127.0.0.1:9091/transmission/rpc"
)
RPC_USER = os.environ.get("TRANSMISSION_RPC_USER", "admin")
LISTEN = os.environ.get("TRANSMISSION_EXPORTER_LISTEN", "127.0.0.1:9555")
STATUS = {
0: "stopped",
1: "check-wait",
2: "checking",
3: "download-wait",
4: "downloading",
5: "seed-wait",
6: "seeding",
}
def _password():
directory = os.environ.get("CREDENTIALS_DIRECTORY")
if directory:
path = os.path.join(directory, "transmission-rpc")
if os.path.exists(path):
with open(path, encoding="utf-8") as handle:
data = json.load(handle)
for key in ("rpc-password", "password"):
if data.get(key):
return data[key]
path = os.environ.get("TRANSMISSION_RPC_PASSWORD_FILE")
if path:
with open(path, encoding="utf-8") as handle:
return handle.read().strip()
return os.environ.get("TRANSMISSION_RPC_PASSWORD", "")
_AUTH = "Basic " + base64.b64encode(
f"{RPC_USER}:{_password()}".encode()
).decode()
_SESSION = [""]
def rpc(method, arguments=None):
payload = json.dumps({"method": method, "arguments": arguments or {}}).encode()
request = urllib.request.Request(
RPC_URL,
data=payload,
headers={
"Authorization": _AUTH,
"Content-Type": "application/json",
"X-Transmission-Session-Id": _SESSION[0],
},
)
try:
with urllib.request.urlopen(request, timeout=10) as response:
return json.load(response)["arguments"]
except urllib.error.HTTPError as error:
if error.code == 409:
_SESSION[0] = error.headers.get("X-Transmission-Session-Id", "")
return rpc(method, arguments)
raise
def _escape(value):
return (
str(value).replace("\\", "\\\\").replace('"', '\\"').replace("\n", " ")
)
def _collect():
try:
torrents = rpc(
"torrent-get",
{
"fields": [
"name",
"hashString",
"status",
"rateUpload",
"rateDownload",
"peersConnected",
"uploadRatio",
"percentDone",
"trackerStats",
]
},
)["torrents"]
session = rpc("session-stats")
except Exception as error: # noqa: BLE001 - report any failure as down
return f"# HELP transmission_up 1 if the RPC was reachable.\n# TYPE transmission_up gauge\ntransmission_up 0\n# scrape_error {_escape(error)}\n"
out = [
"# HELP transmission_up 1 if the RPC was reachable.",
"# TYPE transmission_up gauge",
"transmission_up 1",
"# HELP transmission_upload_rate_bytes_per_second Session upload rate.",
"# TYPE transmission_upload_rate_bytes_per_second gauge",
f"transmission_upload_rate_bytes_per_second {session.get('uploadRate', 0)}",
"# HELP transmission_download_rate_bytes_per_second Session download rate.",
"# TYPE transmission_download_rate_bytes_per_second gauge",
f"transmission_download_rate_bytes_per_second {session.get('downloadRate', 0)}",
]
states = {}
for tor in torrents:
labels = (
f'{{name="{_escape(tor["name"])}",'
f'turbohash="{tor["hashString"]}"}}'
)
out.append(
f"transmission_torrent_upload_rate_bytes_per_second{labels} "
f"{tor['rateUpload']}"
)
out.append(
f"transmission_torrent_download_rate_bytes_per_second{labels} "
f"{tor['rateDownload']}"
)
out.append(
f"transmission_torrent_peers_connected{labels} {tor['peersConnected']}"
)
out.append(
f"transmission_torrent_upload_ratio{labels} {tor['uploadRatio']}"
)
out.append(
f"transmission_torrent_percent_done{labels} {tor['percentDone']}"
)
state = STATUS.get(tor["status"], str(tor["status"]))
states[state] = states.get(state, 0) + 1
trackers = tor.get("trackerStats") or []
if trackers:
ok = 1 if trackers[0].get("lastAnnounceSucceeded") else 0
out.append(
f"transmission_torrent_tracker_announce_ok{labels} {ok}"
)
out.append("# HELP transmission_torrents Number of torrents by state.")
out.append("# TYPE transmission_torrents gauge")
for state, count in sorted(states.items()):
out.append(f'transmission_torrents{{state="{state}"}} {count}')
return "\n".join(out) + "\n"
class Handler(BaseHTTPRequestHandler):
def do_GET(self): # noqa: N802 - http.server API
if self.path != "/metrics":
self.send_response(404)
self.end_headers()
return
body = _collect().encode()
self.send_response(200)
self.send_header("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *_args):
pass
def main():
host, port = LISTEN.rsplit(":", 1)
HTTPServer((host, int(port)), Handler).serve_forever()
if __name__ == "__main__":
main()