Replace transmission-exporter with homegrown Python exporter #283

Merged
eblume merged 2 commits from feature/transmission-exporter-python into main 2026-03-04 21:55:01 -08:00
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
eblume marked this conversation as resolved

just checking, you probably know this already, but you'll need to include the sha of the commit as well once this is built.

just checking, you probably know this already, but you'll need to include the sha of the commit as well once this is built.

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()
eblume marked this conversation as resolved

wait really, is this idiomatic for transmission's start_http_server func? You need to just block the caller forever... not like await an async or poll an event loop or something?

wait really, is this idiomatic for transmission's start_http_server func? You need to just block the caller forever... not like await an async or poll an event loop or something?

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