Monitoring
How to watch a running Meridian install, covering the /health liveness and readiness endpoint, the Prometheus metrics listener and its series, OpenTelemetry traces and metrics, Sentry error reporting, the JSON logs, the job queue and scheduler commands, and the doctor support bundle.
The engine reports on itself five ways: a health endpoint for load balancers, Prometheus metrics on a listener of their own, optional OpenTelemetry export and error reporting, structured logs on standard error, and engine-dmo doctor for a one-file snapshot.
Health
Every site’s host answers GET /health:
| Request | Answers 200 when | Body |
|---|---|---|
/health | The process is up (liveness) | {"status":"ok"} |
/health?readiness | Startup is complete, the database answers within 2 seconds, and every migration the binary knows is applied. Not while shutting down | {"status":"ok","checks":{"database":"ok","migrations":"ok"}} |
Otherwise /health?readiness answers 503 with status set to starting (still booting or warming the page cache), stopping (shutting down) or degraded (a check failed), and the failing check’s error under checks. Responses are Cache-Control: no-store.
Use liveness for “restart it if it stops answering” and readiness for “send it traffic”. Readiness turns false as soon as a shutdown begins, so a load balancer drains a replica before it stops; the engine then finishes in-flight requests for up to server.shutdown_grace (30 seconds by default).
From the command line, against the server this project’s configuration describes:
engine-dmo health # liveness; prints the body
engine-dmo health --readiness # exit 0 only when ready
engine-dmo health --readiness --wait 60s # keep trying for 60 seconds, for a deploy step engine-dmo health requests http://<server.listen>/health, so it runs on the same machine or in the same container as the server, with the same environment. It exits 4 when the server does not answer 200.
Metrics
The engine serves Prometheus text metrics on a listener of its own, never on the public sites:
telemetry:
metrics:
enabled: true # default
bind: 127.0.0.1:9090 # default: this machine only
path: /_metrics # default curl -s http://127.0.0.1:9090/_metrics To let a Prometheus on another host or container scrape it, bind to an interface that network reaches (0.0.0.0:9090 inside a container network) and keep the port off the public internet. bind may not equal server.listen. Only engine-dmo serve opens the listener; one-off commands do not.
Series
| Series | Type | Labels | Meaning |
|---|---|---|---|
meridian_http_requests_total | Counter | class, status | Requests by route class (public, admin, api, portal, endpoint, preview, static, beacon, health, other) and status code |
meridian_http_request_duration_seconds | Histogram | class | Request latency |
meridian_db_up | Gauge | 1 when the database answered at scrape time | |
meridian_db_ping_seconds | Gauge | The database’s round trip at scrape time | |
meridian_db_pool_max_connections, _total_connections, _acquired_connections, _idle_connections | Gauge | The connection pool | |
meridian_db_pool_acquires_total, _empty_acquires_total, _canceled_acquires_total, _empty_acquire_wait_seconds_total | Counter | How often, and how long, a request waited for a connection | |
meridian_jobs_pending, meridian_jobs_running, meridian_jobs_dead | Gauge | queue | The job queue |
meridian_jobs_oldest_runnable_age_seconds | Gauge | queue | How long the oldest runnable job has waited |
meridian_job_runs_total | Counter | queue, outcome | Job runs by outcome |
meridian_scheduler_leader | Gauge | 1 on the replica that holds the scheduler lease | |
meridian_scheduler_task_success_age_seconds | Gauge | task | On the leader: seconds since each scheduled task last succeeded |
meridian_scheduler_task_failures_total | Counter | task | On the leader: each task’s failures |
meridian_cache_entries, meridian_cache_hits_total, meridian_cache_misses_total | Gauge, counters | The in-process cache | |
meridian_analytics_events_written_total, meridian_analytics_events_dropped_total | Counter | Analytics ingest; drops mean the ingest buffer was full | |
meridian_search_queries_total | Counter | outcome | Search queries by outcome |
meridian_search_breaker_open | Gauge | 1 while the search circuit breaker has stopped asking a failing backend | |
meridian_mail_deliveries_total | Counter | outcome (sent, suppressed, rejected, provider_error) | Transactional mail |
meridian_media_derivatives_generated_total, meridian_media_derivative_failures_total | Counter | Image derivative generation | |
meridian_ai_requests_total | Counter | outcome | AI provider calls, when AI is configured |
Series that read the database are collected at scrape time with a time limit, so a database that does not answer shows as meridian_db_up 0 rather than a scrape that hangs.
What to alert on
| Alert | Expression (sketch) |
|---|---|
| Not ready | Readiness probe failing for more than 2 minutes |
| Database down | meridian_db_up == 0 |
| Pool exhausted | meridian_db_pool_acquired_connections / meridian_db_pool_max_connections > 0.9 for 10 minutes |
| Server errors | Rate of meridian_http_requests_total{status=~"5.."} above your baseline |
| Dead jobs | meridian_jobs_dead > 0 |
| Queue stuck | meridian_jobs_oldest_runnable_age_seconds > 900 |
| No scheduler | sum(meridian_scheduler_leader) == 0 for 5 minutes |
| Analytics behind | meridian_scheduler_task_success_age_seconds{task="analytics.rollup"} > 3600 |
| Search backend failing | meridian_search_breaker_open == 1 |
| Mail failing | Rate of meridian_mail_deliveries_total{outcome="provider_error"} above zero |
| Disk | Free space on the storage, database and backup volumes (from your node exporter) |
OpenTelemetry
Traces and metrics can also be pushed over OTLP/HTTP (JSON encoding) to a collector:
telemetry:
otlp:
endpoint: https://otel-collector.internal:4318 # /v1/traces and /v1/metrics are appended
headers:
Authorization: ${OTEL_AUTH_HEADER}
timeout: 10s
traces:
enabled: true
sample_ratio: 0.1
metrics:
export: true
interval: 1m
resource:
service_name: meridian
attributes:
deployment.environment: production Every header value is a secret (${VAR}). Traces sample 10% of new traces by default; an inbound traceparent decides only when respect_parent_sampling: true, so a visitor cannot make every request expensive. Each database statement is a span when tracing is on. The full key list is in engine.yaml: telemetry.
Error reporting
Errors and panics can be reported to Sentry (or any service that accepts the Sentry protocol):
telemetry:
errors:
driver: sentry
dsn: ${SENTRY_DSN}
environment: production # defaults to engine.environment
sample_rate: 1.0 # errors only; a panic is always sent Check the reporter and the OTLP endpoint from the deployment:
engine-dmo telemetry test --note "deploy 2026-11-02" The engine also keeps its own grouped error log in the database, which engine-dmo doctor --include-logs 20 prints and the admin’s diagnostics screen shows. A configured secret quoted in an error is scrubbed before it is stored.
Logs
The engine writes one structured line per event to standard error: JSON by default, or logfmt-style text with logging.format: text.
{"time":"2026-11-02T03:15:01.204Z","level":"INFO","msg":"request","request_id":"01JB...","event":"http.request","method":"GET","path":"/whats-on/","status":200,"bytes":48211,"duration_ms":12.4} | Setting | Values | Default |
|---|---|---|
logging.level | debug, info, warn, error | info |
logging.format | json, text | json |
Each request is one request line with method, path, status, bytes and duration_ms, plus request_id and, when tracing, trace_id. A line written by a job or a provider call carries the request_id of the request that caused it. The request id is taken from an inbound X-Request-ID header when it comes from a trusted proxy, otherwise minted, and is sent on outbound calls. Single-use tokens in paths (enrolment, reset, magic and invitation links) are logged as {token}.
Boot logs the version, variant, bundle, modules, environment, the extensions loaded, the database server version, and each migration or schema change applied.
Jobs and scheduled tasks
Background work runs in every engine process: jobs from a queue in the database, and scheduled tasks on whichever replica holds the scheduler lease.
engine-dmo jobs list # jobs not yet complete, and dead ones, newest first
engine-dmo jobs list --failed # dead jobs and jobs waiting to retry after an error
engine-dmo jobs retry --id <job id> # run a dead job again with fresh attempts, once the cause is fixed
engine-dmo jobs retry --all-failed --queue <queue>
engine-dmo schedule list # every scheduled task, its cadence, last success and the leader
engine-dmo schedule run analytics.rollup --now # run one task once, in this process; audited schedule list reads the last successes from the running engine’s metrics listener, so run it where telemetry.metrics.bind is reachable.
With audit.hash_chain on, the daily audit.anchor task logs an audit chain head line per realm, with realm, seq and hash. Keep these lines: they are how a rebuilt chain is detected.
The doctor bundle
engine-dmo doctor writes one pasteable text file describing the install: build, localisation data, configuration (non-secret), plugins, database (version, encoding, collation, ICU, pool, largest tables), storage, media pipeline, templates, mail, search (and whether the index matches what is published), jobs, permissions, timings, and recorded errors. It works without a database too, reporting the connection failure.
engine-dmo doctor --out doctor.txt
engine-dmo doctor --include-logs 20 --redact-level paranoid --out doctor.txt
engine-dmo doctor --mail-to you@example.org # also send a live test message
engine-dmo doctor --telemetry-test # also test the error reporter and OTLP endpoint | Flag | Meaning |
|---|---|
--out | Write the bundle to a file instead of the terminal |
--include-logs N | Include the N most recent error groups the serving engine recorded |
--redact-level | standard (default) redacts every secret by declaration and reports the database as host and name; paranoid also redacts host names, the database host and name, email addresses and IP addresses |
--mail-to | Send a live test message through the configured mail driver |
--telemetry-test | Send a test event to the error reporter and an empty export to the OTLP endpoint |
Related
- engine.yaml: telemetry and logging
- Scaling: the scheduler lease across replicas.
- Error codes: every
MRD-nnnncode a log line or refusal carries.
Upgrades
The production procedure for moving an install to a new engine version (check in CI, back up, migrate, apply the schema, roll out, verify), how a rolling upgrade behaves across replicas, and how to roll back.
Scaling
Running more than one engine replica behind a load balancer, covering what each replica does, the scheduler lease, shared storage, caches across replicas, background job workers, database connections and the page cache.