Meridian handbook
Deploying

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:

RequestAnswers 200 whenBody
/healthThe process is up (liveness){"status":"ok"}
/health?readinessStartup 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

SeriesTypeLabelsMeaning
meridian_http_requests_totalCounterclass, statusRequests by route class (public, admin, api, portal, endpoint, preview, static, beacon, health, other) and status code
meridian_http_request_duration_secondsHistogramclassRequest latency
meridian_db_upGauge1 when the database answered at scrape time
meridian_db_ping_secondsGaugeThe database’s round trip at scrape time
meridian_db_pool_max_connections, _total_connections, _acquired_connections, _idle_connectionsGaugeThe connection pool
meridian_db_pool_acquires_total, _empty_acquires_total, _canceled_acquires_total, _empty_acquire_wait_seconds_totalCounterHow often, and how long, a request waited for a connection
meridian_jobs_pending, meridian_jobs_running, meridian_jobs_deadGaugequeueThe job queue
meridian_jobs_oldest_runnable_age_secondsGaugequeueHow long the oldest runnable job has waited
meridian_job_runs_totalCounterqueue, outcomeJob runs by outcome
meridian_scheduler_leaderGauge1 on the replica that holds the scheduler lease
meridian_scheduler_task_success_age_secondsGaugetaskOn the leader: seconds since each scheduled task last succeeded
meridian_scheduler_task_failures_totalCountertaskOn the leader: each task’s failures
meridian_cache_entries, meridian_cache_hits_total, meridian_cache_misses_totalGauge, countersThe in-process cache
meridian_analytics_events_written_total, meridian_analytics_events_dropped_totalCounterAnalytics ingest; drops mean the ingest buffer was full
meridian_search_queries_totalCounteroutcomeSearch queries by outcome
meridian_search_breaker_openGauge1 while the search circuit breaker has stopped asking a failing backend
meridian_mail_deliveries_totalCounteroutcome (sent, suppressed, rejected, provider_error)Transactional mail
meridian_media_derivatives_generated_total, meridian_media_derivative_failures_totalCounterImage derivative generation
meridian_ai_requests_totalCounteroutcomeAI 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

AlertExpression (sketch)
Not readyReadiness probe failing for more than 2 minutes
Database downmeridian_db_up == 0
Pool exhaustedmeridian_db_pool_acquired_connections / meridian_db_pool_max_connections > 0.9 for 10 minutes
Server errorsRate of meridian_http_requests_total{status=~"5.."} above your baseline
Dead jobsmeridian_jobs_dead > 0
Queue stuckmeridian_jobs_oldest_runnable_age_seconds > 900
No schedulersum(meridian_scheduler_leader) == 0 for 5 minutes
Analytics behindmeridian_scheduler_task_success_age_seconds{task="analytics.rollup"} > 3600
Search backend failingmeridian_search_breaker_open == 1
Mail failingRate of meridian_mail_deliveries_total{outcome="provider_error"} above zero
DiskFree 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}
SettingValuesDefault
logging.leveldebug, info, warn, errorinfo
logging.formatjson, textjson

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
FlagMeaning
--outWrite the bundle to a file instead of the terminal
--include-logs NInclude the N most recent error groups the serving engine recorded
--redact-levelstandard (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-toSend a live test message through the configured mail driver
--telemetry-testSend a test event to the error reporter and an empty export to the OTLP endpoint