id: mirth-connect-channel-health-sentinel
namespace: company.team
description: |
On a schedule, read-only check every deployed channel of a Mirth Connect
(NextGen Connect) integration engine through its REST API: channels that
are stopped or paused, messages piling up in destination queues, and new
errors since the previous run. Classify the engine as HEALTHY, WARNING,
CRITICAL or UNREACHABLE, page Slack when HL7 messages are not flowing, and
keep the last error counts in the KV store so old errors never re-alert.
inputs:
- id: mirth_url
type: STRING
defaults: "https://mirth.example.org:8443"
displayName: "Mirth Connect URL"
description: "Base URL of the Mirth Connect server (the HTTPS port that serves
/api), without a trailing slash."
- id: warning_queued
type: INT
defaults: 50
displayName: "Warning Queued Messages"
description: "At or above this many messages waiting in one channel's
destination queues, the check is classified WARNING."
- id: critical_queued
type: INT
defaults: 500
displayName: "Critical Queued Messages"
description: "At or above this many queued messages in one channel, the check is
classified CRITICAL."
- id: critical_new_errors
type: INT
defaults: 10
displayName: "Critical New Errors"
description: "At or above this many new errors across all channels since the
previous run, the check is classified CRITICAL. Any new error is at least
WARNING."
triggers:
- id: engine_check
type: io.kestra.plugin.core.trigger.Schedule
description: Check the integration engine every 10 minutes. Shipped disabled;
enable it once mirth_url and the MIRTH_USERNAME and MIRTH_PASSWORD secrets
point at a real server and a manual run has been verified.
cron: "*/10 * * * *"
disabled: true
inputs:
mirth_url: "https://mirth.example.org:8443"
tasks:
- id: fetch_channel_statuses
type: io.kestra.plugin.core.http.Request
description: Read the dashboard status of every deployed channel (name and
state) from /api/channels/statuses. This is a GET; nothing in Mirth is
changed. Mirth's API requires the X-Requested-With header on every call.
uri: "{{ inputs.mirth_url }}/api/channels/statuses"
method: GET
headers:
Accept: application/json
X-Requested-With: OpenAPI
options:
auth:
type: BASIC
username: "{{ secret('MIRTH_USERNAME') }}"
password: "{{ secret('MIRTH_PASSWORD') }}"
allowFailed: true
timeout:
connectTimeout: PT10S
readIdleTimeout: PT30S
retry:
type: constant
interval: PT15S
maxAttempts: 3
allowFailure: true
- id: fetch_channel_statistics
type: io.kestra.plugin.core.http.Request
description: Read the message counters of every channel (received, sent, error,
filtered, queued) from /api/channels/statistics. Skipped when the statuses
could not be read.
runIf: "{{ outputs.fetch_channel_statuses.code is defined and
outputs.fetch_channel_statuses.code == 200 and
(outputs.fetch_channel_statuses.body | trim | startsWith('{')) }}"
uri: "{{ inputs.mirth_url }}/api/channels/statistics"
method: GET
headers:
Accept: application/json
X-Requested-With: OpenAPI
options:
auth:
type: BASIC
username: "{{ secret('MIRTH_USERNAME') }}"
password: "{{ secret('MIRTH_PASSWORD') }}"
allowFailed: true
timeout:
connectTimeout: PT10S
readIdleTimeout: PT30S
retry:
type: constant
interval: PT15S
maxAttempts: 3
allowFailure: true
- id: load_previous_error_counts
type: io.kestra.plugin.core.kv.Get
description: Read the per-channel error counters saved by the previous run, so
only errors that happened since then count. Missing on the first run.
key: "mirth_error_counts_{{ inputs.mirth_url | slugify }}"
errorOnMissing: false
- id: classify_engine
type: io.kestra.plugin.core.debug.Return
description: Join statuses, statistics and the previous error counters in one jq
query. Mirth's JSON returns a single object instead of a list when only
one channel is deployed and null when none is, so both shapes are
normalised. A missing response, a non-200 code or a body that is not JSON
becomes UNREACHABLE instead of looking healthy.
format: >-
{{ (outputs.fetch_channel_statuses.code is not defined or
outputs.fetch_channel_statuses.code != 200 or not
(outputs.fetch_channel_statuses.body | trim | startsWith('{')) or
outputs.fetch_channel_statistics is not defined or
outputs.fetch_channel_statistics.code is not defined or
outputs.fetch_channel_statistics.code != 200 or not
(outputs.fetch_channel_statistics.body | trim | startsWith('{'))) ?
'{"status":"UNREACHABLE","channels":0,"not_running":0,"new_errors":0,"max_queued":0,"not_running_channels":"none","error_channels":"none","queued_channels":"none","error_counts":{}}'
: ({'st': fromJson(outputs.fetch_channel_statuses.body), 'sx':
fromJson(outputs.fetch_channel_statistics.body), 'prev':
(outputs.load_previous_error_counts.value ?? {})} | jq('
(.st.list.dashboardStatus // [] | if type == "array" then . else [.] end)
as $status | (.sx.list.channelStatistics // [] | if type == "array" then .
else [.] end) as $stats | .prev as $prev | ([$stats[] | {key: .channelId,
value: .}] | from_entries) as $byid | [$status[] | . as $s |
($byid[$s.channelId] // {}) as $c | ($c.error // 0) as $err |
($prev[$s.channelId] // null) as $p | {id: $s.channelId, name: $s.name,
state: $s.state, queued: ($c.queued // 0), error: $err, new_errors: (if $p
== null then 0 elif $err >= $p then $err - $p else $err end)}] as $ch |
[$ch[] | select(.state != "STARTED")] as $down | [$ch[] |
select(.new_errors > 0)] as $erring | ([$ch[].new_errors] | add // 0) as
$newerr | ([$ch[].queued] | max // 0) as $maxq | {channels: ($ch |
length), not_running: ($down | length), new_errors: $newerr, max_queued:
$maxq, not_running_channels: (if ($down | length) == 0 then "none" else
([$down[] | .name + " (" + .state + ")"] | join(", ")) end),
error_channels: (if ($erring | length) == 0 then "none" else ([$erring[] |
.name + " (+" + (.new_errors | tostring) + ")"] | join(", ")) end),
queued_channels: ([$ch[] | select(.queued >= ' ~ inputs.warning_queued ~
') | .name + " (" + (.queued | tostring) + ")"] | if length == 0 then
"none" else join(", ") end), largest_queue: (if $maxq == 0 then "none"
else ($ch | max_by(.queued) | .name + " (" + (.queued | tostring) + ")")
end), error_counts: ([$ch[] | {key: .id, value: .error}] | from_entries),
first_run: ($prev | length == 0), status: (if ($ch | length) == 0 then
"UNREACHABLE" elif ($down | length) > 0 or $maxq >= ' ~
inputs.critical_queued ~ ' or $newerr >= ' ~ inputs.critical_new_errors ~
' then "CRITICAL" elif $newerr > 0 or $maxq >= ' ~ inputs.warning_queued ~
' then "WARNING" else "HEALTHY" end)} ') | first | toJson) }}
- id: save_error_counts
type: io.kestra.plugin.core.kv.Set
description: Save this run's per-channel error counters for the next run.
Skipped when the engine could not be read, so an outage never resets the
baseline.
runIf: "{{ fromJson(outputs.classify_engine.value).status != 'UNREACHABLE' }}"
key: "mirth_error_counts_{{ inputs.mirth_url | slugify }}"
value: "{{ fromJson(outputs.classify_engine.value).error_counts | toJson }}"
kvType: JSON
- id: route_by_engine_status
type: io.kestra.plugin.core.flow.Switch
description: Route to a quiet log when every channel is started with no new
errors and short queues, an informational Slack message for new errors or
a growing queue, or a Slack page when a channel is stopped, queues or
errors cross the critical line, or the engine could not be read.
value: "{{ fromJson(outputs.classify_engine.value).status }}"
cases:
"HEALTHY":
- id: log_healthy
type: io.kestra.plugin.core.log.Log
description: Log a quiet confirmation when every channel is started, no new
errors appeared and no queue reached the warning line.
message: "Mirth Connect healthy: {{
fromJson(outputs.classify_engine.value).channels }} channel(s)
started, no new errors{{
fromJson(outputs.classify_engine.value).first_run ? ' (first run,
error baseline saved)' : '' }}, largest queue {{
fromJson(outputs.classify_engine.value).max_queued }}."
"WARNING":
- id: alert_warning_engine
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Post an informational Slack message naming the channels with new
errors and the channels with a growing queue.
url: "{{ secret('SLACK_WEBHOOK') }}"
payload: |
{
"text": "Mirth Connect WARNING ({{ fromJson(outputs.classify_engine.value).channels }} channel(s)).{% if fromJson(outputs.classify_engine.value).new_errors > 0 %} New errors since the last check: {{ fromJson(outputs.classify_engine.value).error_channels }}.{% endif %}{% if fromJson(outputs.classify_engine.value).queued_channels != 'none' %} Queues at {{ inputs.warning_queued }}+: {{ fromJson(outputs.classify_engine.value).queued_channels }}.{% endif %} Execution {{ execution.id }}."
}
"CRITICAL":
- id: alert_critical_engine
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Page Slack when a channel is not running, a queue crosses
critical_queued, or new errors cross critical_new_errors; HL7
messages are not reaching their destination.
url: "{{ secret('SLACK_WEBHOOK') }}"
payload: |
{
"text": "Mirth Connect CRITICAL ({{ fromJson(outputs.classify_engine.value).channels }} channel(s)).{% if fromJson(outputs.classify_engine.value).not_running > 0 %} Not running: {{ fromJson(outputs.classify_engine.value).not_running_channels }}.{% endif %}{% if fromJson(outputs.classify_engine.value).new_errors > 0 %} New errors since the last check: {{ fromJson(outputs.classify_engine.value).error_channels }}.{% endif %}{% if fromJson(outputs.classify_engine.value).max_queued > 0 %} Largest queue: {{ fromJson(outputs.classify_engine.value).largest_queue }}.{% endif %} HL7 messages may not be reaching their destination. Execution {{ execution.id }}."
}
"UNREACHABLE":
- id: alert_unreachable_mirth
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Page Slack when the Mirth Connect API could not be read, rejected
the credentials, or returned no deployed channels.
url: "{{ secret('SLACK_WEBHOOK') }}"
payload: |
{
"text": "Mirth Connect UNREACHABLE: {{ inputs.mirth_url }} did not return channel statuses and statistics (server down, wrong URL or port, rejected credentials, or no channel deployed). Integration status is unknown. Execution {{ execution.id }}."
}
defaults:
- id: log_unclassified
type: io.kestra.plugin.core.log.Log
description: Log any classification outside the four known values, so a change
in the jq query never fails silently.
message: "Mirth Connect channel health sentinel completed with an unexpected
classification: {{ outputs.classify_engine.value }}."
- id: log_audit_result
type: io.kestra.plugin.core.log.Log
description: Record one audit line for every run, whichever branch fired, with
both HTTP codes and the classification, so the check's own history stays
queryable even if Slack delivery fails.
message: "Mirth Connect audit: server={{ inputs.mirth_url }} statuses_http={{
outputs.fetch_channel_statuses.code ?? 'n/a' }} statistics_http={{
outputs.fetch_channel_statistics.code ?? 'n/a' }} result={{
outputs.classify_engine.value }} execution={{ execution.id }}."
errors:
- id: alert_flow_failure
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Alert Slack when the sentinel flow itself fails outright, so a
broken check is never mistaken for a healthy integration engine.
url: "{{ secret('SLACK_WEBHOOK') }}"
payload: |
{
"text": "Mirth Connect channel health sentinel flow {{ flow.id }} FAILED (execution {{ execution.id }}). Integration status at {{ inputs.mirth_url }} is unknown; check the task logs and the Mirth dashboard manually."
}
outputs:
- id: status
type: STRING
value: "{{ outputs.classify_engine is defined ?
fromJson(outputs.classify_engine.value).status : 'UNREACHABLE' }}"
- id: engine
type: JSON
value: "{{ outputs.classify_engine is defined ? outputs.classify_engine.value :
'{\"status\":\"UNREACHABLE\"}' }}"