Schedule icon
Request icon
Return icon
Switch icon
Log icon
SlackIncomingWebhook icon

FHIR Pending Lab Results Sentinel

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.

Categories
BusinessData

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.

How it works

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.

What you get

  • A scheduled, read-only results backlog check against any FHIR R4 server (HAPI FHIR, Microsoft Azure Health Data Services, Google Cloud Healthcare API, AWS HealthLake, an EHR's FHIR endpoint) with no vendor plugin.
  • A status output (HEALTHY / WARNING / CRITICAL / UNREACHABLE) other flows can gate on, and a backlog JSON output with pending, stale, critical, fhir_version and oldest.
  • Slack messages that name the oldest unfinished report by test, status, collection time and resource id, for example Potassium [Moles/volume] in Serum or Plasma (preliminary, collected 2026-10-10T08:00:00Z, DiagnosticReport/1003), without patient identifiers.
  • An audit log line on every run, so the backlog's history survives a Slack outage.

Who it's for

  • Laboratory and clinical informatics teams who run or feed a FHIR server and need to know when results stop flowing.
  • Integration and interface engineers responsible for analyzer, LIS and EHR feeds.
  • Data platform teams already using Kestra who want the same alerting for their health data platform.

Why orchestrate this with Kestra

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.

Prerequisites

  • A FHIR R4 server that supports DiagnosticReport search by status, category and date, plus _sort, _total and _summary=count (HAPI FHIR does).
  • A read-only access token for a system account (for example a SMART Backend Services client with system/DiagnosticReport.read). For a server without authentication, any value works.
  • A Slack incoming webhook for the WARNING, CRITICAL, UNREACHABLE and flow-failure alerts.

Secrets

  • 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).

Quick start

  1. To try it without a production server, run the open-source HAPI FHIR JPA server locally (the 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.
  2. Add 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.
  3. Update those reports to final and run again: the status becomes HEALTHY.
  4. Enable hourly_results_check once pointed at the real server.

How to extend

  • Add code= to the searches to watch only critical tests (for example potassium or troponin) with a tighter stale_hours.
  • Run the flow once per category (LAB, MB, HM) with a ForEach, each with its own thresholds.
  • Add _has or subject:Patient.organization= filters to watch one hospital site in a shared server.
  • Store status and stale in the KV store and alert only when the backlog grows.

Inputs

  • 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.

Outputs

  • 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"}.

Links

Pitfalls

  • FHIR 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.
  • Some servers do not support _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.
  • Category codes differ between systems (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.
  • The search response names the oldest report's test and id only (_elements=code,status,effective), and the Slack messages carry no patient data. Keep the audit logs and Slack channel inside your organisation anyway.
  • Some FHIR servers cache identical searches for a short time (HAPI FHIR reuses results for 60 seconds by default), which would report an old total. Every request sends Cache-Control: no-cache to get a fresh result; keep it if you add searches.
  • A full CapabilityStatement can be several megabytes (about 2 MB on HAPI FHIR), more than Kestra's default 1 MB limit for a task output, which is why /metadata is read with _elements. If your server ignores _elements there, use _summary=true instead or raise kestra.task.outputs.limit.
  • The check only reads. It never changes a report's status or contacts patients.
See How

New to Kestra?

Use blueprints to kickstart your first workflows.