Replace transmission-exporter with homegrown Python exporter (#283)
All checks were successful
All checks were successful
## Summary - Replace unmaintained `metalmatze/transmission-exporter:master` sidecar with a homegrown Python exporter - Uses `prometheus_client` + `transmission-rpc` with collect-on-scrape pattern (fresh metrics per scrape, no stale labels) - Same metric names so existing Grafana Transmission dashboard works unchanged - Container built with `uv` for dependency management, follows `grafana-sidecar` Dockerfile pattern ## Changes - **New:** `containers/transmission-exporter/exporter.py` — single-file exporter (~130 lines) - **New:** `containers/transmission-exporter/Dockerfile` — multi-stage Alpine build with uv - **Modified:** `argocd/manifests/torrent/deployment.yaml` — swap sidecar image reference - **Modified:** `argocd/manifests/torrent/kustomization.yaml` — add image tag entry - **Modified:** `service-versions.yaml` — add transmission-exporter entry ## Deployment and Testing - [ ] Build container: `mise run container-build-and-release transmission-exporter` - [ ] Update kustomization.yaml newTag with build SHA - [ ] Branch deploy: `argocd app set torrent --revision feature/transmission-exporter-python && argocd app sync torrent` - [ ] Verify metrics: `kubectl -n torrent --context=minikube-indri port-forward svc/transmission 19091:19091` then `curl localhost:19091/metrics | grep transmission_` - [ ] Verify Grafana Transmission dashboard panels populate - [ ] After merge: `argocd app set torrent --revision main && argocd app sync torrent` Reviewed-on: #283
This commit is contained in:
parent
91d84e54d5
commit
f2704b26da
6 changed files with 182 additions and 1 deletions
21
containers/transmission-exporter/Dockerfile
Normal file
21
containers/transmission-exporter/Dockerfile
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# Transmission Prometheus exporter - collect-on-scrape, no polling loop
|
||||
# uv run --script handles dependency resolution at runtime
|
||||
|
||||
ARG CONTAINER_APP_VERSION=1.0.0
|
||||
|
||||
FROM python:3.13-alpine3.23
|
||||
|
||||
LABEL org.opencontainers.image.title="Transmission Exporter"
|
||||
LABEL org.opencontainers.image.description="Prometheus exporter for Transmission BitTorrent client"
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV UV_CACHE_DIR=/tmp/uv-cache
|
||||
|
||||
WORKDIR /app
|
||||
COPY exporter.py .
|
||||
|
||||
EXPOSE 19091
|
||||
USER 65534:65534
|
||||
CMD ["uv", "run", "--script", "/app/exporter.py"]
|
||||
150
containers/transmission-exporter/exporter.py
Normal file
150
containers/transmission-exporter/exporter.py
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
#!/usr/bin/env -S uv run --script
|
||||
# /// script
|
||||
# requires-python = ">=3.13"
|
||||
# dependencies = [
|
||||
# "prometheus-client",
|
||||
# "transmission-rpc",
|
||||
# ]
|
||||
# ///
|
||||
"""Minimal Prometheus exporter for Transmission, using collect-on-scrape."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import urllib.parse
|
||||
from wsgiref.simple_server import make_server
|
||||
|
||||
from prometheus_client import make_wsgi_app
|
||||
from prometheus_client.core import REGISTRY, GaugeMetricFamily
|
||||
from transmission_rpc import Client
|
||||
|
||||
|
||||
def parse_addr(addr: str) -> dict:
|
||||
"""Parse TRANSMISSION_ADDR into kwargs for transmission_rpc.Client."""
|
||||
parsed = urllib.parse.urlparse(addr)
|
||||
kwargs: dict = {}
|
||||
if parsed.hostname:
|
||||
kwargs["host"] = parsed.hostname
|
||||
if parsed.port:
|
||||
kwargs["port"] = parsed.port
|
||||
if parsed.scheme == "https":
|
||||
kwargs["protocol"] = "https"
|
||||
if parsed.path and parsed.path != "/":
|
||||
kwargs["path"] = parsed.path.strip("/")
|
||||
if parsed.username:
|
||||
kwargs["username"] = parsed.username
|
||||
if parsed.password:
|
||||
kwargs["password"] = parsed.password
|
||||
return kwargs
|
||||
|
||||
|
||||
class TransmissionCollector:
|
||||
def __init__(self, client_kwargs: dict):
|
||||
self._client_kwargs = client_kwargs
|
||||
|
||||
def collect(self):
|
||||
try:
|
||||
client = Client(**self._client_kwargs)
|
||||
session = client.session_stats()
|
||||
torrents = client.get_torrents()
|
||||
except Exception as e:
|
||||
print(f"Error collecting metrics: {e}", file=sys.stderr)
|
||||
return
|
||||
|
||||
yield _gauge(
|
||||
"transmission_session_stats_download_speed_bytes",
|
||||
"Current download speed in bytes/s",
|
||||
session.download_speed,
|
||||
)
|
||||
yield _gauge(
|
||||
"transmission_session_stats_upload_speed_bytes",
|
||||
"Current upload speed in bytes/s",
|
||||
session.upload_speed,
|
||||
)
|
||||
yield _gauge(
|
||||
"transmission_session_stats_torrents_active",
|
||||
"Number of active torrents",
|
||||
session.active_torrent_count,
|
||||
)
|
||||
yield _gauge(
|
||||
"transmission_session_stats_torrents_total",
|
||||
"Total number of torrents",
|
||||
session.torrent_count,
|
||||
)
|
||||
|
||||
downloaded = GaugeMetricFamily(
|
||||
"transmission_session_stats_downloaded_bytes",
|
||||
"Total bytes downloaded",
|
||||
labels=["type"],
|
||||
)
|
||||
downloaded.add_metric(["cumulative"], session.cumulative_stats.downloaded_bytes)
|
||||
yield downloaded
|
||||
|
||||
uploaded = GaugeMetricFamily(
|
||||
"transmission_session_stats_uploaded_bytes",
|
||||
"Total bytes uploaded",
|
||||
labels=["type"],
|
||||
)
|
||||
uploaded.add_metric(["cumulative"], session.cumulative_stats.uploaded_bytes)
|
||||
yield uploaded
|
||||
|
||||
t_download = GaugeMetricFamily(
|
||||
"transmission_torrent_download_bytes",
|
||||
"Torrent total downloaded bytes",
|
||||
labels=["name"],
|
||||
)
|
||||
t_upload = GaugeMetricFamily(
|
||||
"transmission_torrent_upload_bytes",
|
||||
"Torrent total uploaded bytes",
|
||||
labels=["name"],
|
||||
)
|
||||
t_ratio = GaugeMetricFamily(
|
||||
"transmission_torrent_ratio",
|
||||
"Torrent upload ratio",
|
||||
labels=["name"],
|
||||
)
|
||||
t_uploaded_ever = GaugeMetricFamily(
|
||||
"transmission_torrent_uploaded_ever",
|
||||
"Torrent total uploaded ever in bytes",
|
||||
labels=["name"],
|
||||
)
|
||||
t_done = GaugeMetricFamily(
|
||||
"transmission_torrent_done",
|
||||
"Torrent percent done (0.0-1.0)",
|
||||
labels=["name"],
|
||||
)
|
||||
|
||||
for t in torrents:
|
||||
name = t.name or "unknown"
|
||||
t_download.add_metric([name], t.total_size * t.percent_done)
|
||||
t_upload.add_metric([name], t.uploaded_ever)
|
||||
t_ratio.add_metric([name], t.ratio)
|
||||
t_uploaded_ever.add_metric([name], t.uploaded_ever)
|
||||
t_done.add_metric([name], t.percent_done)
|
||||
|
||||
yield t_download
|
||||
yield t_upload
|
||||
yield t_ratio
|
||||
yield t_uploaded_ever
|
||||
yield t_done
|
||||
|
||||
|
||||
def _gauge(name: str, doc: str, value: float) -> GaugeMetricFamily:
|
||||
g = GaugeMetricFamily(name, doc)
|
||||
g.add_metric([], value)
|
||||
return g
|
||||
|
||||
|
||||
def main():
|
||||
addr = os.environ.get("TRANSMISSION_ADDR", "http://localhost:9091")
|
||||
port = int(os.environ.get("EXPORTER_PORT", "19091"))
|
||||
|
||||
client_kwargs = parse_addr(addr)
|
||||
REGISTRY.register(TransmissionCollector(client_kwargs))
|
||||
|
||||
print(f"Listening on :{port}, scraping {addr}")
|
||||
httpd = make_server("", port, make_wsgi_app())
|
||||
httpd.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue