A Prometheus exporter for Xray and V2Ray. It reads runtime and traffic statistics from Xray's gRPC Stats API, and optionally parses the access log to report who is connecting, from where, and to which destinations.
Rotation is detected by inode, so log parsing is unix only. Linux and macOS builds are published.
From the access log:
- Unique users and open connections over a rolling window
- Top requested domains, with subdomains folded into their registrable domain
- Top direct IP destinations, with localhost, private ranges, and the usual public DNS resolvers filtered out
- Top ASNs with their network name, top countries, and top cities, resolved from GeoLite2 databases the exporter downloads and refreshes on its own
- A world map of connections by country or city
- How traffic splits across your outbounds, including whichever tag your config uses for blocked traffic
From the Stats API:
- Bytes up and down per inbound and per outbound
- Bytes up and down per user, off by default because it is one series per user
- Xray process health: uptime, goroutine count, heap usage, and GC pauses
- Whether the last scrape worked and how long it took
Our CompassVPN dashboard is published on grafana.com if you want a starting point. The metrics reference below has the queries if you would rather build your own.
Linux (amd64, arm64, arm) and macOS (amd64, arm64) builds are on the releases page.
Multi-arch images (amd64, arm64, arm/v7) are published to the GitHub Container Registry:
docker run --rm -it ghcr.io/compassvpn/xray-exporter:latestThree kinds of tag are available: latest follows the newest release, a version tag like v0.6.6 pins to one release, and main is built from the tip of the main branch and may not be stable.
The exporter talks to Xray's Stats API, so the api, stats, and policy blocks all need to be present, along with a routing rule that sends the API inbound to the API outbound:
{
"routing": {
"rules": [
{
"inboundTag": [
"api"
],
"outboundTag": "api"
}
]
},
"policy": {
"levels": {
"0": {
"statsUserUplink": true,
"statsUserDownlink": true
}
},
"system": {
"statsInboundUplink": true,
"statsInboundDownlink": true,
"statsOutboundUplink": true,
"statsOutboundDownlink": true
}
},
"stats": {},
"api": {
"tag": "api",
"services": [
"StatsService"
]
},
"inbounds": [
{
"tag": "api",
"listen": "127.0.0.1",
"port": 54321,
"protocol": "dokodemo-door",
"settings": {
"address": "127.0.0.1"
}
},
{
"tag": "inbound-1",
"port": 12345,
"protocol": "vmess",
"settings": {
"clients": [
{
"email": "email",
"id": "uuid",
"level": 0
}
]
}
}
],
"outbounds": [
{
"tag": "direct",
"protocol": "freedom",
"settings": {}
}
]
}There are two inbounds here. The first listens on port 54321 on localhost and answers the API calls the exporter makes. The second is an ordinary VMess inbound for the user email. If the exporter runs on a different machine than Xray, listen on 0.0.0.0 instead of 127.0.0.1 and firewall the port, since anyone who can reach it can read your stats.
See the xray-core API docs and stats docs for the details.
Everything under user activity below comes from the access log, which is off unless you turn it on:
{
"log": {
"access": "/var/log/xray/access.log",
"error": "/var/log/xray/error.log",
"loglevel": "warning"
}
}# gRPC metrics only
xray-exporter --xray-endpoint "127.0.0.1:54321"
# plus user activity from the access log
xray-exporter --xray-endpoint "127.0.0.1:54321" --log-path "/var/log/xray/access.log"
# with a 10 minute window instead of the default 5
xray-exporter --xray-endpoint "127.0.0.1:54321" --log-time-window 10Or in Docker, with the log mounted read-only and a writable directory for the GeoIP databases:
docker run --rm -d \
-v /var/log/xray:/var/log/xray:ro \
-v xray-exporter-geoip:/geoip \
ghcr.io/compassvpn/xray-exporter:latest \
--xray-endpoint "xray:54321" \
--log-path "/var/log/xray/access.log" \
--geoip-dir /geoipThe GeoLite2 databases are written to --geoip-dir, which defaults to the working directory. Inside the container that is /, so a container started with --read-only and no writable volume cannot download them. That is not fatal, but the ASN, country, and city metrics stay empty.
On startup you should see:
Xray Exporter XXX-a1b2c3d (built 2025-01-01T21:00:00Z)
time="2025-01-15T10:30:45Z" level=info msg="Log parser started successfully"
time="2025-01-15T10:30:45Z" level=info msg="Server starting on :9550"
Open http://ip:9550 and follow the Scrape Xray Metrics link to see the output. If xray_up 1 is missing from the response, the scrape failed and the exporter logs will say why.
global:
scrape_interval: 15s
scrape_timeout: 5s
scrape_configs:
- job_name: xray
metrics_path: /scrape
static_configs:
- targets: [IP:9550]Import dashboard 23181 from grafana.com, or build your own from the metrics reference.
| Flag | Default | Description |
|---|---|---|
-l, --listen [ADDR]:PORT |
:9550 |
Address to listen on |
-m, --metrics-path PATH |
/scrape |
Path that serves the Xray metrics |
-e, --xray-endpoint HOST:PORT |
127.0.0.1:8080 |
Xray API endpoint |
-t, --scrape-timeout N |
5 |
Timeout in seconds for each individual scrape |
-u, --user-traffic-metrics |
off | Export per-user traffic byte counters |
-p, --log-path PATH |
/var/log/xray/access.log |
Access log to parse. Empty disables the log metrics |
-w, --log-time-window N |
5 |
Window in minutes for the log metrics |
-g, --geoip-dir PATH |
. |
Directory for the GeoLite2 databases |
--log-level LEVEL |
info |
error, warn, info, or debug. Also read from LOG_LEVEL |
--log-format FORMAT |
text |
text or json. Also read from LOG_FORMAT |
--version |
Print the version and exit |
xray-exporter -h prints the same list.
| Xray stat | Exported metric | Description |
|---|---|---|
uptime |
xray_uptime_seconds |
Xray uptime in seconds |
num_goroutine |
xray_goroutines |
Number of goroutines |
alloc |
xray_memstats_alloc_bytes |
Bytes allocated and in use |
total_alloc |
xray_memstats_alloc_bytes_total |
Total bytes allocated |
sys |
xray_memstats_sys_bytes |
Bytes obtained from the OS |
mallocs |
xray_memstats_mallocs_total |
Total number of mallocs |
frees |
xray_memstats_frees_total |
Total number of frees |
num_gc |
xray_memstats_num_gc |
Number of GC cycles |
pause_total_ns |
xray_memstats_pause_total_ns |
Total GC pause time |
| Xray stat | Exported metric |
|---|---|
inbound>>>tag-name>>>traffic>>>uplink |
xray_traffic_uplink_bytes_total{dimension="inbound",target="tag-name"} |
inbound>>>tag-name>>>traffic>>>downlink |
xray_traffic_downlink_bytes_total{dimension="inbound",target="tag-name"} |
outbound>>>tag-name>>>traffic>>>uplink |
xray_traffic_uplink_bytes_total{dimension="outbound",target="tag-name"} |
outbound>>>tag-name>>>traffic>>>downlink |
xray_traffic_downlink_bytes_total{dimension="outbound",target="tag-name"} |
user>>>user-email>>>traffic>>>uplink |
xray_traffic_uplink_bytes_total{dimension="user",target="user-email"} |
user>>>user-email>>>traffic>>>downlink |
xray_traffic_downlink_bytes_total{dimension="user",target="user-email"} |
The dimension="user" rows are opt-in. Pass --user-traffic-metrics to turn them on. Every user becomes its own series with no top-N cap, so only do this where the set of users is small and known.
Only exported when --log-path points at a readable access log.
| Metric | Type | Description | Labels |
|---|---|---|---|
xray_requested_domain_ip_total |
counter | Requests per domain or IP since startup | target |
xray_asns_total |
counter | Requests per ASN since startup | asn, org |
xray_countries_total |
counter | Requests per country since startup | country |
xray_cities_total |
counter | Requests per city since startup | city, country |
xray_unique_users |
gauge | Unique users active in the time window | |
xray_total_connections |
gauge | Connections in the time window | |
xray_outbound_requests |
gauge | Requests per outbound in the time window | outbound |
Before committing, run the checks CI runs:
gofmt -w . && go vet ./... && go build ./...