Replace transmission-exporter with homegrown Python exporter (#283)
All checks were successful
Build Container (Nix) / detect (push) Successful in 2s
Build Container / detect (push) Successful in 2s
Build Container (Nix) / build (transmission-exporter) (push) Successful in 2s
Build Container / build (transmission-exporter) (push) Successful in 19s

## 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:
Erich Blume 2026-03-04 21:55:00 -08:00
commit f2704b26da
6 changed files with 182 additions and 1 deletions

View file

@ -56,7 +56,7 @@ spec:
initialDelaySeconds: 10
periodSeconds: 10
- name: transmission-exporter
image: docker.io/metalmatze/transmission-exporter:master
image: registry.ops.eblu.me/blumeops/transmission-exporter
env:
- name: TRANSMISSION_ADDR
value: "http://localhost:9091"

View file

@ -11,3 +11,5 @@ resources:
images:
- name: registry.ops.eblu.me/blumeops/transmission
newTag: v4.1.1-r1-ab34cbd
- name: registry.ops.eblu.me/blumeops/transmission-exporter
newTag: v1.0.0

View 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"]

View 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()

View file

@ -0,0 +1 @@
Replace unmaintained `metalmatze/transmission-exporter` sidecar with homegrown Python exporter using `prometheus_client` and `transmission-rpc`. Same metric names, so Grafana dashboards work unchanged.

View file

@ -173,6 +173,13 @@ services:
current-version: "4.1.1-r1"
upstream-source: https://github.com/transmission/transmission/releases
- name: transmission-exporter
type: argocd
last-reviewed: 2026-03-04
current-version: "1.0.0"
upstream-source: null
notes: Homegrown Python exporter, no upstream
- name: kiwix
type: argocd
last-reviewed: null