Skip to main content

Monitoring - health and metrics

std/monitoring is a mechanism, not a list of things it knows how to watch. It defines what a monitor looks like, and loops over every implementation to build the reports. Each subsystem contributes its own numbers, so the standard library stays decoupled: std/monitoring never mentions HTTP or a data source, and std/web and std/query never mention monitoring.

std/monitoring.xi Monitoring, MonitoringRegistry (imports json only)
std/monitoring/memory.xi MemoryMonitoring
std/monitoring/cpu.xi CpuMonitoring
std/monitoring/web.xi WebMonitoring + HTTP endpoints
std/monitoring/query.xi QueryMonitoring (probes the bound QueryProvider)
std/monitoring/thread.xi ThreadMonitoring

Import only what you use. A batch job importing std/monitoring/thread.xi pulls in no web code, and a program that imports none of them has no monitoring at all.

Off by default

Monitoring does nothing until you switch it on. Importing a module makes it available, not running - including the HTTP endpoints, which answer 404 while monitoring is off. Enable it in main:

import "std/monitoring/memory.xi"
import "std/monitoring/web.xi"
import "std/monitoring/query.xi"

module App {
id = "billing"
name = "Billing Service"
version = "2.1.0"

entry (mon: MonitoringRegistry as singleton) main(args: String[]) -> Integer {
mon.enable() // starts every imported monitor
web.serve(8080)
return 0
}
}

enable() calls startMonitor() on every Monitoring implementation - the query monitor takes its first probe, the memory monitor records its baseline.

Mark the dependency as singleton. Without it each injection site gets its own registry, so the one main enables is not the one the endpoints answer from, and everything looks disabled.

Gate it on whatever you like - a flag, an env var, a config field:

entry (mon: MonitoringRegistry as singleton, cfg: AppConfig) main(args: String[]) -> Integer {
if cfg.monitoringEnabled() { mon.enable() }
web.serve(8080)
return 0
}

The two interfaces

// One subsystem that can be watched.
interface Monitoring {
mapper name() -> String // labels it in the reports
consumer startMonitor() // called once, when monitoring is enabled
producer healthy() -> Bool // usable right now? (a producer: may probe)
producer metrics() -> Json // its numbers
}

// Every Monitoring in the program, assembled.
interface MonitoringRegistry {
consumer enable()
predicate isEnabled() -> Bool
producer report() -> Json // uptime + gauges + every monitor
producer health() -> Json // {"status": …, "checks": {…}}
producer healthy() -> Bool
}

healthy() is a producer, not a predicate, precisely so a check may do work - probing a data source is a round trip, not a pure function.

Adding your own

Implement Monitoring and it joins the loop - no registration call, no bind:

class CacheMonitoring implements Monitoring {
deps { cache: Cache }
mapper name() -> String => "cache"
consumer startMonitor() { }
producer healthy() -> Bool => cache.reachable()
producer metrics() -> Json {
let o = json.object()
return json.set(o, "entries", json.int(cache.size()))
}
}

The bundled monitors are not special-cased - each implements exactly this.

ImportClassReports
std/monitoring/memory.xiMemoryMonitoringrssBytes, peakRssBytes, growthBytes
std/monitoring/cpu.xiCpuMonitoringuserMs, systemMs, busyPercent
std/monitoring/web.xiWebMonitoringrequests served
std/monitoring/query.xiQueryMonitoringprovider, probes, failures; health runs a real query
std/monitoring/thread.xiThreadMonitoringspawned, live

MemoryMonitoring and ThreadMonitoring accept a ceiling (maxRssBytes, maxLive); leave it 0 and they report numbers without ever claiming unhealthy.

Reporting things directly

For something the app already knows, push it - no class needed:

monitor.status("migrations", done) // joins the health report
monitor.gauge("queueDepth", n) // joins the metrics

Setting the same name again updates it in place. A status reported false takes overall health down, exactly like a failing monitor.

Process readings are always available, whether or not monitoring is enabled:

FunctionReturns
monitor.rss() / monitor.peakRss()resident / peak bytes
monitor.uptimeMs()milliseconds since start
monitor.info(n)module id (0), name (1), version (2)
monitor.snapshot()uptime + reported gauges
monitor.markStart()reset the uptime origin

rss is read from the OS (task_info on macOS, /proc/self/statm on Linux), so it reflects what the process actually occupies.

Reading the reports

Inject MonitoringRegistry anywhere:

scheduled heartbeat() cron "0 * * * * *" {
logger.info(json.stringify(mon.report()))
}

HTTP endpoints

std/monitoring/web.xi ships a controller that exposes the reports. It auto-registers like any WebRequestHandler, and is live only while monitoring is enabled:

EndpointReturns
GET /monitor/health{"status":"UP","checks":{…}}, 503 when DOWN, 404 when disabled
GET /monitor/infomodule id, name, version
GET /monitor/metricsuptime, gauges, and every monitor
$ curl localhost:8080/monitor/health
{"status":"UP","checks":{"memory":"UP","cpu":"UP","web":"UP","query":"UP","threads":"UP"}}

$ curl localhost:8080/monitor/metrics
{"uptimeMs":2046,"memory":{"rssBytes":1900544,"peakRssBytes":1900544,"growthBytes":98304},
"cpu":{"userMs":1,"systemMs":2,"busyPercent":0},"web":{"requests":3},
"query":{"provider":"memory","probes":7,"failures":0},"threads":{"spawned":0,"live":0}}

Your own controllers keep working alongside it - each request goes to the one whose where guard matches.

Want a different shape? The controller is optional. Inject MonitoringRegistry into a controller of your own for different paths, auth, or a subset - health() and report() hand you the same Json:

class OpsController implements WebRequestHandler {
deps { mon: MonitoringRegistry as singleton }
action handle(req: HttpRequest, res: HttpResponse) where req.path == "/healthz" {
if mon.healthy() { res.sendText(200, "ok") } else { res.sendText(503, "unhealthy") }
}
}

See examples/web/monitor_demo.xi for a runnable service.