Skip to main content

Observability

The server emits Prometheus metrics and structured JSON logs. Neither carries resource content by default, but both can carry identifiers — see the warning at the end of this page.

Metrics​

Metrics are exposed at /metrics, outside the FHIR base path so a scraper does not traverse the FHIR middleware stack.

Request
curl -sS http://localhost:9090/metrics | grep fhir_
Response
# HELP fhir_request_total Total number of FHIR HTTP requests.
# TYPE fhir_request_total counter
fhir_request_total{method="GET",route="/fhir/r4/Patient",status_code="200"} 1
fhir_request_total{method="GET",route="/fhir/r4/Patient/{id}",status_code="404"} 1
fhir_request_total{method="GET",route="/t/acme/fhir/r4/Patient",status_code="200"} 2
MetricTypeLabels
fhir_request_totalcountermethod, route, status_code

The route label is the matched route pattern, not the raw URL, so ids are collapsed to {id} and cardinality stays bounded. In a multi-tenant deployment the tenant prefix is part of the pattern (/t/acme/fhir/r4/Patient), which lets you break traffic down per tenant.

Alongside it, /metrics exposes the standard Go runtime and process collectors — go_goroutines, go_memstats_*, go_gc_duration_seconds, process_resident_memory_bytes, and so on — roughly 40 metric families in total.

note

fhir_request_total is the only FHIR-specific metric. There is no request-latency histogram, so response-time monitoring has to come from your ingress, service mesh, or APM agent rather than from this endpoint.

A minimal scrape config:

scrape_configs:
- job_name: fhir-server
metrics_path: /metrics
static_configs:
- targets: ['fhir-server:9090']

Structured logs​

The server writes JSON to standard output. Set verbosity with LOG_LEVEL (debug, info, warn, error; default info) — see Configuration.

Request
LOG_LEVEL=info ./fhir-server
Response
{"time":"2026-09-03T08:55:01Z","level":"INFO","msg":"server listening","addr":":9090","baseURL":"http://localhost:9090/fhir/r4"}

Startup logs are worth capturing on every deploy, because they report the effective configuration after environment overrides are applied:

Response
{"level":"INFO","msg":"search tuning","probeCap":5000,"defaultPageSize":20,"maxPageSize":0,"maxChainDepth":5,"planCacheMode":"force_custom_plan"}
{"level":"INFO","msg":"write tuning","maxRowsPerStatement":1000,"maxRowsPerBundle":100000}
{"level":"INFO","msg":"FHIR router initialized","baseURL":"http://localhost:9090/fhir/r4","validateOnWrite":false,"baseValidation":true,"igPackages":0}

Confirm those logged values match what you intended rather than assuming an environment variable took effect.

What to monitor​

SignalWhere it comes fromWhy
Request volume and status classesfhir_request_totalDetect error spikes and traffic shifts
Request latencyIngress or APMNot exposed by this server
Readiness state and restart count/health/ready, orchestratorDistinguish crash loops from slow startup
PostgreSQL connections, lock waits, statement latency, disk, autovacuumDatabase monitoringThe database is the capacity limit for search and writes
Transaction Bundle duration and rejection countsLogs, fhir_request_total by statusLarge Bundles are the most common timeout source
Implementation Guide load failuresStartup logsA failed package silently reduces validation coverage
Terminology latency and failuresLogsTerminology-backed search modifiers fail closed
Cross-tenant access-denial checksYour own probesVerify isolation continuously in shared deployments

Correlation​

Preserve a request correlation identifier at the ingress and propagate it, so a single FHIR request can be traced across the gateway, the server logs, and the database.

PHI

FHIR resources and search parameters can contain protected health information. Search values may appear in URLs, and URLs commonly reach access logs, traces, and metrics labels. Apply data minimisation and access controls to every telemetry sink, and avoid logging request or response bodies by default.