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)
- WebSocket connection health
- Active transcode session count
- WebSocket for real-time session tracking — listens to the Plex notification stream for instant session updates instead of polling on an interval
- Single binary with no runtime dependencies — only two direct Go dependencies (
coder/websocketandprometheus/client_golang), everything else is stdlib - Distroless and rootless — runs on
gcr.io/distroless/staticas UID 65534 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.
- WebSocket is required. The exporter uses the Plex WebSocket notification stream for real-time session tracking. If your Plex server is behind a reverse proxy, ensure WebSocket connections are forwarded correctly.
- 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:
TZ: "Europe/Paris"
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) |
http://plex:32400 |
Yes |
PLEX_TOKEN |
Plex authentication token for the server administrator. Get it from Plex Web → Settings → XML view → myPlexAccessToken | - | Yes |
TZ |
Container timezone | Europe/Paris |
No |
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_estimated_transmit_bytes_total |
Counter | server, server_id |
Estimated bytes transmitted based on session bitrates. 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_websocket_connected |
Gauge | server, server_id |
WebSocket connection status: 1 = connected, 0 = disconnected |
plex_http_reachable |
Gauge | server, server_id |
HTTP polling reachability: 1 = last refresh succeeded, 0 = failed |
plex_exporter_errors_total |
Counter | server, server_id, type |
Exporter error count by type. Types: refresh, websocket_dial, websocket_read, invalid_message, 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, 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 |
direct play, 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. For movies: title = movie
name, others are empty.
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 becomes unhealthy only if the initial Plex connection fails or the metrics server fails to start; WebSocket disconnects do not trigger unhealthy status because the exporter reconnects automatically with exponential backoff (monitor via plex_websocket_connected).
| Metric | Value |
|---|---|
| Test Coverage | 81.6% |
| Tests | 188 |
| Cyclomatic Complexity (avg) | 4.0 |
| Cognitive Complexity (avg) | 3.8 |
| Mutation Efficacy | 87.3% (59 runs) |
| Test Framework | Property-based (rapid) + table-driven |
Tests cover Prometheus metric collection (all 13 metric descriptors, server/library/session metrics, Plex Pass gating), session tracking (play/stop/resume lifecycle, concurrent sessions, bandwidth accumulation, prune timeouts), transcode detection and subtitle classification, library item counting with artist-type fallback, bandwidth tracking with boundary conditions, HTTP client retry logic, and the full refresh cycle (server info, library items, resources). Property-based tests verify invariants across all pure functions.
Not tested: WebSocket connection management, the main event loop,
and ticker-based refresh scheduling — these are I/O-bound runtime
paths. WebSocket health is monitored via the
plex_websocket_connected Prometheus metric.
No vulnerabilities found. All scans clean across 7 tools.
| 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. Runs as
nonroot on a distroless base image with no shell.
Details for advanced users: Plex response bodies capped at
10 MB via io.LimitReader. WebSocket messages capped at 1 MB.
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. Semgrep flags
the /tmp/.healthy marker and the opt-in TLS skip (both
intentional).
All dependencies are updated automatically via Renovate and pinned by digest or version for reproducibility.
| Dependency | Version | Source |
|---|---|---|
| golang | 1.26-alpine |
Go |
| gcr.io/distroless/static-debian13 | nonroot |
Distroless |
| github.com/coder/websocket | v1.8.14 |
GitHub |
| github.com/prometheus/client_golang | v1.23.2 |
GitHub |
| github.com/prometheus/client_model | v0.6.2 |
GitHub |
| golang.org/x/sync | v0.20.0 |
Go stdlib |
| pgregory.net/rapid | v1.3.0 |
pkg.go.dev |
This is an original tool that builds upon prometheus-plex-exporter.
- Grafana Hackathon 2022 — the original hackathon project that started it all
- prometheus-plex-exporter by @jsclayton — the post-hackathon fork that added graceful shutdown and Go module updates
- prometheus-plex-exporter by @timothystewart6 — the actively maintained upstream with multi-package architecture, transcode tracking, and configurable library refresh
- Plex Media Server API — the official API documentation
- coder/websocket — Go WebSocket implementation
- prometheus/client_golang — Prometheus instrumentation library for Go
Issues and pull requests are welcome. Please open an issue first for larger changes so the approach can be discussed before implementation.
These images are built with care and follow security best practices, but they are intended for homelab 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.