Replace unmaintained transmission-exporter with homegrown Python exporter

Swap out docker.io/metalmatze/transmission-exporter:master for a minimal
Python exporter using prometheus_client and transmission-rpc with the
collect-on-scrape pattern. Same metric names so Grafana dashboards work
unchanged. Uses uv for dependency management in the container build.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Erich Blume 2026-03-04 21:35:54 -08:00
commit 22979572f9
6 changed files with 196 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,31 @@
# Transmission Prometheus exporter - collect-on-scrape, no polling loop
# Two-stage build: uv installs deps into venv, runtime is minimal Alpine
ARG CONTAINER_APP_VERSION=1.0.0
FROM python:3.12-alpine3.22 AS base
FROM base AS builder
WORKDIR /app
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
COPY exporter.py .
RUN uv venv .venv && \
uv pip install --python .venv/bin/python \
"prometheus-client>=0.24,<1.0" \
"transmission-rpc>=7.0,<8.0" && \
find /app/.venv \( -type d -a -name test -o -name tests \) \
-o \( -type f -a -name '*.pyc' -o -name '*.pyo' \) -exec rm -rf '{}' \+
FROM base
LABEL org.opencontainers.image.title="Transmission Exporter"
LABEL org.opencontainers.image.description="Prometheus exporter for Transmission BitTorrent client"
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY --from=builder /app /app
ENV PATH="/app/.venv/bin:$PATH"
EXPOSE 19091
USER 65534:65534
CMD ["python", "-u", "/app/exporter.py"]

View file

@ -0,0 +1,154 @@
# /// script
# requires-python = ">=3.12"
# dependencies = [
# "prometheus-client>=0.24,<1.0",
# "transmission-rpc>=7.0,<8.0",
# ]
# ///
"""Minimal Prometheus exporter for Transmission, using collect-on-scrape."""
import os
import sys
import urllib.parse
from prometheus_client import start_http_server
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
# Session stats
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
# Per-torrent metrics
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}")
start_http_server(port)
# Block forever
import threading
threading.Event().wait()
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