Skip to content

Commit 284a044

Browse files
committed
prometheus: scrape per-track metrics per namespace
/metrics/track takes one namespace per request and rejects a match wider than its limit, so an unscoped scrape fails once the relay's total track count passes the limit and takes every namespace with it. Scoping per namespace keeps working until a single namespace passes it. Namespaces change as events start and end, so ns-targets walks the relay's namespace tree and writes the file_sd target list; Prometheus rereads it without a restart. Encoding matches the endpoint's safe form.
1 parent afa1f8e commit 284a044

5 files changed

Lines changed: 161 additions & 27 deletions

File tree

docker/docker-compose.yml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,7 @@ services:
202202
mem_limit: ${PROMETHEUS_MEM:-2g}
203203
volumes:
204204
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
205+
- prometheus-targets:/etc/prometheus/targets:ro
205206
# TSDB backing store. Defaults to the named volume (persists across
206207
# redeploys — `compose down` without -v keeps it). Set PROMETHEUS_DATA_DIR
207208
# to an absolute host path to pin it on a specific/larger disk instead.
@@ -315,6 +316,29 @@ services:
315316
max-size: "10m"
316317
max-file: "3"
317318

319+
# Writes one Prometheus file_sd target per live namespace, read from the
320+
# relay's namespace tree. /metrics/track takes a single namespace per
321+
# request, and namespaces come and go with events, so the list cannot be
322+
# static.
323+
ns-targets:
324+
container_name: moqx-ns-targets
325+
image: python:3.12-alpine
326+
restart: unless-stopped
327+
profiles: [stats]
328+
command: ["python3", "/app/namespace-targets.py"]
329+
environment:
330+
MOQX_STATE_URL: http://moqx:${MOQX_ADMIN_PORT:-8000}/state
331+
MOQX_TARGETS_PATH: /targets/namespaces.json
332+
MOQX_TARGETS_INTERVAL: "30"
333+
volumes:
334+
- ./prometheus/namespace-targets.py:/app/namespace-targets.py:ro
335+
- prometheus-targets:/targets
336+
logging:
337+
driver: json-file
338+
options:
339+
max-size: "10m"
340+
max-file: "3"
341+
318342
# Turns the relay admin /state JSON into per-track Prometheus series (the
319343
# aggregate /metrics has no per-track labels). Prometheus scrapes it with a
320344
# target= param pointing at the relay /state (see the moqx-state job).
@@ -334,6 +358,7 @@ services:
334358
max-file: "3"
335359

336360
volumes:
361+
prometheus-targets:
337362
moqx-coredumps:
338363
prometheus-data:
339364
grafana-data:

docker/prometheus/README.md

Lines changed: 28 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,31 +2,40 @@
22

33
## Per-track metrics (`moqx-track`)
44

5-
`/metrics/track` takes optional `service`, `namespace`, `track` and `limit`
6-
parameters; with none of them it reports every live track, so a single scrape
7-
covers the relay.
5+
`/metrics/track` reports counters per live track. It takes one namespace per
6+
request, so each namespace is scraped separately.
87

9-
`limit` is a guard rather than a selector: more live tracks than the limit
10-
returns 400, so an over-wide scrape fails visibly instead of graphing a series
11-
set that reshuffles between scrapes. Ceiling is
12-
`admin.track_metrics_endpoint_max_limit` (1000).
8+
Scoping per namespace is not just tidiness. `limit` is a guard rather than a
9+
selector: a request matching more tracks than the limit returns 400 instead of
10+
truncating, because an arbitrary subset would give Prometheus a series set that
11+
reshuffles between scrapes. An unscoped scrape therefore fails as soon as the
12+
relay's *total* track count passes the limit, and takes every namespace with
13+
it, while per-namespace scrapes keep working until a *single* namespace passes
14+
it. Ceiling is `admin.track_metrics_endpoint_max_limit` (1000).
1315

14-
Past that ceiling the scrape splits by namespace prefix, one job per prefix,
15-
with `/state.namespace_tree` generating the target list into a `file_sd` file:
16+
Counting only happens when `admin.track_metrics_enabled` is true (the default);
17+
with it false the endpoint returns 503 rather than an empty scrape that would
18+
read as "no live tracks".
1619

17-
file_sd_configs:
18-
- files: ['/etc/prometheus/targets/namespaces.json']
19-
refresh_interval: 30s
20+
## Target generation (`ns-targets`)
2021

21-
Label values use the moq-transport encoded form — tuple elements joined by
22-
`-`, other bytes as `.<hex>`:
22+
Namespaces come and go with events, so the target list cannot be static.
23+
`namespace-targets.py` walks the relay's `/state` namespace tree every 30s and
24+
writes one target per namespace to a file Prometheus rereads without a restart.
25+
It collects every node carrying a namespace rather than only the leaves, since
26+
tracks can be published at any depth.
27+
28+
Targets are namespace values in the moq-transport safe form — `[A-Za-z0-9_]`
29+
passes through, every other byte becomes `.<hex>`, tuple elements join with
30+
`-`:
2331

2432
moq-test/interop -> moq.2dtest-interop
2533
conf.example.com / room 1 -> conf.2eexample.2ecom-room.201
2634

27-
Series exist only for live tracks: they disappear when a track ends and restart
28-
from zero if it returns, so counters need `increase()`/`rate()` rather than
29-
raw deltas across a track's lifetime.
35+
Relabelling turns each target into the `namespace` query parameter and points
36+
the scrape at the relay. The unencoded namespace is kept as `moqx_namespace`
37+
for display, since the encoded form is what lands in the metric labels.
3038

31-
Counting is installed only when `admin.track_metrics_enabled` is true (default);
32-
with it false the endpoint returns 503.
39+
Series exist only while a track is live: they disappear when it ends and
40+
restart from zero if it returns, so counters need `rate()`/`increase()` rather
41+
than differences taken across a track's lifetime.
3.58 KB
Binary file not shown.
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
#!/usr/bin/env python3
2+
"""Generate Prometheus file_sd targets for per-track metric scrapes.
3+
4+
/metrics/track takes one namespace per request and rejects a match wider than
5+
its limit, so each namespace is scraped separately. This walks the relay's
6+
namespace tree and writes one target per namespace; Prometheus rereads the file
7+
without a restart.
8+
9+
Namespaces are written in the moq-transport safe form the endpoint expects:
10+
[A-Za-z0-9_] passes through, every other byte becomes .<hex>, and tuple
11+
elements are joined with '-'.
12+
"""
13+
import json
14+
import os
15+
import sys
16+
import tempfile
17+
import time
18+
import urllib.request
19+
20+
STATE_URL = os.environ.get("MOQX_STATE_URL", "http://moqx:8000/state")
21+
OUT_PATH = os.environ.get("MOQX_TARGETS_PATH", "/targets/namespaces.json")
22+
INTERVAL = float(os.environ.get("MOQX_TARGETS_INTERVAL", "30"))
23+
24+
_PASS = set(
25+
"abcdefghijklmnopqrstuvwxyz" "ABCDEFGHIJKLMNOPQRSTUVWXYZ" "0123456789" "_"
26+
)
27+
28+
29+
def safe_element(element):
30+
out = []
31+
for byte in element.encode():
32+
ch = chr(byte)
33+
out.append(ch if ch in _PASS else ".%02x" % byte)
34+
return "".join(out)
35+
36+
37+
def safe_namespace(tuple_elements):
38+
return "-".join(safe_element(e) for e in tuple_elements)
39+
40+
41+
def walk(node, found):
42+
"""Collect every node carrying a namespace, not just the leaves: tracks can
43+
be published at any depth."""
44+
full = node.get("full_namespace") or []
45+
if full:
46+
found.append(full)
47+
for child in (node.get("children") or {}).values():
48+
walk(child, found)
49+
50+
51+
def namespaces():
52+
with urllib.request.urlopen(STATE_URL, timeout=10) as resp:
53+
state = json.load(resp)
54+
found = []
55+
for service in (state.get("services") or {}).values():
56+
tree = service.get("namespace_tree")
57+
if tree:
58+
walk(tree, found)
59+
# A namespace can appear under more than one service.
60+
return sorted({tuple(ns) for ns in found})
61+
62+
63+
def write(path, entries):
64+
payload = [
65+
{"targets": [safe_namespace(ns)], "labels": {"moqx_namespace": "/".join(ns)}}
66+
for ns in entries
67+
]
68+
body = json.dumps(payload, indent=2) + "\n"
69+
if os.path.exists(path) and open(path).read() == body:
70+
return False
71+
# Rename into place so Prometheus never reads a partial file.
72+
directory = os.path.dirname(path) or "."
73+
fd, tmp = tempfile.mkstemp(dir=directory)
74+
with os.fdopen(fd, "w") as handle:
75+
handle.write(body)
76+
os.replace(tmp, path)
77+
return True
78+
79+
80+
def main():
81+
while True:
82+
try:
83+
entries = namespaces()
84+
if write(OUT_PATH, entries):
85+
print("wrote %d namespace target(s)" % len(entries), flush=True)
86+
except Exception as exc: # keep polling: the relay restarts
87+
print("namespace target refresh failed: %s" % exc, file=sys.stderr, flush=True)
88+
time.sleep(INTERVAL)
89+
90+
91+
if __name__ == "__main__":
92+
main()

docker/prometheus/prometheus.yml

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -73,16 +73,24 @@ scrape_configs:
7373
- target_label: __address__
7474
replacement: json-exporter:7979
7575

76-
# Per-track counters from the relay (openmoq/moqx#533). Every parameter is
77-
# optional, so one scrape covers all namespaces. limit is a guard, not a
78-
# top-N: more live tracks than the limit returns 400 rather than a series
79-
# set that reshuffles between scrapes.
76+
# Per-track counters from the relay (openmoq/moqx#533). One namespace per
77+
# request: an unscoped scrape covers everything only until the total exceeds
78+
# the limit, at which point it 400s and takes every namespace down with it,
79+
# while per-namespace scrapes keep working until a single namespace exceeds
80+
# it. Targets are namespaces in the safe form, generated from the relay's
81+
# namespace tree by ns-targets and relabelled into the query.
8082
- job_name: moqx-track
8183
metrics_path: /metrics/track
8284
scrape_interval: 15s
8385
params:
8486
limit: ['1000']
85-
static_configs:
86-
- targets: ['moqx:8000']
87-
labels:
88-
instance: moqx-relay
87+
file_sd_configs:
88+
- files: ['/etc/prometheus/targets/namespaces.json']
89+
refresh_interval: 30s
90+
relabel_configs:
91+
- source_labels: [__address__]
92+
target_label: __param_namespace
93+
- target_label: __address__
94+
replacement: moqx:8000
95+
- target_label: instance
96+
replacement: moqx-relay

0 commit comments

Comments
 (0)