New to Kestra?
Use blueprints to kickstart your first workflows.
Search a FHIR R4 server on a schedule, change nothing, and alert Slack when lab reports stay unfinished too long after collection or the server is down.
A lab result that is stuck as registered or preliminary is a result a clinician is not acting on. It happens when an analyzer interface stops, a validation queue backs up, or a message fails between the laboratory system and the EHR, and nothing pages anyone: the report simply never becomes final. This blueprint turns standard FHIR R4 searches into a scheduled results backlog gate: /metadata proves the server is really answering, DiagnosticReport searches return how many reports in the category are not final, how many are older than stale_hours and critical_hours after collection, and the oldest one, and the result routes to a quiet log, an informational Slack note or a Slack page.
read_capability_statement (io.kestra.plugin.core.http.Request) reads /metadata?_elements=fhirVersion,software,kind. Only a CapabilityStatement counts as a working FHIR server. Every request is a GET with a bearer token, Cache-Control: no-cache, a timeout, three retries, allowFailed: true and allowFailure: true, so an outage never aborts the flow.search_pending_reports searches DiagnosticReport?status=registered,partial,preliminary&category=<category> sorted by date with _count=1, _total=accurate and _elements=code,status,effective: the total pending, and only the oldest report.count_stale_reports and count_critical_reports run the same search with date=lt<now minus stale_hours> and date=lt<now minus critical_hours> and _summary=count, so they return a count and no clinical data. The three searches only run when /metadata answered.classify_results_backlog (io.kestra.plugin.core.debug.Return) checks every response in one jq query: a missing response, a non-200 code, anything other than a CapabilityStatement and searchset Bundles with a numeric total (for example an OperationOutcome or an HTML login page) becomes UNREACHABLE. Otherwise any report past critical_hours, or at least critical_stale_count past stale_hours, is CRITICAL; any report past stale_hours is WARNING; otherwise HEALTHY.route_by_backlog (io.kestra.plugin.core.flow.Switch) logs HEALTHY quietly, posts a Slack note for WARNING, pages Slack for CRITICAL and UNREACHABLE, and logs any unexpected value in defaults.log_audit_result writes one audit line per run with all four HTTP codes and the classification. The flow-level errors block alerts Slack if the flow fails outright, and the hourly_results_check trigger ships disabled: true.status output (HEALTHY / WARNING / CRITICAL / UNREACHABLE) other flows can gate on, and a backlog JSON output with pending, stale, critical, fhir_version and oldest.Potassium [Moles/volume] in Serum or Plasma (preliminary, collected 2026-10-10T08:00:00Z, DiagnosticReport/1003), without patient identifiers.Interface engines alert when a message fails, but not when a report quietly stays preliminary for three days. Kestra runs the same standard FHIR searches on a schedule against any server, classifies the result in one typed status, keeps every run in the execution history, and routes it to the right channel, while the Switch branches are ready to grow into real follow-up (a ticket for the interface team, a per-department digest) without changing the check.
DiagnosticReport search by status, category and date, plus _sort, _total and _summary=count (HAPI FHIR does).system/DiagnosticReport.read). For a server without authentication, any value works.FHIR_ACCESS_TOKEN: bearer token sent in the Authorization header.SLACK_WEBHOOK: Slack incoming webhook used for every alert branch and the flow-failure alert.In Kestra OSS (no Enterprise secrets backend), secrets are supplied as environment variables prefixed SECRET_ and base64-encoded, then read in flows with {{ secret('NAME') }}, for example SECRET_FHIR_ACCESS_TOKEN=$(echo -n '<token>' | base64).
hapiproject/hapi image or the hapi-fhir-jpaserver-starter project), and load a few DiagnosticReport resources with category LAB, different status values and effectiveDateTime values hours or days in the past.FHIR_ACCESS_TOKEN and SLACK_WEBHOOK as secrets, set fhir_base_url (for example http://localhost:8080/fhir), and run the flow once. Reports that are still preliminary more than 24 hours after collection make the status WARNING, more than 72 hours CRITICAL.final and run again: the status becomes HEALTHY.hourly_results_check once pointed at the real server.code= to the searches to watch only critical tests (for example potassium or troponin) with a tighter stale_hours.category (LAB, MB, HM) with a ForEach, each with its own thresholds._has or subject:Patient.organization= filters to watch one hospital site in a shared server.status and stale in the KV store and alert only when the backlog grows.fhir_base_url: type STRING, default "https://fhir.example.org/fhir".category: type STRING, default "LAB".stale_hours: type INT, default 24.critical_hours: type INT, default 72.critical_stale_count: type INT, default 10.status: one of HEALTHY, WARNING, CRITICAL, UNREACHABLE.backlog: the full classification as JSON, for example {"fhir_version":"4.0.1","pending":9,"stale":7,"critical":2,"oldest":"...","status":"CRITICAL"}.date on DiagnosticReport is the clinically relevant time (effective[x], usually specimen collection), not when the report was created. Reports without effective[x] are not matched by the stale searches; they still count in pending._total=accurate or _summary=count and leave total out. The flow reports UNREACHABLE rather than treating a missing total as zero; check your server's CapabilityStatement.LAB from HL7 v2-0074 is the most common). Run one search by hand first to confirm your reports are tagged the way you expect._elements=code,status,effective), and the Slack messages carry no patient data. Keep the audit logs and Slack channel inside your organisation anyway.Cache-Control: no-cache to get a fresh result; keep it if you add searches./metadata is read with _elements. If your server ignores _elements there, use _summary=true instead or raise kestra.task.outputs.limit.