Last week I read that companies are putting AI agents to work maintaining mainframes and that confidence in the platform held up while modernization runs alongside (CIO Dive, on BMC's annual survey). My first reaction had nothing to do with agents. It was that a platform older than most of the people operating it can tell you what it did at 3:12am on a Tuesday, and several Node services I have opened this year cannot.
So I stopped arguing about it in meetings and ran two boring tests on a codebase I had read access to. Six HTTP services, Node and TypeScript, Postgres, Redis, one external payments API. Both tests took an afternoon and neither needed anything beyond docker, curl, grep and jq.
Test 1: stop the database, then ask the service if it is healthy
The health endpoint in four of the six services looked like this. Same shape, different indentation.
app.get('/health', (req, res) => {
res.status(200).json({ status: 'ok', uptime: process.uptime() });
});
It reads nothing. It answers because the process is alive and the event loop got to it. Here is the run:
$ docker compose stop postgres
[+] Stopping 1/1
✔ Container app-postgres Stopped
$ curl -s -o /dev/null -w "%{http_code}\n" localhost:3000/health
200
$ curl -s localhost:3000/orders/8814 | head -c 80
{"statusCode":500,"message":"Internal server error"}
Four services returned 200 with the database down. One returned 503, and it was the only one whose author had wired an actual query into the check. The sixth had no health route at all, which is at least honest.
The dashboard on the wall reads that endpoint every 30 seconds and draws availability at 99 point something. During the two minutes Postgres was stopped, the graph stayed green and every order request returned 500. The metric existed. The guarantee did not.
Test 2: rebuild one order from the logs
Second test, and this is the one that changed my opinion about what legacy means. Pick an order that behaved oddly yesterday and reconstruct its path from the log file, without opening a psql session.
$ wc -l app-2026-08-31.log
41823 app-2026-08-31.log
$ grep -c 8814 app-2026-08-31.log
2
Two lines for an order that went through six code paths. One was the access log line with the URL, the other was a stack trace. Everything in between was logged like this:
2026-08-31T14:02:12.114Z info discount applied
2026-08-31T14:02:12.119Z warn fallback rate used
2026-08-31T14:02:12.240Z info order persisted
I counted 31 lines in that time window that plausibly belonged to the request. Twenty two of them carried no id of any kind, so "plausibly" was the best I could do. Under concurrency, that window contained three other orders. There is no way to tell which one used the fallback rate. Not a hard question, an unanswerable one.
What I tried first and had to undo
My first move was the obvious one: put the dependency checks inside /health, the same route the Kubernetes liveness probe already pointed at. It worked for about a day. Then the payments API had a slow minute, three checks went past the probe timeout, and kubelet restarted pods that were serving traffic fine. I turned a downstream hiccup into a restart storm of my own making.
The split that stuck: /health answers for the process only and stays on liveness. /ready answers for dependencies and goes on the readiness probe, with a per check timeout well under the probe timeout.
const CHECK_TIMEOUT_MS = 800;
async function check(name, fn) {
const started = Date.now();
const timer = new Promise((_, reject) =>
setTimeout(() => reject(new Error('timeout')), CHECK_TIMEOUT_MS),
);
try {
await Promise.race([fn(), timer]);
return { name, ok: true, ms: Date.now() - started };
} catch (err) {
return { name, ok: false, ms: Date.now() - started, error: err.message };
}
}
app.get('/ready', async (req, res) => {
const checks = await Promise.all([
check('postgres', () => db.query('select 1')),
check('redis', () => redis.ping()),
check('payments', () => fetch(`${PAYMENTS_URL}/ping`)),
]);
const ok = checks.every((c) => c.ok);
res.status(ok ? 200 : 503).json({ ok, checks });
});
Same run as before, with the database stopped:
$ curl -s -o /dev/null -w "%{http_code}\n" localhost:3000/health
200
$ curl -s localhost:3000/ready | jq -c '.checks[] | select(.ok == false)'
{"name":"postgres","ok":false,"ms":802,"error":"timeout"}
Giving the log lines an owner
For the second problem I did not adopt a tracing stack. I added a request id in async local storage and a log function that always merges it in, so nobody has to remember to pass context down five call levels.
const { AsyncLocalStorage } = require('node:async_hooks');
const crypto = require('node:crypto');
const store = new AsyncLocalStorage();
app.use((req, res, next) => {
const requestId = req.header('x-request-id') || crypto.randomUUID();
res.setHeader('x-request-id', requestId);
store.run({ requestId, route: req.path }, next);
});
function log(level, msg, fields = {}) {
const ctx = store.getStore() || {};
process.stdout.write(
JSON.stringify({ ts: new Date().toISOString(), level, msg, ...ctx, ...fields }) + '\n',
);
}
// at the call site, the order id rides along with the value that matters
log('warn', 'fallback rate used', { orderId: order.id, rate, source: 'default_table' });
The replay of the same question, one command instead of an afternoon:
$ jq -r 'select(.orderId == "8814") | [.ts, .level, .msg, .rate // ""] | @tsv' app.log
2026-09-01T11:40:02.101Z info order created
2026-09-01T11:40:02.118Z info discount applied 0.15
2026-09-01T11:40:02.121Z warn fallback rate used 0.09
2026-09-01T11:40:02.244Z info order persisted
Thirty one of thirty one lines now carry the id. The whole change was under 60 lines across the four services, plus the tedious part, which was rewriting roughly 90 log call sites that were passing strings built with template literals.
What I take from it
The date of the first commit told me nothing. Two of these services are from 2019, two are from last year, and they failed the same two checks. The 1970s platform in that survey is auditable because someone paid to keep it that way for decades, with records, procedures and a named owner per change window. An agent reading its logs has something to read. Pointed at a service that logs "discount applied" with no id, any agent, human or otherwise, produces a confident guess about code that moves money.
One thing I am still unsure about: whether the split between liveness and readiness is worth it for a single instance service with no orchestrator, where a failing dependency and a failing process end in the same restart either way.
How do you draw the line on your readiness checks, and do you let a slow third party API mark you as not ready?
Originally published on the Revin blog.
Top comments (0)