Monitoring
Two probes that mean different things, one histogram that answers three questions, and request logs that join to a trace. What ships on by default and what you have to turn on.
Your app is running. How do you know it is working? Three surfaces answer that, and they do not all come from the same place.
| Surface | Comes from | Needs |
|---|---|---|
| Health probes | questpie core routes | Nothing |
| Request logs | questpie core, via Pino | Nothing |
| Traces and metrics | @questpie/observability | An OTLP collector |
The first two are already on. The third is one config key.
The two probes
Two routes come from the core module. There is nothing to enable. Both are
public, and both sit under your handler's base path. That is /api in every
starter.
| Route | Point this at | Touches |
|---|---|---|
/api/health/live | livenessProbe | Nothing |
/api/health | readinessProbe, load balancer | Database, KV, search |
Liveness must not touch the database. If it does, one database blip fails
the probe on every replica at once. The orchestrator restarts all of them. A
recoverable outage turns into a cold start under load. /api/health/live
answers from the process alone and returns its uptime in seconds.
Readiness may. A replica that cannot reach the database should leave rotation. A failed readiness probe is exactly how that happens.
livenessProbe:
httpGet: { path: /api/health/live, port: 3000 }
periodSeconds: 10
readinessProbe:
httpGet: { path: /api/health, port: 3000 }
periodSeconds: 5
timeoutSeconds: 5What readiness checks
| Check | What runs | On failure |
|---|---|---|
database | SELECT 1 through the app's client | 503 |
kv | A read of one reserved key | 503 |
search | app.search.isInitialized() | degraded, 200 |
storage | Nothing. Reports it is configured. | cannot fail |
queue | Nothing. Reports it is configured. | cannot fail |
Storage and queue are not contacted on purpose. Any real probe costs an
object-storage request or enqueues a job. That happens on every health check, at
load-balancer frequency. Check storage reachability from a scheduled job
instead. The queue line reads configured even when no queue adapter is set, so
do not alert on it.
{
"status": "ok",
"timestamp": "2026-08-03T09:12:44.108Z",
"checks": {
"database": { "status": "ok", "latency_ms": 3 },
"kv": { "status": "ok", "latency_ms": 1 },
"search": { "status": "ok" },
"storage": { "status": "ok", "detail": "configured (not probed)" },
"queue": { "status": "ok", "detail": "configured (not probed)" }
}
}Read the body, not just the status. The worst check wins. unhealthy returns 503.
degraded and ok both return 200. Both probes answer with
cache-control: no-store.
degraded has exactly one cause. Search has not finished starting. That happens
in the background at boot, so a fresh process can report it for a moment. The
replica keeps serving, which is the point.
Give the probe more than four seconds
The database and KV checks are each bounded at 2 seconds, and they run one after the other. If both hang the response takes four seconds. A shorter probe timeout fails before the check does.
The default KV adapter is in-process
On an app with no kv config the check reads an in-memory map, so it proves
nothing about a shared cache. It becomes a real network round trip once you
point KV at Redis.
Request logs
Every request writes one structured line through Pino. The message is always
HTTP request completed. The record carries requestId, traceId, method,
path, status, durationMs and slow. The matched route pattern arrives as
route when routing found one.
Output is JSON on stdout. Pretty printing turns itself on when NODE_ENV is
development. The level defaults to info.
import { runtimeConfig } from "questpie/app";
export default runtimeConfig({
db: { url: process.env.DATABASE_URL! },
app: { url: process.env.APP_URL! },
logger: {
requests: {
slowThresholdMs: 1000,
logSuccessfulRequests: false,
ignorePaths: ["/api/health", "/api/health/live"],
},
},
});ignorePaths matters more than it looks. A readiness probe every 5 seconds
across 10 replicas is 172,800 lines a day that tell you nothing.
ignorePaths matches the full pathname
Including the base path. Under a handler mounted at /api the value is
/api/health, not /health. A string has to match exactly. Pass a RegExp
when it should not.
logSuccessfulRequests and ignorePaths only suppress successes. Neither one
can hide a failing request. logger.requests: false turns the lot off in one
word.
Request logs has every option, the order the filters run in, which level a request lands at, and the shape of the record.
Correlation
Every response carries x-request-id and x-trace-id. The request id is read
from an inbound x-request-id or x-correlation-id when there is one. So an id
your proxy assigned survives. Otherwise it is a fresh UUID.
The trace id comes from x-trace-id first. Then from the trace id inside
traceparent. Failing both, it falls back to the request id.
Both land on the log line and on the request's span. So you can start from either and find the other.
Traces and metrics
Install @questpie/observability and give it an OTLP endpoint. Requests, CRUD
calls, database queries, transactions, jobs, KV and search all open spans. An
inbound traceparent is continued rather than replaced.
One metric is recorded. http.server.request.duration is a histogram in
seconds, sliced by http.request.method, http.route and
http.response.status_code. That one instrument is the whole RED triple. Rate
is its count. Errors are that count sliced by status. Duration is the histogram
itself. Parallel counters only duplicate the series.
All collections are served by one parameterized route. So http.route will not
tell posts apart from users.
Flush on shutdown or the last batch is lost. That batch is usually the interesting one.
process.on("SIGTERM", async () => {
await app.destroy();
process.exit(0);
});Observability covers the adapter options, sampling and pointing it at a backend.
What to alert on
| Alert on | Read it from | Why |
|---|---|---|
| Readiness failing on several replicas | The probe | One replica is one replica. Several at once is a shared cause. |
| 5xx rate | The histogram, sliced by status | A 404 is a client asking for something absent. A 500 is you. |
| p99 request duration | The same histogram | The mean hides the requests people complain about. |
| Failed job spans | Traces | A job fails outside any request, so nothing upstream reports it. |
| Database connection saturation | Your database | Usually the first hard limit you hit. |
Queue depth is worth watching too, and QUESTPIE does not emit it. Read it from your queue backend. A queue draining slowly is a different problem from a queue that is failing. Only the second is urgent at 3am.
Related
- Request logs, every option and the filter order.
- Instrumented seams, every span and attribute.
- Trace a slow request, install to waterfall.
- Scaling, replicas, workers and connection limits.