What to monitor and alert on

The probes worth polling on a Bicrypto install — the public readiness URL, the batch health endpoint, PM2, the scheduler heartbeat, queue depth, host metrics and the licence — with a cron-and-curl recipe.

11 min readUpdated 12 August 2026monitoring, alerting, pm2, health, cron, redis

The rest of this section tells you how to check things by hand. This page is what to watch when nobody is looking.

Every failure that has cost an operator money on this platform was quiet. The site kept serving, every page loaded, the admin panel stayed green — and the cron app was dead, or Redis had gone, or the licence heartbeat had been firewalled off three days earlier. None of those announce themselves. All of them are one HTTP request or one shell command away from being an alert.

Start with the four signals at the top of the next section. Everything after them is refinement.

The four that matter

Signal How Alert when
Is the site up GET /api/health — unauthenticated anything other than 200, or a timeout
Is the scheduler alive the cron:scheduler Redis key, or GET /api/admin/system/cron/scheduler the key is gone, or status is not running
Are the processes up pm2 jlist any app not online, or a restart count that keeps climbing
Is the platform healthy GET /api/admin/system/health/batch overall.status is not healthy

The first three need no session and no permission key. The fourth does, which is why it is last.

The external probe: GET /api/health

This is the endpoint to point a load balancer, an uptime monitor or a container orchestrator at. It is unauthenticated, it is cheap enough to poll every few seconds forever, and — unlike every other endpoint on the platform — its status code alone is the answer, so a monitor never has to parse a body to know what to do.

Liveness and readiness. 200 while this backend can serve, 503 when it cannot.
Code Meaning What a load balancer should do
200 This process is serving and can reach its database keep it in rotation
503 A critical dependency is gone; this process can serve nothing take it out of rotation

A 200 body carries the detail behind the verdict:

{
  "status": "ok",
  "uptime": 8241,
  "timestamp": "2026-08-12T12:25:47.738Z",
  "checks": {
    "database": { "status": "up", "latency": 1 },
    "redis": { "status": "up", "latency": 1 }
  }
}
  • status is ok or degraded. degraded still answers 200, and it means exactly one thing today: Redis is unreachable. That stops scheduled jobs and cross-process cache invalidation, but it does not stop this process serving requests — and since every node shares one Redis, failing the probe on it would drain your whole fleet out of the load balancer for a fault that removing nodes cannot fix. Alert on degraded; do not depower on it.
  • The database is the only fatal check. With MySQL gone essentially every other endpoint 500s, so a 503 here is the honest answer rather than a node that stays in rotation serving errors.
  • Every probe is bounded (2s for the database, 1s for Redis). The endpoint answers even when a dependency is wedged, which is the case where a naive health check hangs and your monitor blames the network.
  • uptime is whole seconds for this process. A number that keeps resetting is a crash loop, which pm2 jlist's restart count confirms.

Cache-Control: no-store is set on every response, so a proxy in front of the platform cannot serve you a stale verdict about liveness.

scripts/updator-migrate.js polls /api/settings as its readiness gate during an update, and scripts/gate.mjs treats a 4xx on it as reachable — a backend that answers 401 is still a backend that is answering. That tells you how much it proves: this process is serving HTTP and can read its settings. It says nothing about the database or Redis. Prefer /api/health for monitoring; reach for /api/settings only when you specifically want "is anything listening".

pnpm stop, pnpm restart and pnpm updator all route through scripts/maintenance-on.js, which deletes the three PM2 apps and starts the maintenance server in their place. That server binds the frontend port and the backend port and answers:

Request Response
/health or /api/health 503, {"status":"maintenance"}
anything under /api/ 503, {"status":false,...} with Retry-After: 300
everything else 503, the maintenance HTML page

Port 4001 is deliberately not bound — the cron app binds it only to avoid a clash and nothing is meant to connect to it.

So a monitor that pages on "any non-200" will page on every planned update. Both a planned window and a dead database answer 503 on the same URL, and the body is what tells them apart:

Body What it is Page?
{"status":"maintenance", ...} the maintenance server — a planned stop no, unless it runs long
{"message":"Database unreachable: ...","statusCode":503} the real backend, database down yes
connection refused, timeout, a 502 from your proxy nothing is listening at all yes

If your monitor can only match on the status line, alert on 503 persisting past your normal update duration rather than on the first one.

The internal probe: GET /api/admin/system/health/batch

Permission access.admin. This is what draws the health card on the admin dashboard at /admin, and it is the one endpoint that folds the whole install into a number.

Runs eleven checks and returns a score

It returns overall: { score, status } plus a services array. Each service is { name, status, message, latency?, critical? } where status is one of up, down, warning or unconfigured.

Check What it actually measures Critical
Database models.user.count(); warning over 1000 ms yes
Cache (Redis) a real PING; warning over 500 ms, down on failure yes
Scheduler (Cron) the heartbeat, read against this process's own CRON_MODE yes
Email Service that the variables for the selected APP_EMAILER are set — not that mail sends no
Exchange Provider an active exchange row plus its API key and secret in .env no
Transaction Queue PENDING transactions, and those older than the 72-hour SLA no
Withdrawal Queue PENDING withdrawals, and those older than the 7-day SLA no
KYC Queue PENDING applications older than 7 days, or a backlog over 50 no
Support Queue open tickets, and open tickets with importance: HIGH no
Error Rate FAILED transactions as a share of the last 24 hours critical over 10%
Blockchain RPC first configured EVM RPC that answers — omitted entirely unless ecosystem or wallet_connect is enabled no

How the score is built

Start at 100, then for each service:

Service state Deduction
down and critical 40
down and not critical 15
warning and critical 15
warning and not critical 5
unconfigured none

status is then critical if any critical service is down or the score is under 50, warning under 80, and healthy otherwise.

Two consequences worth building your alert around:

  • A single critical service down takes the score to 60 — still above the 50 floor, but criticalDown forces critical anyway. Alert on overall.status, not on the number.
  • unconfigured is free. An install with no exchange provider and no mail configured can read 100. The score answers "is anything broken", not "is anything missing".

If the handler throws, it returns a fallback body: score 50, status warning, and a single service named Health Check carrying the error message. A monitor that only reads overall will call that a warning. Count the services array too — fewer than about nine entries on a normal install means the checks did not run.

The per-service probe, and why not to poll it

Deep-probes one named service

GET /api/admin/system/health takes a service query parameter and really calls the third party: Stripe's balance endpoint, TransFi's /v3/balance, the SMS provider's own healthCheck(), every configured fiat-rate provider, the EVM RPC and WebSocket endpoints for each chain, and ScyllaDB when ecosystem is enabled. An unknown or missing service returns an empty object.

Each service's result is stored in a module-level map the first time it is asked for, and nothing ever clears or expires it. A provider that was down when you first checked reads Down until the backend restarts; one that was up reads Up through a total outage.

?service=email is worse than stale: it enqueues a real test email to NEXT_PUBLIC_APP_EMAIL on the first call.

This endpoint is a diagnostic for a human pressing a button, not a probe. Poll health/batch instead — that one recomputes every time.

PM2

production.config.js defines three apps: backend, frontend and cron. pm2 jlist is the machine-readable list; three fields matter.

pm2_env.status is not online

Any of stopped, errored or launching for more than a few seconds is an incident. stopped on backend or cron is the one to page on, because PM2 will not bring it back on its own.

A climbing restart_time on cron

The cron app carries max_memory_restart: "2G" — far below the web backend's ceiling, deliberately, because that is the entire point of running the scheduler in its own process. PM2 measures RSS, so it recycles the app before V8 reaches its heap limit and starts producing multi-second garbage-collection pauses.

One restart is the ceiling working. A restart count that climbs every few hours means a job is leaking, and the platform is losing whatever was mid-flight each time. Alert on the rate, not the value: a delta over a day is the useful signal.

Exit code 78, which no restart can fix

78 is EX_CONFIG from sysexits.h, and every production.*.config.js lists it in stop_exit_codes. PM2 therefore stops the app instead of restart-looping it, leaving the explanation on screen instead of scrolling it away behind sixteen retries.

Three things raise it:

Raised by Meaning
backend/preflight.ts the running Node major cannot load the shipped native modules — see Requirements
backend/src/utils/redis.ts Redis did not answer within 30 seconds at boot. Redis is a hard dependency, not a cache
maintenance/server.js maintenance mode could not bind a port, which means the app you believe you stopped is still serving

An app in stopped with a non-zero exit code is not a transient failure. It is waiting for a person.

pm2 jlist | node -e 'JSON.parse(require("fs").readFileSync(0)).forEach(p=>console.log(p.name, p.pm2_env.status, p.pm2_env.restart_time))'
pm2 logs cron --lines 100

Scheduler liveness

A dead scheduler is invisible from the website. Every page loads, every other probe is green, and no withdrawal is processed, no price is written, no investment settles, no binary contract expires.

The scheduling process — whichever one registered the jobs — rewrites a single Redis key cron:scheduler every 15 seconds with a 90-second TTL. SCHEDULER_STALE_MS is 60 seconds: a beat older than that is treated as no beat, deliberately shorter than the TTL so the message can say how long it has been silent.

Reports which process is scheduling and how recently it reported

status is one of:

status Means Page?
running one process is beating no
missing no beat in 90 seconds — nothing scheduled is happening yes
stale a beat exists but is older than 60 seconds yes
duplicate two processes are registering jobs yes, first
unknown the heartbeat could not be read because Redis could not be read as a cache alert

duplicate is reported ahead of staleness because it is worse than having no scheduler at all: BullMQ hands a repeatable job to whichever worker takes it and the single-flight guard is per-process, so every money-moving job runs twice over the same rows.

The heartbeat is a plain Redis key with a TTL, so the cheapest possible scheduler alarm needs no authentication and no HTTP at all:

redis-cli EXISTS cron:scheduler     # 0 = nothing has scheduled in 90 seconds
redis-cli TTL cron:scheduler        # seconds left before it disappears

Add -a "$REDIS_PASSWORD" if REDIS_PASSWORD is set in .env.

The value is JSON carrying instanceId, pid, hostname, mode, jobs and peer. A non-null peer is the duplicate-scheduler condition; a jobs count of 0 is its own finding.

The scheduler console is the screen behind all of this, and Scheduled jobs lists every job with its cadence.

Queue depth and backlog age

The notification queue

Service status, Redis, channels and the email queue
Sent, failed and success rate per channel
Raw BullMQ counts for the notification queue

health returns components.emailQueue with waiting, active, completed, failed, delayed and the Redis cache hit rate. queue/stats returns the same counts plus a computed health.status, which flips to degraded when failed > completed × 0.1, and a failureRate percentage.

What to alert on:

  • waiting growing and not draining. A queue that is deep but moving is a busy platform; one that is deep and static means the workers are gone — which usually means the same thing as a missing scheduler heartbeat.
  • failed climbing at all. These are notifications customers did not get: withdrawal confirmations, KYC decisions, password resets.
  • metrics per channel. A successRate collapsing on EMAIL alone is a provider problem, not a platform one.

All three are gated on access.notification.settings, not access.admin, so a role that can read the health card cannot necessarily read these.

Backlog ages, and where the numbers come from

The health probe already computes backlog age against one file, backend/src/utils/sla.ts, which is the single definition of "late" for the whole product:

Queue SLA Used by the health probe
transaction 72 hours yes — Transaction Queue
withdrawal 7 days yes — Withdrawal Queue
deposit 72 hours no
transfer 72 hours no
kyc 7 days the KYC check uses the same 7 days, written out inline
support 24 hours no — the Support check counts open and high-priority tickets, not age
dispute 24 hours no
order 48 hours no
approval 72 hours no

frontend/config/sla.ts mirrors the file so the queue screens badge rows with the same thresholds the dashboard warns on. If you change one, change both.

The practical alert is the Withdrawal Queue line going to warning: it means real customer money has been sitting in PENDING for a week. See The withdrawal queue.

Host metrics

Three of these are not visible from inside the product at all, and two of them have taken installs down.

Disk

Two directories grow without bound.

  • PM2 logs. Nothing in this product installs or configures pm2-logrotate, so PM2 writes to ~/.pm2/logs forever. On a busy install the cron log alone is the largest file on the box within a couple of months. Install the module (pm2 install pm2-logrotate) or rotate the directory yourself.
  • frontend/public/uploads. KYC documents, dispute evidence, ticket attachments, avatars and product images. It never shrinks, and it is also the directory your backups have to carry — see Backups.

Alert at 80% of the filesystem. A full disk stops MySQL writes, which stops everything.

MySQL connections

Each backend process opens its own pool: DB_POOL_MAX, default 25, with DB_POOL_MIN 2 and a 30-second acquire timeout. A standard deployment is three apps, two of which are backends (backend and cron), so a default install can hold 50 connections before anything else — against MySQL's own default max_connections of 151.

That is comfortable until you run a second web process, raise DB_POOL_MAX, or point a second host at one database. Watch Threads_connected against max_connections; alert at 80%. Connection exhaustion surfaces as SequelizeConnectionAcquireTimeoutError and looks, from the outside, exactly like a slow site.

Redis

Redis is a hard boot dependency, not a cache. The backend refuses to start without it (exit 78) and it carries the distributed locks that stop a binary order settling twice, the scheduler heartbeat, sessions, rate limits, the cross-process cache invalidation bus and the BullMQ job queues.

Watch two things:

  • used_memory against maxmemory. If Redis is configured with a maxmemory and an eviction policy, it will start discarding keys under pressure — and the keys it discards include locks and the heartbeat. Alert on evicted_keys being anything other than zero.
  • Availability. Redis disappearing mid-run does not stop the backend: it logs one critical line, repeats once a minute while the outage lasts, and logs again when it clears. pm2 logs backend | grep -i redis is where that lands.
redis-cli INFO stats | grep evicted_keys
redis-cli INFO memory | grep -E 'used_memory_human|maxmemory_human'

Licence reachability

The licence heartbeat runs every 6 hours and the offline grace period is 72 hours. Those two numbers together produce the nastiest delayed failure on the platform: a firewall change that blocks outbound 443 to updates.mashdiv.com produces an install that works perfectly for three days and then starts refusing licensed addons — long after anybody connects the two events.

Probe the egress, not the symptom:

curl -sS -o /dev/null -w '%{http_code}\n' https://updates.mashdiv.com

Alert on anything that is not an HTTP response. Full detail, including what the heartbeat sends and how to recover an install that has already fallen out of grace, is on Licensing.

A minimal alerting recipe

If you have no monitoring stack, this is enough to catch every failure named on this page. It needs curl, redis-cli, pm2 and a mail command.

#!/usr/bin/env bash
# Run from cron every 5 minutes. Prints nothing when healthy.
set -u
ALERT_TO="ops@example.com"
API="http://127.0.0.1:4000/api/settings"
APPS="backend frontend cron"
problems=""

# 1. Is the API answering? 503 is maintenance, not an outage.
code=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 10 "$API" || echo 000)
case "$code" in
  200) ;;
  503) ;;  # maintenance mode - remove this line to be told about updates
  *)   problems="${problems}API returned ${code}\n" ;;
esac

# 2. Is every PM2 app online?
for app in $APPS; do
  state=$(pm2 jlist | node -e "
    const l=JSON.parse(require('fs').readFileSync(0));
    const p=l.find(x=>x.name==='$app');
    console.log(p?p.pm2_env.status:'absent');")
  [ "$state" = "online" ] || problems="${problems}PM2 ${app} is ${state}\n"
done

# 3. Is anything scheduling? The key has a 90-second TTL.
beat=$(redis-cli EXISTS cron:scheduler 2>/dev/null | tr -d ' \r')
[ "$beat" = "1" ] || \
  problems="${problems}NO scheduler heartbeat - nothing scheduled is running\n"

# 4. Disk.
use=$(df -P / | awk 'NR==2 {gsub("%","",$5); print $5}')
[ "$use" -lt 85 ] || problems="${problems}Disk at ${use}%\n"

# 5. Licence egress.
curl -fsS -o /dev/null --max-time 15 https://updates.mashdiv.com || \
  problems="${problems}updates.mashdiv.com unreachable - 72h grace is running\n"

if [ -n "$problems" ]; then
  printf "%b" "$problems" | mail -s "Bicrypto alert on $(hostname)" "$ALERT_TO"
fi
chmod +x /usr/local/bin/bicrypto-watch.sh
crontab -e
# */5 * * * * /usr/local/bin/bicrypto-watch.sh

health/batch needs an authenticated admin. Doing that from cron means leaving an admin session or an admin-owned API key on the box, which is a real security decision — an API key authenticates as its owner, with its owner's role.

The four checks above deliberately use only things that need no credential. Read health/batch from the admin dashboard, where a human is already signed in, and keep the unattended script credential-free.

When an alert fires

  1. Read the API probe first. A 503 is maintenance — check whether an update is running before doing anything else.

  2. pm2 list. An app that is stopped with a non-zero exit code is waiting for a person; pm2 logs <app> --lines 100 has the reason on screen.

  3. If the scheduler is the alert, go to The scheduler console. missing and stale are both "start it"; duplicate is "run pnpm start, then stop whatever was started outside PM2".

  4. If Redis is the alert, everything else on this page is unreliable until it is back — the heartbeat, the locks and the queues all live there.

  5. Otherwise open /admin and read the health card, then Troubleshooting for the specific subsystem.