ZeroMQ (ØMQ) is not a message broker — it's a high-performance asynchronous messaging library that turns sockets into distributed computing primitives. There's no central server to restart, no admin UI to log into, and no built-in health endpoint to probe. Your ZeroMQ topology is as healthy as its constituent processes are, and when one silently falls behind or stops processing, the only way to know is by instrumenting your own code.
This is exactly where Vigilmon fits: you add lightweight health and heartbeat instrumentation to your ZeroMQ workers, expose HTTP endpoints, and let Vigilmon probe them from outside your process. This tutorial shows you how to monitor socket health, queue fill rates, message drops, context lifecycle, and the three core ZeroMQ patterns — pub/sub, push/pull, and req/rep — with Vigilmon HTTP probes and heartbeat monitors.
Why ZeroMQ Needs External Monitoring
ZeroMQ's brokerless design means there's no single point of failure to monitor — but it also means failures are distributed and invisible:
- A PUSH socket can silently drop messages when a downstream PULL socket's high-water mark is full
- A PUB socket continues sending to zero subscribers without error
- A REP socket that crashes mid-request leaves the paired REQ socket hanging forever
- A context with open sockets that lost their peer connections sits idle consuming memory
External monitoring with Vigilmon surfaces these conditions by probing health endpoints you expose from your ZeroMQ worker processes:
- Socket health — are all expected sockets bound and connected?
- Queue fill rate — are high-water marks being approached?
- Message drop counters — are messages being silently discarded?
- Context lifecycle — is the ZeroMQ context alive?
- Heartbeat pings — is each worker actively processing messages?
Step 1: Instrument Your ZeroMQ Workers
Add a lightweight Flask (or Express) health server alongside your ZeroMQ sockets. This server runs in a background thread and does not interfere with the ZeroMQ context.
Python Worker with Health Endpoint
# worker.py
import zmq
import threading
import time
import os
import requests
from flask import Flask, jsonify
# --- Shared metrics (updated by ZMQ threads) ---
metrics = {
'context_alive': False,
'sockets': {},
'messages_processed': 0,
'messages_dropped': 0,
'last_message_at': None,
}
metrics_lock = threading.Lock()
# --- Health server ---
health_app = Flask(__name__)
@health_app.route('/health/zmq')
def health():
with metrics_lock:
m = dict(metrics)
issues = []
if not m['context_alive']:
return jsonify(status='down', reason='context_dead'), 503
for name, info in m['sockets'].items():
if not info.get('bound') and not info.get('connected'):
issues.append(f"socket_{name}_not_ready")
stale_threshold = int(os.environ.get('ZMQ_STALE_SECONDS', '120'))
if m['last_message_at'] is not None:
age = time.time() - m['last_message_at']
if age > stale_threshold:
issues.append(f"no_messages_for_{int(age)}s")
if m['messages_dropped'] > 0:
issues.append(f"dropped_{m['messages_dropped']}_messages")
if issues:
return jsonify(status='degraded', issues=issues, **m), 503
return jsonify(
status='ok',
messages_processed=m['messages_processed'],
messages_dropped=m['messages_dropped'],
context_alive=m['context_alive'],
), 200
def run_health_server():
health_app.run(host='0.0.0.0', port=3008, use_reloader=False)
# --- ZeroMQ PULL worker example ---
def run_pull_worker(ctx):
socket = ctx.socket(zmq.PULL)
socket.set_hwm(1000) # high-water mark: 1000 messages queued max
socket.bind(f"tcp://*:{os.environ.get('ZMQ_PULL_PORT', '5555')}")
with metrics_lock:
metrics['sockets']['pull'] = {'bound': True, 'port': 5555}
VIGILMON_URL = os.environ.get('VIGILMON_HEARTBEAT_URL', '')
last_heartbeat = time.time()
HEARTBEAT_INTERVAL = 60
while True:
try:
# Non-blocking poll: check for messages every 100ms
if socket.poll(timeout=100):
msg = socket.recv()
process_message(msg)
with metrics_lock:
metrics['messages_processed'] += 1
metrics['last_message_at'] = time.time()
now = time.time()
if VIGILMON_URL and now - last_heartbeat > HEARTBEAT_INTERVAL:
try:
requests.get(VIGILMON_URL, timeout=3)
except Exception:
pass
last_heartbeat = now
except zmq.ZMQError as e:
if e.errno == zmq.ETERM:
break # context was terminated
def process_message(msg):
pass # your processing logic
if __name__ == '__main__':
ctx = zmq.Context.instance()
with metrics_lock:
metrics['context_alive'] = True
# Start health server in background thread
t_health = threading.Thread(target=run_health_server, daemon=True)
t_health.start()
# Run ZMQ worker
try:
run_pull_worker(ctx)
finally:
with metrics_lock:
metrics['context_alive'] = False
ctx.term()
Step 2: Pattern-Specific Health Endpoints
Different ZeroMQ patterns have different failure modes. Add pattern-specific endpoints for each:
PUB/SUB — Subscriber Health
# pub_sub_subscriber.py
import zmq, threading, time, os, requests
from flask import Flask, jsonify
sub_metrics = {
'connected': False,
'messages_received': 0,
'last_received_at': None,
'subscribed_topics': [],
}
health_app = Flask(__name__)
@health_app.route('/health/zmq/sub')
def sub_health():
m = dict(sub_metrics)
issues = []
if not m['connected']:
return jsonify(status='down', reason='not_connected_to_publisher'), 503
stale = int(os.environ.get('ZMQ_SUB_STALE_SECONDS', '30'))
if m['last_received_at'] and time.time() - m['last_received_at'] > stale:
issues.append(f"no_messages_for_{int(time.time()-m['last_received_at'])}s")
if not m['subscribed_topics']:
issues.append('no_active_subscriptions')
if issues:
return jsonify(status='degraded', issues=issues, **m), 503
return jsonify(status='ok', **m), 200
def run_subscriber():
ctx = zmq.Context.instance()
socket = ctx.socket(zmq.SUB)
socket.connect(f"tcp://{os.environ['ZMQ_PUB_HOST']}:5556")
socket.setsockopt(zmq.SUBSCRIBE, b'') # subscribe to all topics
sub_metrics['connected'] = True
sub_metrics['subscribed_topics'] = ['all']
while True:
if socket.poll(timeout=1000):
msg = socket.recv_multipart()
process_event(msg)
sub_metrics['messages_received'] += 1
sub_metrics['last_received_at'] = time.time()
def process_event(msg):
pass
REQ/REP — Request Timeout Monitor
@health_app.route('/health/zmq/req')
def req_health():
ctx = zmq.Context.instance()
socket = ctx.socket(zmq.REQ)
socket.setsockopt(zmq.RCVTIMEO, 3000) # 3s recv timeout
socket.setsockopt(zmq.SNDTIMEO, 1000) # 1s send timeout
socket.setsockopt(zmq.LINGER, 0) # don't block on close
try:
socket.connect(f"tcp://{os.environ['ZMQ_REP_HOST']}:5557")
socket.send(b'ping')
response = socket.recv()
socket.close()
if response == b'pong':
return jsonify(status='ok', roundtrip_ms='<3000'), 200
return jsonify(status='degraded', reason='unexpected_response'), 503
except zmq.Again:
socket.close()
return jsonify(status='down', reason='req_rep_timeout'), 503
except Exception as e:
socket.close()
return jsonify(status='down', error=str(e)), 503
PUSH/PULL — Queue Fill Approximation
ZeroMQ does not expose its internal send queue depth via the API, but you can track it by counting sent-vs-acknowledged messages or by monitoring high-water mark behavior:
push_metrics = {
'messages_pushed': 0,
'hwm': 1000,
'hwm_hit_count': 0,
}
@health_app.route('/health/zmq/push')
def push_health():
m = dict(push_metrics)
if m['hwm_hit_count'] > 0:
return jsonify(
status='degraded',
reason='hwm_pressure',
hwm_hits=m['hwm_hit_count'],
messages_pushed=m['messages_pushed'],
), 503
return jsonify(status='ok', messages_pushed=m['messages_pushed']), 200
# In your PUSH worker:
def push_message(socket, msg):
try:
socket.send(msg, zmq.NOBLOCK)
push_metrics['messages_pushed'] += 1
except zmq.Again:
# HWM reached — downstream PULL is not consuming fast enough
push_metrics['hwm_hit_count'] += 1
push_metrics['messages_dropped'] += 1
Step 3: Message Drop Monitoring
ZeroMQ drops messages silently when high-water marks are reached. Track this at the application level:
# drop_tracker.py
import zmq, os
class DroppingPushSocket:
def __init__(self, ctx, endpoint, hwm=1000):
self.socket = ctx.socket(zmq.PUSH)
self.socket.set_hwm(hwm)
self.socket.bind(endpoint)
self.dropped = 0
self.sent = 0
def send(self, msg, flags=0):
try:
self.socket.send(msg, zmq.NOBLOCK | flags)
self.sent += 1
except zmq.Again:
self.dropped += 1
@property
def drop_rate(self):
total = self.sent + self.dropped
return self.dropped / total if total > 0 else 0.0
Expose this through your health endpoint:
@health_app.route('/health/zmq/drops')
def drop_health():
rate = push_socket.drop_rate if push_socket else 0.0
if rate > 0.01: # alert above 1% drop rate
return jsonify(
status='degraded',
drop_rate_pct=round(rate * 100, 2),
dropped=push_socket.dropped,
sent=push_socket.sent,
), 503
return jsonify(
status='ok',
drop_rate_pct=round(rate * 100, 2),
), 200
Step 4: Configure Vigilmon HTTP Monitors
- Log in to vigilmon.online and go to Monitors → New Monitor
- Choose HTTP / HTTPS
- Set the URL to your ZeroMQ health endpoint:
https://your-app.example.com/health/zmq - Set the check interval to 1 minute
- Under Expected response, configure:
- Status code:
200 - Response body contains:
"status":"ok" - Response time threshold:
2000ms
- Status code:
- Under Alert channels, assign your Slack or PagerDuty integration
- Save the monitor
Add pattern-specific monitors:
| Monitor URL | Pattern | Purpose | Interval |
|---|---|---|---|
| /health/zmq | All | Context health, drop count | 1 min |
| /health/zmq/sub | PUB/SUB | Subscriber liveness, last message | 1 min |
| /health/zmq/req | REQ/REP | Round-trip timeout probe | 2 min |
| /health/zmq/push | PUSH/PULL | HWM pressure detection | 1 min |
| /health/zmq/drops | All | Message drop rate | 1 min |
Step 5: Heartbeat Monitoring for ZeroMQ Workers
For ZeroMQ workers running in long-lived processes — especially PULL consumers that might silently stop consuming after a transient error — Vigilmon heartbeat monitors provide a critical liveness check beyond what a socket health endpoint can detect.
Set Up the Heartbeat Monitor
- In Vigilmon, go to Monitors → New Monitor → Heartbeat
- Set the name:
zmq-event-processor - Set the expected interval: 5 minutes
- Set the grace period: 10 minutes
- Save — copy the heartbeat URL:
https://vigilmon.online/heartbeat/abc123xyz
The heartbeat logic is already integrated in the PULL worker example from Step 1. For PUSH workers (senders), ping Vigilmon after each successful batch:
# In your PUSH worker
VIGILMON_URL = os.environ.get('VIGILMON_HEARTBEAT_URL', '')
last_heartbeat = time.time()
def send_batch(socket, messages):
global last_heartbeat
for msg in messages:
push_message(socket, msg)
now = time.time()
if VIGILMON_URL and now - last_heartbeat > 60:
try:
requests.get(VIGILMON_URL, timeout=3)
except Exception:
pass
last_heartbeat = now
Node.js Worker (zeromq.js)
// worker.js
const zmq = require('zeromq');
const axios = require('axios');
const sock = new zmq.Pull();
const VIGILMON_URL = process.env.VIGILMON_HEARTBEAT_URL;
let msgCount = 0;
let lastHeartbeat = Date.now();
const metrics = {
contextAlive: true,
messagesProcessed: 0,
lastMessageAt: null,
};
// Express health server
const express = require('express');
const healthApp = express();
healthApp.get('/health/zmq', (req, res) => {
const stale = Date.now() - (metrics.lastMessageAt || 0) > 120_000;
if (!metrics.contextAlive) return res.status(503).json({ status: 'down' });
if (stale && metrics.lastMessageAt) return res.status(503).json({
status: 'degraded', reason: 'no_messages_120s',
});
res.json({ status: 'ok', messages: metrics.messagesProcessed });
});
healthApp.listen(3008);
async function run() {
await sock.bind(`tcp://*:${process.env.ZMQ_PORT || '5555'}`);
for await (const [msg] of sock) {
processMessage(msg);
metrics.messagesProcessed++;
metrics.lastMessageAt = Date.now();
msgCount++;
const now = Date.now();
if (now - lastHeartbeat > 60_000) {
axios.get(VIGILMON_URL).catch(() => {});
lastHeartbeat = now;
}
}
}
run().catch(err => {
metrics.contextAlive = false;
console.error(err);
});
Step 6: Alert Routing
| Monitor | Alert Channel | Priority |
|---|---|---|
| ZMQ context /health/zmq | Slack + PagerDuty | P1 |
| REQ/REP timeout /health/zmq/req | Slack + PagerDuty | P1 |
| SUB liveness /health/zmq/sub | Slack | P2 |
| Drop rate /health/zmq/drops | Slack | P2 |
| Heartbeat: PULL worker | Slack + email | P2 |
The REQ/REP monitor deserves special attention: a REP socket crash leaves the paired REQ forever blocked. Set the check interval to 2 minutes and fire PagerDuty immediately — REQ/REP deadlocks require manual recovery (restart both sides).
For PUSH/PULL topologies processing time-sensitive data, configure the drop rate monitor with a tight threshold (1%) and a short interval. Silent message drops at high throughput can mean significant data loss before anyone notices.
Summary
ZeroMQ's brokerless design makes it fast and flexible — but also invisible by default. Adding a lightweight health sidecar to each worker transforms opaque socket processes into monitorable services:
| Monitor Type | What It Covers |
|---|---|
| HTTP monitor on /health/zmq | Context health, drop count, message staleness |
| HTTP monitor on /health/zmq/sub | PUB/SUB subscriber liveness |
| HTTP monitor on /health/zmq/req | REQ/REP round-trip health and timeout detection |
| HTTP monitor on /health/zmq/push | PUSH HWM pressure and queue fill rate |
| Heartbeat monitor | Worker process liveness and processing continuity |
Get started free at vigilmon.online — your first ZeroMQ worker monitor is running in under two minutes.