See what your Plex server is doing in Grafana — sessions, libraries, bandwidth, transcoding.
Connects to your Plex Media Server and exposes metrics (active sessions, library sizes, bandwidth, transcoding status) in a format that Prometheus can scrape and Grafana can visualize.
Key metrics exposed:
- Library duration, storage, and item counts (movies, episodes, tracks)
- Active session details (user, device, resolution, stream type)
- Transcode type detection (video/audio/both) and subtitle handling
- Session bandwidth and location (LAN/WAN)
- Host CPU and memory utilization (Plex Pass)
- Bandwidth transmission totals (Plex Pass)
- HTTP polling reachability
- Active transcode session count
- Polling
/status/sessionsfor real-time session tracking — polls the Plex sessions API on a short interval (5s) for near-instant session detection, with the tracker pruning sessions after 60s of inactivity - Single binary with no runtime dependencies — minimal direct Go dependencies (
prometheus/client_golang), everything else is stdlib - Distroless and rootless — runs on
gcr.io/distroless/static-debian13as UID 65532 with no shell or package manager, minimizing attack surface - Prometheus-native — exposes a standard
/metricsendpoint that works with any Prometheus-compatible scraper and any Grafana dashboard, no custom visualization layer
- Plex Pass features degrade gracefully. CPU/memory utilization and bandwidth statistics require Plex Pass. Without it, those metrics are simply absent — the exporter still works for all other metrics.
- Library item counts are cached. Episode, track, and item counts are refreshed every 15 minutes to avoid hammering the Plex API. Counts may lag slightly after large library scans.
Available from both ghcr.io/cplieger/plex-exporter and docker.io/cplieger/plex-exporter — identical images and tags.
services:
plex-exporter:
image: ghcr.io/cplieger/plex-exporter:latest
container_name: plex-exporter
restart: unless-stopped
user: "1000:1000" # match your host user
environment:
PLEX_SERVER: "http://plex:32400" # full URL including scheme and port
PLEX_TOKEN: "your-plex-token" # admin token from Plex Web settings
ports:
- "9594:9594"| Variable | Description | Default | Required |
|---|---|---|---|
PLEX_SERVER |
Full URL of your Plex Media Server including scheme and port (e.g. http://192.0.2.100:32400) |
- | Yes |
PLEX_TOKEN |
Plex authentication token for the server administrator. Get it from Plex Web → Settings → XML view → myPlexAccessToken | - | Yes |
LISTEN_ADDRESS |
Address and port for the metrics HTTP server | :9594 |
No |
PLEX_CA_CERT_PATH |
Path to a PEM file containing your Plex server's CA certificate. When set, that CA is added to the TLS RootCAs pool — TLS verification stays on, pinned to your CA. Required only when (a) your PLEX_SERVER uses https:// and (b) the cert isn't trusted by the OS bundle (i.e. you signed it yourself or with a private CA). Plain http:// URLs and Plex's official *.plex.direct HTTPS URLs need no TLS env var. |
unset | No |
Pick the configuration that matches your Plex server:
Your PLEX_SERVER looks like |
What to do |
|---|---|
http://plex:32400 (Docker network, LAN, etc.) |
nothing — TLS isn't in use |
https://<hash>.plex.direct:32400 (Plex's official cert) |
nothing — Let's Encrypt is trusted by default |
https://192.0.2.100:32400 or https://plex.local (self-signed / private CA) |
set PLEX_CA_CERT_PATH to the PEM file of the CA that signed your Plex cert |
| Port | Description |
|---|---|
9594 |
Prometheus metrics endpoint (/metrics) and health check (/api/health) |
| Endpoint | Method | Description |
|---|---|---|
/metrics |
GET | Prometheus metrics (see below) |
/api/health |
GET | Returns {"status":"OK"} when ready, 503 when starting/stopping |
| Metric | Type | Labels | Description |
|---|---|---|---|
plex_server_info |
Gauge (always 1) | server, server_id, version, platform, platform_version, plex_pass |
Server metadata and Plex Pass status |
plex_host_cpu_utilization_ratio |
Gauge | server, server_id |
Host CPU utilization as a ratio (0.0–1.0). Requires Plex Pass. |
plex_host_memory_utilization_ratio |
Gauge | server, server_id |
Host memory utilization as a ratio (0.0–1.0). Requires Plex Pass. |
plex_transmit_bytes_total |
Counter | server, server_id |
Cumulative bytes transmitted (from Plex bandwidth API). Requires Plex Pass. Resets on container restart — indicative only. |
plex_active_transcode_sessions |
Gauge | server, server_id |
Number of active video transcode sessions (from root endpoint, no Plex Pass needed) |
plex_http_reachable |
Gauge | server, server_id |
HTTP polling reachability: 1 = last refresh succeeded, 0 = failed |
plex_session_poll_reachable |
Gauge | server, server_id |
Session poll reachability: 1 = last /status/sessions poll succeeded, 0 = failed |
plex_http_retries_total |
Counter | server, server_id |
Total HTTP retries performed by the Plex client's retry round-tripper across all requests |
plex_exporter_errors_total |
Counter | server, server_id, type |
Exporter error count by type. Types: refresh, sessions_fetch, metadata_fetch, invalid_rating_key, metrics_server, library_items. |
| Metric | Type | Labels | Description |
|---|---|---|---|
plex_library_duration_milliseconds |
Gauge | server, server_id, library_type, library, library_id |
Total duration of all items in the library (ms) |
plex_library_storage_bytes |
Gauge | server, server_id, library_type, library, library_id |
Total storage used by the library (bytes) |
plex_library_items |
Gauge | server, server_id, library_type, library, library_id, content_type |
Number of items in the library. content_type is movies, episodes, tracks, photos, or items. Refreshed every 15 minutes. |
| Metric | Type | Labels | Description |
|---|---|---|---|
plex_plays_active |
Gauge | server, server_id, library, library_id, library_type, media_type, title, child_title, grandchild_title, grandchild_index, stream_type, stream_resolution, stream_file_resolution, device, device_type, user, session, transcode_type, subtitle_action, location, local |
Currently active play sessions (1 per session). Use count(plex_plays_active) for total stream count. Removed after 60s of inactivity. |
plex_play_seconds_total |
Counter | (same as above) | Cumulative play time for the session (seconds) |
plex_session_bandwidth_kbps |
Gauge | server, server_id, session, user, location |
Real-time session bandwidth from the Plex Sessions API (kbps) |
plex_session_bitrate_kbps |
Gauge | server, server_id, session, user, location |
Live stream bitrate per session (kbps). Replaces the former stream_bitrate label on plex_plays_active/plex_play_seconds_total, which caused unbounded cardinality as Plex reports changing bitrates during adaptive streaming. |
| Label | Values | Description |
|---|---|---|
stream_type |
directplay, copy, transcode |
How the stream is being delivered |
transcode_type |
none, video, audio, both |
What is being transcoded |
subtitle_action |
none, burn, copy, transcode |
How subtitles are handled |
location |
lan, wan |
Client network location |
local |
true, false |
Whether the client is on the local network |
media_type |
movie, episode, track, etc. |
Plex media type |
For episodes: title = show name, child_title = season,
grandchild_title = episode title, grandchild_index = episode
number (track number for music). For movies: title = movie
name, others are empty.
Beyond the values above, every user-controlled label value is
normalized to a bounded set, so an unexpected Plex response can never
explode Prometheus cardinality: a value outside the documented set
becomes other and missing data becomes unknown. This covers
stream_type, media_type, location, subtitle_action, and the
resolution labels. An empty Plex subtitleDecision is reported as
subtitle_action="none".
The container includes an HTTP health endpoint (/api/health) and a CLI probe (/plex-exporter health) that checks a /tmp/.healthy marker file written once the HTTP server is listening — no shell, HTTP client, or open port required. The container exits (and Docker restarts it) only when the initial Plex connection fails with a non-recoverable error — a bad token or other 4xx (except 408 and 429), the wrong server (404), or a TLS/certificate misconfiguration — or when the metrics server fails to start. A transient initial failure (DNS, dial, timeout, a 408 or 429 rate-limit/timeout response, or a 5xx from a Plex that is still starting up) instead brings the exporter up in a degraded-but-healthy state: it binds /metrics, reports plex_http_reachable=0, and recovers automatically once Plex is reachable again.
No vulnerabilities found. All scans clean.
| Tool | Result |
|---|---|
| govulncheck | No vulnerabilities in call graph |
| golangci-lint (gosec, gocritic) | 0 issues |
| trivy | 0 vulnerabilities (distroless base) |
| grype | 0 vulnerabilities |
| gitleaks | No secrets detected |
| semgrep | 2 info (false positives) |
| hadolint | Clean |
Connects outbound to Plex only. The /metrics endpoint serves
read-only Prometheus data (standard for internal exporters).
PLEX_TOKEN is never logged or exposed in metrics.
Details for advanced users: Plex response bodies capped at
10 MB via io.LimitReader. All HTTP clients use explicit 10s
timeouts; the metrics server sets ReadHeaderTimeout,
ReadTimeout, WriteTimeout, IdleTimeout, and
MaxHeaderBytes (1 MB). Rating keys validated via
strconv.Atoi before URL construction. Explicit
MinVersion: tls.VersionTLS12 set on TLS config.
All dependencies are updated automatically via Renovate and pinned by digest or version for reproducibility.
| Dependency | Source |
|---|---|
| golang | Go |
| gcr.io/distroless/static | Distroless |
| github.com/prometheus/client_golang | GitHub |
| github.com/prometheus/client_model | GitHub |
| golang.org/x/sync | Go stdlib |
| pgregory.net/rapid | pkg.go.dev |
This is an original tool building on the Grafana Hackathon 2022 prometheus-plex-exporter lineage: the @jsclayton post-hackathon fork and the actively maintained @timothystewart6 fork. It also uses the Plex Media Server API and prometheus/client_golang.
Issues and pull requests are welcome. Please open an issue first for larger changes so the approach can be discussed before implementation.
This project is built with care and follows security best practices, but it is intended for personal / self-hosted use. No guarantees of fitness for production environments. Use at your own risk.
This project was built with AI-assisted tooling using Claude Opus and Kiro. The human maintainer defines architecture, supervises implementation, and makes all final decisions.
This project is licensed under the GNU General Public License v3.0.