Script Health Checks
The Script health check plugin exposes two collectors that run on whatever satellite the check is bound to:
- Shell Script - your text is fed to
sh -c, so pipes, redirects, variable expansion,if/for/while, command substitution, etc. behave exactly like they do in a terminal. - Inline Script - your text is executed as a real ES module by a
freshly-spawned Bun subprocess, so you can
importfromnode:os,node:fs/promises,node:child_processand so on, use top-levelawait, and return your result viaexport default.
Use shell when the check is naturally a one-liner over the system
(awk, curl, df, …); use inline TS/JS when you want a real
programming language with the Node/Bun standard library at hand.
Shell scripts
Section titled “Shell scripts”The collector takes one field, script, plus optional cwd, env, and
timeout. Exit code 0 is healthy; anything else is unhealthy. stdout
and stderr are captured and shown with the run.
Example - fail when 1-minute load average exceeds 0.60
Section titled “Example - fail when 1-minute load average exceeds 0.60”load=$(uptime | sed 's/.*load average[s]*: //' | awk '{ print $1 }' | tr -d ',')awk -v l="$load" -v t=0.60 'BEGIN { exit (l+0 > t) ? 1 : 0 }'echo "load_ok=$load"That works precisely because the script is run through sh -c -
awk, the $(...) command substitution, the pipes, and the -v flag
passing are all handled by the shell, not by Bun.spawn parsing argv.
We use uptime | … rather than awk '{print $1}' /proc/loadavg so
the same script runs on Linux and macOS satellites. The two uptime
formats differ slightly (Linux: load average: 0.00, 0.01, 0.05;
macOS: load averages: 0.45 0.55 0.65); the sed/tr pipeline above
handles both.
Available environment variables
Section titled “Available environment variables”The satellite forwards only a curated whitelist to the script:
PATH, HOME, USER, LANG, LC_ALL, LC_CTYPE, TZ, TMPDIR,
HOSTNAME, SHELL. Everything else is stripped, including any
secrets the satellite process itself was started with. You can add
custom vars (e.g. API_TOKEN) via the collector’s env field, in
which case they’re merged on top of the whitelist for that one
invocation.
Working directory
Section titled “Working directory”cwd is optional and defaults to the satellite’s current directory.
Set it explicitly when your script reads relative paths.
Inline scripts (TypeScript / JavaScript)
Section titled “Inline scripts (TypeScript / JavaScript)”The collector takes a script field - a TypeScript/JavaScript module
source - plus a timeout. The runner exposes one runtime global
(context.config) and otherwise gives you the full Node/Bun stdlib.
Example - load average via node:os
Section titled “Example - load average via node:os”import { loadavg } from "node:os";import { defineHealthCheck } from "@checkstack/healthcheck";
const load = loadavg()[0];export default defineHealthCheck({ success: load < 0.60, message: `1m load average is ${load.toFixed(2)}`, value: load,});That import { loadavg } from "node:os" works because the user
script is written to a temp .mjs file and executed by Bun as a
real ESM module - there’s no eval, no Function constructor, no
Web Worker; it’s an actual subprocess that can do everything a
standalone Bun script can.
defineHealthCheck from the virtual @checkstack/healthcheck
module is recommended but optional. It’s a runtime identity
function - its only job is to assert at the type level that you
return a valid HealthCheckScriptResult, so Monaco catches
mistakes like { success: "yes" } before the script ever runs.
Result shape
Section titled “Result shape”export default whatever shape fits - the runner normalises it:
| Returned value | Treated as |
|---|---|
{ success: true, message?: string, value?: number } | The literal result (value feeds the metric chart). |
true / false | success of that boolean. |
number | success: true, with value set to that number. |
undefined (or no export default) | success: true. |
| (throws) | success: false, error message comes from the throw. |
You can also export default async (context) => ... if you want a
function form - the runner awaits the call with the context object.
Backwards compatibility - legacy return X;
Section titled “Backwards compatibility - legacy return X;”Scripts written for the older inline-script collector that used
return { success: ... } at the top level still work. If your source
has no import or export at the top, the runner wraps it in an
async IIFE that becomes the default export:
// Legacy - still validreturn { success: true, message: "All good" };You only need export default once you start mixing in real ESM
features (import ..., top-level await).
Editor support
Section titled “Editor support”The configuration UI uses Monaco with full TypeScript IntelliSense:
- The real upstream
@types/nodeandbun-typesdeclaration files are mounted as a virtual filesystem (lazy-loaded as a separate JS chunk when you open the editor), soloadavg,readFile,spawn, theBunglobal,process.env, etc. all autocomplete and check. context.configis typed from the collector’s own JSON Schema, so the fields you’ve added to the configuration are autocompletable from inside the script.- A virtual
@checkstack/healthcheckmodule exposesdefineHealthCheckand theHealthCheckScriptResultinterface - when you writeexport default defineHealthCheck({ ... }), Monaco type-checks the object literal against the expected shape and flags mistakes inline. - DOM types (
AudioContext,Canvas, …) are deliberately excluded to keep the suggestion list focused on the backend surface. - When you open an empty inline-script field, the editor is pre-seeded
with a runnable starter that imports
node:osand usesdefineHealthCheckso you have a working example to copy from.
For shell scripts, the editor offers env-var autocomplete: typing $
or ${ brings up the platform-forwarded vars (PATH, HOME, TZ,
…). The integration shell editor extends this list with the
event-injected vars (EVENT_ID, DELIVERY_ID, PAYLOAD_* flattened
from the event payload).
Logging
Section titled “Logging”Anything you write to stdout (console.log, console.info, …) is
captured and surfaced as the run’s message when your script doesn’t
return an explicit message. stderr is also captured but reserved
for the runner’s result marker - your console.error calls survive
the parse and are propagated.
Concurrency and cleanup
Section titled “Concurrency and cleanup”Both collectors are safe to run in parallel against the same satellite. Each invocation:
- Spawns its own subprocess (
sh -cfor shell,bun runner.mjsfor inline) with its own stdout/stderr pipes. - For inline scripts only: gets its own temp directory created via
mkdtemp(POSIX-guaranteed unique), so two parallel runs can never read each other’suser.mjs. - Tags its result with a UUID-based marker, so even if a user script happens to write the literal text of another invocation’s marker to stderr, parsing stays unambiguous.
Cleanup is in finally - the temp directory is removed and any
subprocess is killed, on success, on throw, and on timeout. The
internal timeout handle is cleared explicitly so a fast script
doesn’t leak an event-loop timer past return.
Security model
Section titled “Security model”The plugin assumes you trust the user to write scripts that run on
the satellite. There is no sandbox - sh -c runs as the
satellite process’s UID, and the inline-script subprocess inherits
the same. What we do guarantee:
- Only the whitelisted env vars (see above) are forwarded, so secrets from the parent process are not visible to the script.
- The user’s
importstatements load files from the satellite’s module graph; they do not load anything from the network unless the script itself callsfetch.
If you need stronger isolation for less-trusted authors, deploy a dedicated satellite with its own UID and resource limits, and bind the scripted health checks to that satellite.
Working with existing checks
Section titled “Working with existing checks”The shell collector was migrated from a { command, args } shape to
a single script field at platform version 2. Existing checks are
auto-migrated on load - your old command + args are joined into one
script string with POSIX single-quote escaping, so the behaviour is
preserved verbatim. You can edit the resulting script to take
advantage of the new shell features (pipes, conditionals, …) any
time.