merge midi metrics into prometheus-exporter

midi-bridge: removed HTTP server on :9091, now writes /tmp/midi-stats.json
prometheus-exporter: reads /tmp/midi-stats.json, exposes all metrics on :9090/metrics
Single scrape endpoint for all pipeline components
This commit is contained in:
2026-06-24 02:41:27 -07:00
parent 7706517b88
commit f673da4b3b
2 changed files with 164 additions and 156 deletions
+31 -70
View File
@@ -4,16 +4,16 @@
Sends parsed MIDI messages as Pd-style 'midi status data1 data2;' strings Sends parsed MIDI messages as Pd-style 'midi status data1 data2;' strings
to UDP ports for Pd (8081) and LED matrix visualization (8082). to UDP ports for Pd (8081) and LED matrix visualization (8082).
Exposes Prometheus metrics on :9091/metrics. Writes metrics to /tmp/midi-stats.json for prometheus-exporter to collect.
""" """
import os, socket, time, select, pathlib, threading, http.server import os, socket, time, select, pathlib, json
PD_HOST = os.environ.get("MIDI_BRIDGE_HOST", "127.0.0.1") PD_HOST = os.environ.get("MIDI_BRIDGE_HOST", "127.0.0.1")
PD_PORT = int(os.environ.get("MIDI_BRIDGE_PORT", "8081")) PD_PORT = int(os.environ.get("MIDI_BRIDGE_PORT", "8081"))
VIZ_PORT = int(os.environ.get("VIZ_PORT", "8082")) VIZ_PORT = int(os.environ.get("VIZ_PORT", "8082"))
METRICS_PORT = int(os.environ.get("MIDI_METRICS_PORT", "9091")) STATS_FILE = os.environ.get("MIDI_STATS_FILE", "/tmp/midi-stats.json")
STATS_INTERVAL = 2.0
# ── Metrics ──
midi_notes_on = 0 midi_notes_on = 0
midi_notes_off = 0 midi_notes_off = 0
midi_messages_total = 0 midi_messages_total = 0
@@ -22,7 +22,7 @@ last_status = 0
last_note = 0 last_note = 0
last_velocity = 0 last_velocity = 0
last_time = 0.0 last_time = 0.0
_metrics_lock = threading.Lock() _stats_lock = object()
def _find_midi_dev(): def _find_midi_dev():
@@ -74,7 +74,7 @@ def _flush(sock, buf):
sock.sendto(msg.encode(), (PD_HOST, PD_PORT)) sock.sendto(msg.encode(), (PD_HOST, PD_PORT))
sock.sendto(msg.encode(), (PD_HOST, VIZ_PORT)) sock.sendto(msg.encode(), (PD_HOST, VIZ_PORT))
with _metrics_lock: with _stats_lock:
midi_messages_total += 1 midi_messages_total += 1
last_status = status last_status = status
last_note = data1 last_note = data1
@@ -87,75 +87,28 @@ def _flush(sock, buf):
midi_notes_off += 1 midi_notes_off += 1
# ── Prometheus HTTP ── def _write_stats():
try:
def _format_metrics(): with open(STATS_FILE, "w") as f:
with _metrics_lock: json.dump({
lines = [ "midi_messages_total": midi_messages_total,
"# HELP midi_messages_total Total MIDI messages received", "midi_notes_on_total": midi_notes_on,
"# TYPE midi_messages_total counter", "midi_notes_off_total": midi_notes_off,
f"midi_messages_total {midi_messages_total}", "midi_errors_total": midi_errors,
"", "midi_last_status": last_status,
"# HELP midi_notes_on_total Total note-on messages", "midi_last_note": last_note,
"# TYPE midi_notes_on_total counter", "midi_last_velocity": last_velocity,
f"midi_notes_on_total {midi_notes_on}", "midi_last_time_unix": last_time,
"", }, f)
"# HELP midi_notes_off_total Total note-off messages", except OSError:
"# TYPE midi_notes_off_total counter",
f"midi_notes_off_total {midi_notes_off}",
"",
"# HELP midi_errors_total Total MIDI parse errors",
"# TYPE midi_errors_total counter",
f"midi_errors_total {midi_errors}",
"",
"# HELP midi_last_status Status byte of last message",
"# TYPE midi_last_status gauge",
f"midi_last_status {last_status}",
"",
"# HELP midi_last_note Note number of last message",
"# TYPE midi_last_note gauge",
f"midi_last_note {last_note}",
"",
"# HELP midi_last_velocity Velocity of last message",
"# TYPE midi_last_velocity gauge",
f"midi_last_velocity {last_velocity}",
"",
"# HELP midi_last_time_unix Epoch of last MIDI message",
"# TYPE midi_last_time_unix gauge",
f"midi_last_time_unix {last_time:.3f}",
"",
]
return "\n".join(lines) + "\n"
class _MetricsHandler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/metrics":
body = _format_metrics().encode()
self.send_response(200)
self.send_header("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
else:
self.send_response(404)
self.end_headers()
def log_message(self, format, *args):
pass pass
def _start_metrics_server():
server = http.server.HTTPServer(("0.0.0.0", METRICS_PORT), _MetricsHandler)
t = threading.Thread(target=server.serve_forever, daemon=True)
t.start()
def main(): def main():
_start_metrics_server()
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
running = True running = True
last_stats_write = time.monotonic()
while running: while running:
try: try:
fd = _open_midi() fd = _open_midi()
@@ -163,8 +116,16 @@ def main():
poll.register(fd, select.POLLIN) poll.register(fd, select.POLLIN)
buf = bytearray() buf = bytearray()
while running: while running:
now = time.monotonic()
# Write stats periodically
if now - last_stats_write >= STATS_INTERVAL:
with _stats_lock:
_write_stats()
last_stats_write = now
try: try:
events = poll.poll(500) events = poll.poll(100)
for f, flags in events: for f, flags in events:
if flags & select.POLLIN: if flags & select.POLLIN:
data = os.read(fd, 1024) data = os.read(fd, 1024)
+120 -73
View File
@@ -1,20 +1,18 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Prometheus exporter for uno-q-audio-synth pipeline. """Prometheus exporter for uno-q-audio-synth pipeline.
Exposes MIDI, audio, and service health metrics on :9090/metrics. Exposes MIDI, audio, viz, and service health metrics on :9090/metrics.
Reads stats from /tmp/midi-stats.json (midi-bridge) and /tmp/viz-stats.json (viz).
No external dependencies — uses only stdlib. No external dependencies — uses only stdlib.
""" """
import http.server, time, os, re, subprocess, socket, threading, json import http.server, time, os, re, subprocess, json
PORT = int(os.environ.get("METRICS_PORT", "9090")) PORT = int(os.environ.get("METRICS_PORT", "9090"))
SSH_KEY = os.environ.get("SSH_KEY", "/home/david/.ssh/unoq_deploy_key") COLLECT_INTERVAL = 5
BOARD = os.environ.get("BOARD_HOST", "127.0.0.1")
COLLECT_INTERVAL = 5 # seconds between metric collections
# ── Gauges (updated by collector) ──
metrics = { metrics = {
"synth_alsa_running": 0, "synth_alsa_running": 0,
"synth_hw_ptr_bytes_per_sec": 0, "synth_hw_ptr_bytes_per_sec": 0.0,
"synth_alsa_hw_ptr": 0, "synth_alsa_hw_ptr": 0,
"synth_pd_running": 0, "synth_pd_running": 0,
"synth_midi_bridge_running": 0, "synth_midi_bridge_running": 0,
@@ -24,6 +22,14 @@ metrics = {
"synth_hphl_volume": 0, "synth_hphl_volume": 0,
"synth_hphr_volume": 0, "synth_hphr_volume": 0,
"synth_up": 1, "synth_up": 1,
"midi_messages_total": 0,
"midi_notes_on_total": 0,
"midi_notes_off_total": 0,
"midi_errors_total": 0,
"midi_last_status": 0,
"midi_last_note": 0,
"midi_last_velocity": 0,
"midi_last_time_unix": 0.0,
"viz_frames_sent": 0, "viz_frames_sent": 0,
"viz_frames_dropped": 0, "viz_frames_dropped": 0,
"viz_pending_queue": 0, "viz_pending_queue": 0,
@@ -33,12 +39,13 @@ metrics = {
prev_hw_ptr = 0 prev_hw_ptr = 0
prev_hw_ptr_time = 0 prev_hw_ptr_time = 0
MIDI_STATS_FILE = "/tmp/midi-stats.json"
VIZ_STATS_FILE = "/tmp/viz-stats.json" VIZ_STATS_FILE = "/tmp/viz-stats.json"
def read_viz_stats(): def read_json(path):
try: try:
with open(VIZ_STATS_FILE) as f: with open(path) as f:
return json.load(f) return json.load(f)
except (OSError, ValueError): except (OSError, ValueError):
return {} return {}
@@ -53,7 +60,6 @@ def read_local_file(path):
def get_alsa_status(): def get_alsa_status():
"""Read ALSA PCM status from /proc."""
global prev_hw_ptr, prev_hw_ptr_time global prev_hw_ptr, prev_hw_ptr_time
status = read_local_file("/proc/asound/card0/pcm0p/sub0/status") status = read_local_file("/proc/asound/card0/pcm0p/sub0/status")
@@ -76,7 +82,6 @@ def get_alsa_status():
def check_service(name): def check_service(name):
"""Check if a systemd service is active (local only)."""
try: try:
r = subprocess.run( r = subprocess.run(
["systemctl", "is-active", name], ["systemctl", "is-active", name],
@@ -88,7 +93,6 @@ def check_service(name):
def get_mixer_value(name): def get_mixer_value(name):
"""Read an ALSA mixer value via amixer."""
try: try:
r = subprocess.run( r = subprocess.run(
["amixer", "-c", "0", "cget", f"name={name}"], ["amixer", "-c", "0", "cget", f"name={name}"],
@@ -106,10 +110,7 @@ def get_mixer_value(name):
return 0 return 0
def collect_metrics(): def collect():
"""Background thread: poll system state periodically."""
while True:
try:
get_alsa_status() get_alsa_status()
metrics["synth_pd_running"] = check_service("pd-synth-onboard") metrics["synth_pd_running"] = check_service("pd-synth-onboard")
@@ -121,85 +122,124 @@ def collect_metrics():
metrics["synth_hphl_volume"] = get_mixer_value("HPHL Volume") metrics["synth_hphl_volume"] = get_mixer_value("HPHL Volume")
metrics["synth_hphr_volume"] = get_mixer_value("HPHR Volume") metrics["synth_hphr_volume"] = get_mixer_value("HPHR Volume")
viz = read_viz_stats() midi = read_json(MIDI_STATS_FILE)
metrics["midi_messages_total"] = midi.get("midi_messages_total", 0)
metrics["midi_notes_on_total"] = midi.get("midi_notes_on_total", 0)
metrics["midi_notes_off_total"] = midi.get("midi_notes_off_total", 0)
metrics["midi_errors_total"] = midi.get("midi_errors_total", 0)
metrics["midi_last_status"] = midi.get("midi_last_status", 0)
metrics["midi_last_note"] = midi.get("midi_last_note", 0)
metrics["midi_last_velocity"] = midi.get("midi_last_velocity", 0)
metrics["midi_last_time_unix"] = midi.get("midi_last_time_unix", 0.0)
viz = read_json(VIZ_STATS_FILE)
metrics["viz_frames_sent"] = viz.get("frames_sent", 0) metrics["viz_frames_sent"] = viz.get("frames_sent", 0)
metrics["viz_frames_dropped"] = viz.get("frames_dropped", 0) metrics["viz_frames_dropped"] = viz.get("frames_dropped", 0)
metrics["viz_pending_queue"] = viz.get("pending_queue", 0) metrics["viz_pending_queue"] = viz.get("pending_queue", 0)
metrics["viz_queue_full"] = 1 if viz.get("queue_full", False) else 0 metrics["viz_queue_full"] = 1 if viz.get("queue_full", False) else 0
except Exception:
pass
time.sleep(COLLECT_INTERVAL)
def format_metrics(): def format_metrics():
lines = [] lines = [
lines.append("# HELP synth_alsa_running Whether ALSA device is RUNNING") "# HELP synth_alsa_running Whether ALSA device is RUNNING",
lines.append("# TYPE synth_alsa_running gauge") "# TYPE synth_alsa_running gauge",
lines.append(f"synth_alsa_running {metrics['synth_alsa_running']}") f"synth_alsa_running {metrics['synth_alsa_running']}",
lines.append("# HELP synth_hw_ptr_bytes_per_sec Audio throughput in bytes/sec") "# HELP synth_hw_ptr_bytes_per_sec Audio throughput in bytes/sec",
lines.append("# TYPE synth_hw_ptr_bytes_per_sec gauge") "# TYPE synth_hw_ptr_bytes_per_sec gauge",
lines.append(f"synth_hw_ptr_bytes_per_sec {metrics['synth_hw_ptr_bytes_per_sec']:.0f}") f"synth_hw_ptr_bytes_per_sec {metrics['synth_hw_ptr_bytes_per_sec']:.0f}",
lines.append("# HELP synth_alsa_hw_ptr Current ALSA hardware pointer") "# HELP synth_alsa_hw_ptr Current ALSA hardware pointer",
lines.append("# TYPE synth_alsa_hw_ptr gauge") "# TYPE synth_alsa_hw_ptr gauge",
lines.append(f"synth_alsa_hw_ptr {metrics['synth_alsa_hw_ptr']}") f"synth_alsa_hw_ptr {metrics['synth_alsa_hw_ptr']}",
lines.append("# HELP synth_pd_running Whether Pd service is active") "# HELP synth_pd_running Whether Pd service is active",
lines.append("# TYPE synth_pd_running gauge") "# TYPE synth_pd_running gauge",
lines.append(f"synth_pd_running {metrics['synth_pd_running']}") f"synth_pd_running {metrics['synth_pd_running']}",
lines.append("# HELP synth_midi_bridge_running Whether midi-bridge service is active") "# HELP synth_midi_bridge_running Whether midi-bridge service is active",
lines.append("# TYPE synth_midi_bridge_running gauge") "# TYPE synth_midi_bridge_running gauge",
lines.append(f"synth_midi_bridge_running {metrics['synth_midi_bridge_running']}") f"synth_midi_bridge_running {metrics['synth_midi_bridge_running']}",
lines.append("# HELP synth_viz_running Whether led-matrix-viz service is active") "# HELP synth_viz_running Whether led-matrix-viz service is active",
lines.append("# TYPE synth_viz_running gauge") "# TYPE synth_viz_running gauge",
lines.append(f"synth_viz_running {metrics['synth_viz_running']}") f"synth_viz_running {metrics['synth_viz_running']}",
lines.append("# HELP synth_hphl_switch Headphone left switch state") "# HELP synth_hphl_switch Headphone left switch state",
lines.append("# TYPE synth_hphl_switch gauge") "# TYPE synth_hphl_switch gauge",
lines.append(f"synth_hphl_switch {metrics['synth_hphl_switch']}") f"synth_hphl_switch {metrics['synth_hphl_switch']}",
lines.append("# HELP synth_hphr_switch Headphone right switch state") "# HELP synth_hphr_switch Headphone right switch state",
lines.append("# TYPE synth_hphr_switch gauge") "# TYPE synth_hphr_switch gauge",
lines.append(f"synth_hphr_switch {metrics['synth_hphr_switch']}") f"synth_hphr_switch {metrics['synth_hphr_switch']}",
lines.append("# HELP synth_hphl_volume Headphone left volume (0-20)") "# HELP synth_hphl_volume Headphone left volume (0-20)",
lines.append("# TYPE synth_hphl_volume gauge") "# TYPE synth_hphl_volume gauge",
lines.append(f"synth_hphl_volume {metrics['synth_hphl_volume']}") f"synth_hphl_volume {metrics['synth_hphl_volume']}",
lines.append("# HELP synth_hphr_volume Headphone right volume (0-20)") "# HELP synth_hphr_volume Headphone right volume (0-20)",
lines.append("# TYPE synth_hphr_volume gauge") "# TYPE synth_hphr_volume gauge",
lines.append(f"synth_hphr_volume {metrics['synth_hphr_volume']}") f"synth_hphr_volume {metrics['synth_hphr_volume']}",
lines.append("# HELP synth_up Exporter is running") "# HELP synth_up Exporter is running",
lines.append("# TYPE synth_up gauge") "# TYPE synth_up gauge",
lines.append(f"synth_up {metrics['synth_up']}") f"synth_up {metrics['synth_up']}",
lines.append("# HELP viz_frames_sent_total Total frames sent to router") "# HELP midi_messages_total Total MIDI messages received from hardware",
lines.append("# TYPE viz_frames_sent_total counter") "# TYPE midi_messages_total counter",
lines.append(f"viz_frames_sent_total {metrics['viz_frames_sent']}") f"midi_messages_total {metrics['midi_messages_total']}",
lines.append("# HELP viz_frames_dropped_total Total frames dropped (queue full)") "# HELP midi_notes_on_total Total MIDI note-on messages",
lines.append("# TYPE viz_frames_dropped_total counter") "# TYPE midi_notes_on_total counter",
lines.append(f"viz_frames_dropped_total {metrics['viz_frames_dropped']}") f"midi_notes_on_total {metrics['midi_notes_on_total']}",
lines.append("# HELP viz_pending_queue Current pending frame queue depth") "# HELP midi_notes_off_total Total MIDI note-off messages",
lines.append("# TYPE viz_pending_queue gauge") "# TYPE midi_notes_off_total counter",
lines.append(f"viz_pending_queue {metrics['viz_pending_queue']}") f"midi_notes_off_total {metrics['midi_notes_off_total']}",
lines.append("# HELP viz_queue_full Router write buffer was full") "# HELP midi_errors_total Total MIDI parse errors",
lines.append("# TYPE viz_queue_full gauge") "# TYPE midi_errors_total counter",
lines.append(f"viz_queue_full {metrics['viz_queue_full']}") f"midi_errors_total {metrics['midi_errors_total']}",
"# HELP midi_last_status Status byte of last message",
"# TYPE midi_last_status gauge",
f"midi_last_status {metrics['midi_last_status']}",
"# HELP midi_last_note Note number of last message",
"# TYPE midi_last_note gauge",
f"midi_last_note {metrics['midi_last_note']}",
"# HELP midi_last_velocity Velocity of last message",
"# TYPE midi_last_velocity gauge",
f"midi_last_velocity {metrics['midi_last_velocity']}",
"# HELP midi_last_time_unix Epoch of last MIDI message",
"# TYPE midi_last_time_unix gauge",
f"midi_last_time_unix {metrics['midi_last_time_unix']:.3f}",
"# HELP viz_frames_sent_total Total frames sent to router",
"# TYPE viz_frames_sent_total counter",
f"viz_frames_sent_total {metrics['viz_frames_sent']}",
"# HELP viz_frames_dropped_total Total frames dropped (queue full)",
"# TYPE viz_frames_dropped_total counter",
f"viz_frames_dropped_total {metrics['viz_frames_dropped']}",
"# HELP viz_pending_queue Current pending frame queue depth",
"# TYPE viz_pending_queue gauge",
f"viz_pending_queue {metrics['viz_pending_queue']}",
"# HELP viz_queue_full Router write buffer was full",
"# TYPE viz_queue_full gauge",
f"viz_queue_full {metrics['viz_queue_full']}",
]
return "\n".join(lines) + "\n" return "\n".join(lines) + "\n"
class MetricsHandler(http.server.BaseHTTPRequestHandler): class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self): def do_GET(self):
if self.path == "/metrics": if self.path == "/metrics":
collect()
body = format_metrics().encode() body = format_metrics().encode()
self.send_response(200) self.send_response(200)
self.send_header("Content-Type", "text/plain; version=0.0.4; charset=utf-8") self.send_header("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
@@ -217,18 +257,25 @@ class MetricsHandler(http.server.BaseHTTPRequestHandler):
self.end_headers() self.end_headers()
def log_message(self, format, *args): def log_message(self, format, *args):
pass # silent pass
def main(): def main():
# Start background collector import threading
t = threading.Thread(target=collect_metrics, daemon=True) t = threading.Thread(target=lambda: (
setInterval(COLLECT_INTERVAL, collect),
), daemon=True)
t.start() t.start()
server = http.server.HTTPServer(("0.0.0.0", PORT), Handler)
server = http.server.HTTPServer(("0.0.0.0", PORT), MetricsHandler)
print(f"[metrics] Listening on :{PORT}/metrics") print(f"[metrics] Listening on :{PORT}/metrics")
server.serve_forever() server.serve_forever()
def setInterval(secs, func):
while True:
func()
time.sleep(secs)
if __name__ == "__main__": if __name__ == "__main__":
main() main()