Plex Media Server Prometheus exporter with real-time session tracking
A ground-up rewrite of the prometheus-plex-exporter project (originally a Grafana hackathon project), rebuilt for reliability, minimal dependencies, and distroless deployment. Connects to Plex over HTTP and WebSocket to collect metrics in real time and serve them in Prometheus format.
Example use case: You run a Plex Media Server and want to
track library sizes, active sessions, transcode load, bandwidth,
and host resource utilization in Grafana. Point this exporter at
your Plex server, scrape /metrics with Prometheus or Alloy, and
get dashboards covering everything from per-session transcode
details to WebSocket connection health.
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
This is a distroless, rootless container running on
gcr.io/distroless/static with no shell or package manager.
Only two direct Go dependencies: coder/websocket for the Plex
notification stream and prometheus/client_golang for metrics.
This is a complete rewrite — no code is shared with the upstream projects. The architecture and dependency choices are fundamentally different. The comparison below is against the timothystewart6 fork (the actively maintained upstream):
| Upstream | This Project | |
|---|---|---|
| Dependencies | 5 direct (go-plex-client, zap, multierr, prometheus client, prometheus model) | 2 (coder/websocket, prometheus client) |
| Logging | uber-go/zap | stdlib log/slog (zero dep) |
| Plex client | Vendored fork of go-plex-client (~900+ lines in plex.go alone) | Built-in minimal client (~80 lines) |
| Image user | root | nonroot (UID 65534) |
| WebSocket reconnect | Delegated to go-plex-client (no built-in reconnect) | Automatic with exponential backoff (1s→30s) |
| Health check | None | CLI probe (/plex-exporter health) + HTTP /health |
| Transcode tracking | Via vendored client events | Direct WebSocket JSON parsing |
| Session bandwidth | Estimated from bitrates only | Real bandwidth from Plex Session API + estimates |
| Go version | 1.23 | 1.26 |
Additional metrics not in upstream:
plex_websocket_connected— monitor exporter↔Plex connectionplex_active_transcode_sessions— from root endpoint, no Plex Pass neededplex_session_bandwidth_kbps— actual bandwidth per sessionplex_server_infoincludesplex_passlabel- Play metrics include
location(lan/wan) andlocal(true/false)
- 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.
This image is published to both GHCR and Docker Hub:
| Registry | Image |
|---|---|
| GHCR | ghcr.io/cplieger/plex-exporter |
| Docker Hub | docker.io/cplieger/plex-exporter |
# Pull from GHCR
docker pull ghcr.io/cplieger/plex-exporter:latest
# Pull from Docker Hub
docker pull cplieger/plex-exporter:latestBoth registries receive identical images and tags. Use whichever you prefer.
services:
plex-exporter:
image: ghcr.io/cplieger/plex-exporter:latest
container_name: plex-exporter
restart: unless-stopped
user: "1000:1000" # match your host user
mem_limit: 64m
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"
healthcheck:
test:
- CMD
- /plex-exporter
- health
interval: 30s
timeout: 5s
retries: 3
start_period: 15s- Set
PLEX_SERVERto the full URL of your Plex server (e.g.http://192.0.2.100:32400orhttps://plex.local:32400). - Set
PLEX_TOKENto a Plex authentication token belonging to the server administrator. See Finding an authentication token. - The exporter connects immediately, performs an initial metadata
refresh, and starts listening for WebSocket events. Metrics are
available at
/metricswithin seconds. - If your Plex server uses a self-signed TLS certificate, set
SKIP_TLS_VERIFICATION=true. - For Grafana integration, see the Grafana Dashboard section below.
| Variable | Description | Default | Required |
|---|---|---|---|
TZ |
Container timezone | Europe/Paris |
No |
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 |
| Port | Description |
|---|---|
9594 |
Prometheus metrics endpoint (/metrics) and health check (/health) |
| Endpoint | Method | Description |
|---|---|---|
/metrics |
GET | Prometheus metrics (see below) |
/health |
GET | Returns ok if the metrics server is running |
The CLI health probe (/plex-exporter health) checks for a marker
file and does not require HTTP — it works in distroless containers
with no shell or curl.
| 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 |
| 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_total |
Counter | server, server_id, library, library_id, library_type, media_type, title, child_title, grandchild_title, stream_type, stream_resolution, stream_file_resolution, stream_bitrate, device, device_type, user, session, transcode_type, subtitle_action, location, local |
Active play sessions (1 per session). 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) |
| 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.
A ready-to-import Grafana dashboard is included in the repository. It works with Prometheus as the datasource — no special plugins required.
The exporter exposes a standard /metrics endpoint. You need a
Prometheus-compatible scraper to collect the metrics and store them
in a time-series database. Common setups:
- Grafana Alloy → scrapes
/metrics→ pushes to Mimir or Prometheus → Grafana queries the TSDB - Prometheus → scrapes
/metricsdirectly → Grafana queries Prometheus
Add a scrape target for the exporter in your Alloy config or Prometheus config:
# Alloy example
prometheus.scrape "plex_exporter" {
targets = [{"__address__" = "plex-exporter:9594"}]
forward_to = [prometheus.remote_write.mimir.receiver]
}# Prometheus example
scrape_configs:
- job_name: plex-exporter
static_configs:
- targets: ['plex-exporter:9594']- In Grafana, go to Dashboards → Import
- Upload
grafana-dashboard.jsonfrom this repository - Select your Prometheus datasource when prompted
The dashboard includes panels for server info, library sizes and item counts, active sessions with transcode details, bandwidth usage, host resource utilization, and WebSocket connection status.
The container includes both an HTTP health endpoint and a CLI health probe for distroless Docker healthchecks.
The main process writes a marker file at /tmp/.healthy once the
HTTP server is listening. The health subcommand checks for this
file — it requires no shell, HTTP client, or open port.
When it becomes unhealthy:
- The initial connection to Plex fails (bad URL, invalid token)
- The HTTP metrics server fails to start
WebSocket disconnects do not cause unhealthy status. The
exporter automatically reconnects with exponential backoff. The
plex_websocket_connected metric tracks connection state for
alerting.
| Type | Command | Meaning |
|---|---|---|
| Docker | /plex-exporter health |
Exit 0 = metrics server running |
| Metric | Value |
|---|---|
| Test Coverage | 76.3% |
| Tests | 160 |
| Cyclomatic Complexity (avg) | 4.0 |
| Cognitive Complexity (avg) | 4.0 |
| 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 |
| pgregory.net/rapid | v1.2.0 |
pkg.go.dev |
- Always up to date: Base images, packages, and libraries are updated automatically via Renovate. Unlike many community Docker images that ship outdated or abandoned dependencies, these images receive continuous updates.
- Minimal attack surface: When possible, pure Go apps use
gcr.io/distroless/static:nonroot(no shell, no package manager, runs as non-root). Apps requiring system packages use Alpine with the minimum necessary privileges. - Digest-pinned: Every
FROMinstruction pins a SHA256 digest. All GitHub Actions are digest-pinned. - Multi-platform: Built for
linux/amd64andlinux/arm64. - Healthchecks: Every container includes a Docker healthcheck.
- Provenance: Build provenance is attested via GitHub Actions, verifiable with
gh attestation verify.
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
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.