Enable logs, traces and metrics
The proxy and the engine always emit structured logs to stdout. Traces,
metrics, and OTLP log export are opt-in: point OTEL_EXPORTER_OTLP_ENDPOINT
at a collector and they turn on; leave it unset and every instrument stays a
no-op at zero cost (apps/api/src/observability/otel.ts). This how-to wires
that export, brings up the bundled local visualization stack, and sets
liveness/readiness probes. For the full catalogue of log fields, span names
and metric instruments, see
reference/observability-signals.md;
for the complete variable list see
reference/environment-variables.md.
Before you start
- Observability env vars are read once at process boot
(
initLogger/startOtelare called at the top ofapps/api/src/server/api.tsfor the proxy andapps/api/src/restate/server.tsfor the engine). Changing any variable below requires restarting the corresponding process to take effect. - The proxy and the engine are two separate processes with independent
service names (
meridian-proxy/meridian-endpoint) but share the same environment variable names — set them on whichever process(es) you are instrumenting. - Only the proxy serves
/healthzand/readyz(port 4000); the engine has no HTTP probe endpoint of its own.
1. Configure structured logs
Logs are JSON-on-stdout by default outside an interactive terminal (pino,
apps/api/src/observability/logger.ts), one object per line, with a
service field (meridian-proxy / meridian-endpoint) and a component
field per subsystem (plugins, instance, http, sources, engine, …).
- Set the verbosity with
LOG_LEVEL(trace|debug|info|warn|error, defaultinfo). - Set
LOG_PRETTYto force a format:1forces human-readable (pino-pretty),0forces JSON. Leave it unset to get the default (pretty on an interactive TTY outside production, JSON otherwise — e.g. under Docker or any collector). - Optionally set
SERVICE_NAMEto override theservicefield baked in by each entrypoint (meridian-proxy/meridian-endpoint).
Secret-looking fields (authorization, apiKey, api_key, password,
token, req.headers.authorization, config.apiKey) are redacted
automatically as defense in depth. Events themselves are only ever logged by
type and id — no patient payload reaches the logs, by design; see
explanation/security-posture.md.
2. Export traces, metrics and logs to a collector
- Set
OTEL_EXPORTER_OTLP_ENDPOINTto your collector's OTLP/HTTP endpoint, e.g.http://localhost:4318. This single variable enables traces + metrics + logs together — there is no per-signal toggle. - Optionally set
OTEL_SERVICE_NAMEto override the resourceservice.namereported to the collector (defaults tomeridian-proxy/meridian-endpoint). - Restart the proxy and/or the engine.
startOtel()is idempotent, so re-running it on an already-started process is harmless, but the environment variable is only read once at boot.
With the endpoint set, every HTTP request into the proxy, every Restate ingress call, and every workflow node execution produces a span in a single end-to-end trace per event, with the W3C trace context propagated from the proxy to the engine over the Restate invocation payload:
POST /api/events (proxy)
└─ restate.ingress (proxy)
└─ workflow.run (engine)
├─ node <id> (one span per executed node; map body: node <id>#<i>)
│ └─ fhir GET|POST|PUT / posos.graphql (outbound I/O, under the calling node)
└─ …
Business metrics (clinical.events.generated, clinical.runs.executed,
clinical.nodes.duration, …) and HTTP/ingress latency histograms are exported
the same way; see
reference/observability-signals.md
for the full instrument list and attributes
(apps/api/src/observability/metrics.ts).
A human-task validation suspends the run — there is no span for the wait itself. On Restate replay after resumption, spans for nodes that already ran once may be re-emitted; single-pass workflows are unaffected. See explanation/why-durable-execution.md.
3. Correlate logs with traces
Every log line emitted while a span is active already carries trace_id /
span_id (a pino mixin, apps/api/src/observability/logger.ts). To also
ship those logs to your collector as OTLP log records — so you can pivot from
a log line to its trace in Grafana — both of these must hold at once:
- output is JSON, i.e.
LOG_PRETTYis0or unset on a non-TTY process (prettyisfalse); and OTEL_EXPORTER_OTLP_ENDPOINTis set.
When both hold, logs are shipped in-process through the pino → OTel bridge
(apps/api/src/observability/otel-logs.ts) with their trace context attached
natively. This is always the case for containerized deployments; in a bare
dev process with LOG_PRETTY=1 (or an interactive TTY), logs stay
stdout-only even with the endpoint set.
4. Run the bundled LGTM stack locally
For local, all-in-one visualization use the grafana/otel-lgtm image
(Tempo + Prometheus + Loki + Grafana, pre-wired) defined under the obs
compose profile in docker-compose.yml.
Start it alongside the containerized stack:
OTEL_EXPORTER_OTLP_ENDPOINT=http://lgtm:4318 \ docker compose --profile stack --profile obs up -d --buildOpen Grafana at
http://localhost:3001(admin/admin).Go to Explore and pick a datasource:
- Tempo — search by service
meridian-proxyormeridian-endpoint. - Prometheus — query the
clinical_*metrics. - Loki — query
{service_name="meridian-endpoint"}(or-proxy), then click a log line'strace_idto jump straight into its Tempo trace.
- Tempo — search by service
Running the proxy/engine on the host instead of in containers: start only the collector (
docker compose --profile obs up -d lgtm) and exportOTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318beforepnpm run endpoint/pnpm run proxy.
5. Wire liveness and readiness probes
The proxy exposes two probes (apps/api/src/server/api.ts):
GET /healthz— liveness: returns200 { ok: true }as soon as the process is up. No dependency checks.GET /readyz— readiness:200 { ready: true, checks }only when both the Restate ingress (GET {RESTATE_URL}/restate/health) and the run index respond; otherwise503 { ready: false, checks }with a per-dependency boolean inchecks(restate,runStore) telling you which one failed.
- Point your orchestrator's liveness probe at
GET /healthzon the proxy (port 4000). - Point its readiness probe at
GET /readyzon the same port. The bundled docker-compose.yml already does this for theproxyservice's Docker healthcheck — mirror the same path aslivenessProbe.httpGet.path/readinessProbe.httpGet.pathfor any other orchestrator (Kubernetes, systemd, …); see reference/topology.md for where the proxy sits in the deployment. - The engine (
apps/api/src/restate/server.ts) has no/healthzor/readyzof its own — its health is observed through Restate's own admin healthcheck (restate:9070, already probed by therestateservice in the compose file) and through the proxy'schecks.restate.
Verify
Confirm the probes respond:
curl -i http://localhost:4000/healthz curl -i http://localhost:4000/readyz/readyzshould return200with"ready": trueonce Restate and the run index are both reachable.Trigger any event or run (see tutorials/first-instance.md) and confirm a log line appears on stdout with matching
trace_id/span_idfields.With the LGTM stack up, find that same trace in Grafana's Tempo Explore view, and the corresponding log line in Loki via the
trace_idpivot.
Related
- reference/observability-signals.md — full list of log fields, span names and metric instruments with their attributes.
- reference/environment-variables.md — every environment variable, not just the observability ones.
- explanation/why-durable-execution.md — why traces can show re-emitted spans across a suspend/resume.
- explanation/security-posture.md — what is (and isn't) allowed to reach logs and traces.
- how-to/use-postgres-run-index.md — the run index checked by
/readyz. - how-to/upgrade-the-platform.md — restart/rollout order when changing these variables across replicas.