EMQX Neuron is an open-source industrial IoT connectivity server and protocol converter developed by EMQ Technologies. It runs on edge gateways and industrial PCs, polling PLCs, SCADA systems, and field devices using native industrial protocols — Modbus RTU/TCP, OPC-UA, BACnet/IP, DNP3, IEC 60870-5-104, Siemens S7, Mitsubishi MELSEC, and others — then converting the polled data into structured MQTT messages for cloud-native platforms like EMQX, AWS IoT Core, and Azure IoT Hub.
In traditional industrial environments, a driver disconnection or polling failure is completely silent at the MQTT level: Neuron simply stops publishing data from that device, while all other drivers continue normally. An OPC-UA session that drops at 3 AM, a Modbus serial timeout that goes undetected, or a license expiry that silently caps tag polling will cause operational dashboards to show stale PLC data without any error being surfaced. Vigilmon gives you external monitoring of Neuron's HTTP API so you catch industrial connectivity failures before your operators do.
Why EMQX Neuron Needs External Monitoring
EMQX Neuron ships with a web-based management UI and REST API, but no built-in alerting for driver disconnections or polling failures. External monitoring with Vigilmon adds:
- Process health alerting when the Neuron server crashes and all industrial device polling stops
- Driver connection monitoring — each Modbus, OPC-UA, or BACnet driver connection is a potential point of failure; alerts on any production driver disconnecting
- MQTT publish health — catches loss of connectivity to the upstream MQTT broker so industrial data actually reaches the cloud
- Tag polling error rate — a >5% tag read error rate on any device indicates serial/network issues before data loss becomes critical
- OPC-UA session monitoring — OPC-UA subscriptions drop when sessions expire; alerts before operators notice stale data
- License health — Neuron may cap tag polling above 100 tags without a license; alerts before the cap silently truncates your data collection
Step 1: Enable the Neuron REST API
EMQX Neuron exposes a REST API on port 7000 (default). Verify it is reachable:
# Default credentials: admin / 0000
curl -u admin:0000 http://localhost:7000/api/v2/ping
You should receive {"status":"OK"}.
Create a read-only monitoring token via the Neuron API:
curl -X POST http://localhost:7000/api/v2/login \
-H 'Content-Type: application/json' \
-d '{"name":"admin","pass":"0000"}'
# Returns {"token":"<jwt>"}
Store this JWT as an environment variable for your health sidecar:
export NEURON_API_TOKEN="<jwt>"
Step 2: Build a Neuron Health Sidecar
Node.js Health Sidecar
// health/neuron.js
const express = require('express');
const axios = require('axios');
const app = express();
const NEURON_URL = process.env.NEURON_API_URL || 'http://localhost:7000/api/v2';
const NEURON_TOKEN = process.env.NEURON_API_TOKEN;
const TAG_ERROR_RATE_THRESHOLD = parseFloat(process.env.TAG_ERROR_RATE_THRESHOLD || '0.05');
const LICENSE_WARN_DAYS = parseInt(process.env.LICENSE_WARN_DAYS || '30');
const headers = { Authorization: `Bearer ${NEURON_TOKEN}` };
async function getNeuronNodes() {
const res = await axios.get(`${NEURON_URL}/node`, { headers, timeout: 5000 });
return res.data.nodes || [];
}
async function getNodeState(nodeName) {
const res = await axios.get(`${NEURON_URL}/node/state?node=${encodeURIComponent(nodeName)}`, {
headers, timeout: 5000,
});
return res.data;
}
async function getNodeMetrics(nodeName) {
const res = await axios.get(`${NEURON_URL}/metrics/node?node=${encodeURIComponent(nodeName)}`, {
headers, timeout: 5000,
});
return res.data;
}
// Neuron node link states: 0=disconnected, 1=connecting, 2=connected, 3=ready
const LINK_STATE = { 0: 'disconnected', 1: 'connecting', 2: 'connected', 3: 'ready' };
app.get('/health/neuron', async (req, res) => {
try {
// Check basic API health
const ping = await axios.get(`${NEURON_URL.replace('/api/v2', '')}/api/v2/ping`, {
headers, timeout: 3000,
});
if (ping.data?.status !== 'OK') {
return res.status(503).json({ status: 'down', reason: 'neuron_api_not_ok', ping: ping.data });
}
} catch (err) {
return res.status(503).json({ status: 'down', reason: 'neuron_api_unreachable', error: err.message });
}
try {
const nodes = await getNeuronNodes();
// Find southbound (driver) nodes
const southboundNodes = nodes.filter(n => n.plugin_kind === 1 || n.node_type === 'driver');
const disconnectedDrivers = [];
for (const node of southboundNodes) {
try {
const state = await getNodeState(node.name);
const linkState = state.link_state ?? state.link ?? 0;
if (linkState === 0 || linkState === 1) { // disconnected or connecting
disconnectedDrivers.push({
node: node.name,
plugin: node.plugin,
link_state: LINK_STATE[linkState] || linkState,
});
}
} catch {
disconnectedDrivers.push({ node: node.name, plugin: node.plugin, link_state: 'error_checking' });
}
}
if (disconnectedDrivers.length > 0) {
return res.status(503).json({
status: 'degraded',
reason: 'southbound_drivers_disconnected',
disconnected: disconnectedDrivers,
total_drivers: southboundNodes.length,
});
}
return res.status(200).json({
status: 'ok',
total_nodes: nodes.length,
southbound_drivers: southboundNodes.length,
all_drivers_connected: true,
});
} catch (err) {
return res.status(503).json({ status: 'down', error: err.message });
}
});
app.get('/health/neuron/tags', async (req, res) => {
try {
const nodes = await getNeuronNodes();
const southboundNodes = nodes.filter(n => n.plugin_kind === 1 || n.node_type === 'driver');
const highErrorNodes = [];
let totalTagsRead = 0;
let totalTagsError = 0;
for (const node of southboundNodes) {
try {
const metrics = await getNodeMetrics(node.name);
const read = parseInt(metrics.tag_reads_total || metrics.messages_read || 0);
const errors = parseInt(metrics.tag_read_errors_total || metrics.messages_error || 0);
totalTagsRead += read;
totalTagsError += errors;
if (read > 0 && errors / read > TAG_ERROR_RATE_THRESHOLD) {
highErrorNodes.push({
node: node.name,
tag_reads: read,
tag_errors: errors,
error_rate_pct: Math.round((errors / read) * 100 * 10) / 10,
});
}
} catch {
// Metrics endpoint may not be available for all node types
}
}
if (highErrorNodes.length > 0) {
return res.status(503).json({
status: 'degraded',
reason: 'high_tag_error_rate',
high_error_nodes: highErrorNodes,
threshold_pct: TAG_ERROR_RATE_THRESHOLD * 100,
});
}
return res.status(200).json({
status: 'ok',
total_tags_read: totalTagsRead,
total_tag_errors: totalTagsError,
overall_error_rate_pct: totalTagsRead > 0
? Math.round((totalTagsError / totalTagsRead) * 100 * 10) / 10 : 0,
drivers_checked: southboundNodes.length,
});
} catch (err) {
return res.status(503).json({ status: 'down', error: err.message });
}
});
app.get('/health/neuron/northbound', async (req, res) => {
try {
const nodes = await getNeuronNodes();
// Northbound nodes = MQTT broker connections
const northboundNodes = nodes.filter(n => n.plugin_kind === 2 || n.node_type === 'app');
const disconnected = [];
for (const node of northboundNodes) {
try {
const state = await getNodeState(node.name);
const linkState = state.link_state ?? state.link ?? 0;
if (linkState === 0) {
disconnected.push({
node: node.name,
plugin: node.plugin,
link_state: LINK_STATE[linkState],
});
}
} catch {
disconnected.push({ node: node.name, link_state: 'error_checking' });
}
}
if (disconnected.length > 0) {
return res.status(503).json({
status: 'degraded',
reason: 'northbound_mqtt_disconnected',
disconnected,
total_northbound: northboundNodes.length,
});
}
return res.status(200).json({
status: 'ok',
northbound_nodes: northboundNodes.length,
all_connected: true,
});
} catch (err) {
return res.status(503).json({ status: 'down', error: err.message });
}
});
app.listen(3010, () => console.log('Neuron health sidecar listening on :3010'));
Python Health Sidecar
# health/neuron_health.py
import os, requests
from flask import Flask, jsonify
app = Flask(__name__)
NEURON_URL = os.environ.get('NEURON_API_URL', 'http://localhost:7000/api/v2')
TOKEN = os.environ.get('NEURON_API_TOKEN', '')
TAG_ERR_THRESHOLD = float(os.environ.get('TAG_ERROR_RATE_THRESHOLD', 0.05))
HEADERS = {'Authorization': f'Bearer {TOKEN}'}
LINK_STATE = {0: 'disconnected', 1: 'connecting', 2: 'connected', 3: 'ready'}
def get_nodes():
r = requests.get(f'{NEURON_URL}/node', headers=HEADERS, timeout=5)
r.raise_for_status()
return r.json().get('nodes', [])
def get_node_state(name):
r = requests.get(f'{NEURON_URL}/node/state', params={'node': name},
headers=HEADERS, timeout=5)
r.raise_for_status()
return r.json()
@app.route('/health/neuron')
def neuron_health():
try:
r = requests.get(f'{NEURON_URL}/ping', headers=HEADERS, timeout=3)
if r.json().get('status') != 'OK':
return jsonify(status='down', reason='neuron_api_not_ok'), 503
except Exception as e:
return jsonify(status='down', reason='neuron_api_unreachable', error=str(e)), 503
try:
nodes = get_nodes()
drivers = [n for n in nodes if n.get('plugin_kind') == 1 or n.get('node_type') == 'driver']
disconnected = []
for node in drivers:
try:
state = get_node_state(node['name'])
link = state.get('link_state', state.get('link', 0))
if link in (0, 1):
disconnected.append({'node': node['name'], 'plugin': node.get('plugin'),
'link_state': LINK_STATE.get(link, link)})
except Exception:
disconnected.append({'node': node['name'], 'link_state': 'error_checking'})
if disconnected:
return jsonify(status='degraded', reason='southbound_drivers_disconnected',
disconnected=disconnected, total_drivers=len(drivers)), 503
return jsonify(status='ok', total_nodes=len(nodes),
southbound_drivers=len(drivers), all_drivers_connected=True)
except Exception as e:
return jsonify(status='down', error=str(e)), 503
@app.route('/health/neuron/northbound')
def neuron_northbound():
try:
nodes = get_nodes()
nb_nodes = [n for n in nodes if n.get('plugin_kind') == 2 or n.get('node_type') == 'app']
disconnected = []
for node in nb_nodes:
try:
state = get_node_state(node['name'])
link = state.get('link_state', state.get('link', 0))
if link == 0:
disconnected.append({'node': node['name'], 'link_state': LINK_STATE.get(link)})
except Exception:
disconnected.append({'node': node['name'], 'link_state': 'error_checking'})
if disconnected:
return jsonify(status='degraded', reason='northbound_mqtt_disconnected',
disconnected=disconnected, total_northbound=len(nb_nodes)), 503
return jsonify(status='ok', northbound_nodes=len(nb_nodes), all_connected=True)
except Exception as e:
return jsonify(status='down', error=str(e)), 503
if __name__ == '__main__':
app.run(port=3010)
Step 3: Configure Vigilmon HTTP Monitors
- Log in to vigilmon.online and go to Monitors → New Monitor
- Choose HTTP / HTTPS
- URL:
https://your-edge-gateway.example.com/health/neuron - Check interval: 1 minute
- Under Expected response:
- Status code:
200 - Response body contains:
"status":"ok" - Response time threshold:
5000ms
- Status code:
- Assign your primary alert channel (PagerDuty for production industrial deployments)
- Save
Add monitors for each health endpoint:
| Monitor URL | Purpose | Interval | Priority |
|---|---|---|---|
| /health/neuron | Server health, southbound driver connections | 1 min | P1 |
| /health/neuron/northbound | MQTT broker connectivity (northbound) | 1 min | P1 |
| /health/neuron/tags | Tag polling error rates per device | 5 min | P2 |
Also add a direct TCP check on port 7000 to catch Neuron process crashes before the health sidecar detects them:
- In Vigilmon, go to Monitors → New Monitor → TCP Port
- Host: your edge gateway hostname
- Port:
7000 - Interval: 1 minute
- Save
Step 4: Heartbeat Monitoring for Tag Data Pipeline
Neuron publishes polled industrial tag data to your MQTT broker, which downstream consumers (time-series databases, SCADA historians, analytics pipelines) subscribe to. Wire a heartbeat into your downstream consumer to detect when tag data stops arriving even if Neuron appears healthy.
- In Vigilmon, go to Monitors → New Monitor → Heartbeat
- Name:
neuron-tag-data-pipeline - Expected interval: 5 minutes
- Grace period: 10 minutes
- Copy the heartbeat URL
Wire into your MQTT subscriber:
// pipeline/neuron-subscriber.js
const mqtt = require('mqtt');
const axios = require('axios');
const client = mqtt.connect(process.env.MQTT_BROKER_URL);
const HB_URL = process.env.VIGILMON_HEARTBEAT_URL;
let tagCount = 0;
let lastHbAt = 0;
client.on('connect', () => {
// Subscribe to all Neuron tag output topics
client.subscribe('neuron/+/+/+', { qos: 1 });
});
client.on('message', async (topic, payload) => {
tagCount++;
// Ping heartbeat every 5 minutes of activity
const now = Date.now();
if (now - lastHbAt > 5 * 60 * 1000) {
await axios.get(HB_URL).catch(() => {});
lastHbAt = now;
}
});
For Modbus-based deployments where polling intervals may be 30–60 seconds, use a time-based heartbeat with a window that accounts for expected polling cadence:
// For slow-polling industrial devices (30s Modbus poll interval)
setInterval(async () => {
const recentTags = tagCount;
if (recentTags > 0) {
await axios.get(HB_URL).catch(() => {});
tagCount = 0;
}
}, 5 * 60 * 1000); // every 5 minutes
Step 5: OPC-UA and Modbus-Specific Monitoring
OPC-UA Session Health
OPC-UA session drops are common when Neuron is restarted or when OPC-UA servers enforce session lifetime limits. Add an explicit check for OPC-UA session state:
app.get('/health/neuron/opcua', async (req, res) => {
try {
const nodes = await getNeuronNodes();
const opcuaNodes = nodes.filter(n =>
(n.plugin || '').toLowerCase().includes('opcua') ||
(n.plugin || '').toLowerCase().includes('opc-ua')
);
const disconnected = [];
for (const node of opcuaNodes) {
const state = await getNodeState(node.name);
const link = state.link_state ?? state.link ?? 0;
if (link !== 2 && link !== 3) {
disconnected.push({ node: node.name, link_state: LINK_STATE[link] || link });
}
}
if (disconnected.length > 0) {
return res.status(503).json({
status: 'degraded',
reason: 'opcua_sessions_disconnected',
disconnected,
total_opcua_nodes: opcuaNodes.length,
});
}
return res.status(200).json({
status: 'ok',
opcua_nodes: opcuaNodes.length,
all_sessions_active: true,
});
} catch (err) {
return res.status(503).json({ status: 'down', error: err.message });
}
});
Add this as a monitor at /health/neuron/opcua with a 2-minute interval.
Step 6: Alert Routing
| Monitor | Alert Channel | Priority | Condition |
|---|---|---|---|
| TCP: port 7000 | Slack + PagerDuty | P1 | Neuron process crash |
| HTTP: /health/neuron | Slack + PagerDuty | P1 | Any southbound driver disconnected |
| HTTP: /health/neuron/northbound | Slack + PagerDuty | P1 | MQTT broker connectivity lost |
| HTTP: /health/neuron/tags | Slack | P2 | Tag read error rate >5% on any device |
| HTTP: /health/neuron/opcua | Slack | P2 | OPC-UA session disconnected |
| Heartbeat: tag data pipeline | Slack + email | P2 | Tag data not flowing to downstream |
For safety-critical industrial environments (PLC write-back control paths, energy management, manufacturing lines), consider using a 30-second TCP check interval on port 7000 and a 1-minute grace period on the tag pipeline heartbeat.
For non-critical monitoring-only deployments (condition monitoring, asset tracking), a 5-minute check interval and a 15-minute grace period on the heartbeat avoids alert fatigue during planned Neuron maintenance windows.
Summary
EMQX Neuron failures are invisible at the cloud level — your MQTT broker and IoT platform continue operating normally while industrial device data simply stops arriving. External monitoring with Vigilmon gives you real-time visibility into driver connection health, tag polling error rates, northbound MQTT connectivity, and data pipeline liveness.
| Monitor Type | What It Covers |
|---|---|
| TCP: port 7000 | Neuron process crash, port unreachable |
| HTTP: /health/neuron | Southbound driver connections (Modbus, OPC-UA, BACnet) |
| HTTP: /health/neuron/northbound | MQTT broker connectivity, publish health |
| HTTP: /health/neuron/tags | Tag read error rates per device |
| HTTP: /health/neuron/opcua | OPC-UA session liveness |
| Heartbeat: tag pipeline | End-to-end data flow from PLC to cloud |
Get started free at vigilmon.online — your first Neuron monitor is running in under two minutes.